From 503aab26acf6e0f6663c714c26fcfabd65c9d36f Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 2 Jun 2026 23:17:51 +0200 Subject: [PATCH] Update --- .gitignore | 1 + README.md | 4 +- devplacepy/config.py | 2 + devplacepy/content.py | 7 +- devplacepy/main.py | 26 ++- devplacepy/routers/auth.py | 3 +- devplacepy/routers/messages.py | 3 + devplacepy/routers/votes.py | 4 +- devplacepy/static/js/Avatar.js | 13 +- devplacepy/static/js/MentionInput.js | 4 +- devplacepy/static/js/MessageSearch.js | 4 +- devplacepy/static/js/ProfileEditor.js | 3 +- devplacepy/templates/_comment_section.html | 2 +- devplacepy/templates/_post_card.html | 2 +- devplacepy/templates/_post_header.html | 2 +- devplacepy/templates/_user_link.html | 5 + devplacepy/templates/feed.html | 2 +- devplacepy/templates/gist_detail.html | 2 +- devplacepy/templates/landing.html | 2 +- devplacepy/templates/leaderboard.html | 4 +- devplacepy/templates/messages.html | 2 +- devplacepy/templates/post.html | 2 +- devplacepy/templates/project_detail.html | 2 +- devplacepy/utils.py | 4 + locustfile.py | 220 ++++++++++++++++++--- tests/test_auth.py | 22 +-- tests/test_feed.py | 21 +- tests/test_post.py | 42 ++-- tests/test_push.py | 64 ++++++ tests/test_xss.py | 35 ++++ 30 files changed, 406 insertions(+), 103 deletions(-) create mode 100644 devplacepy/templates/_user_link.html create mode 100644 tests/test_push.py create mode 100644 tests/test_xss.py diff --git a/.gitignore b/.gitignore index 1b5990a..0a445cb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.egg-info/ .env devplace.db* +devplace-services.lock notification-private.pem notification-private.pkcs8.pem notification-public.pem diff --git a/README.md b/README.md index ea1f34c..6461231 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ Open `http://localhost:10500`. | Layer | Technology | |-------|-----------| -| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) | +| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) | | Templates | Jinja2 (server-side rendered) | | Frontend | Pure ES6 JavaScript, one class per file | | Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) | -| Auth | Session cookies, SHA256+SALT via passlib | +| Auth | Session cookies, PBKDF2-SHA256 via passlib | | Avatars | Multiavatar (local SVG generation, no external API, <5ms) | | Validation | `hawk` (Python/JS/CSS/HTML) | | Load testing | Locust (locustfile.py) | diff --git a/devplacepy/config.py b/devplacepy/config.py index b4e9c65..81d02c7 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -13,6 +13,8 @@ SESSION_MAX_AGE = 86400 * 7 PORT = 10500 SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/") +SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock" + VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem" VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem" VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem" diff --git a/devplacepy/content.py b/devplacepy/content.py index b6fea85..aeb29fa 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -75,13 +75,18 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str item = resolve_by_slug(table, slug) if is_owner(item, user): delete_target_attachments(target_type, item["uid"]) + comment_uids = [] if "comments" in db.tables: comments = get_table("comments") for comment in comments.find(target_uid=item["uid"]): + comment_uids.append(comment["uid"]) delete_target_attachments("comment", comment["uid"]) comments.delete(target_uid=item["uid"]) if "votes" in db.tables: - get_table("votes").delete(target_uid=item["uid"]) + votes = get_table("votes") + votes.delete(target_uid=item["uid"]) + if comment_uids: + votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment") if inline_image_field: delete_inline_image(item.get(inline_image_field)) table.delete(id=item["id"]) diff --git a/devplacepy/main.py b/devplacepy/main.py index 73b9692..348e243 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -1,4 +1,5 @@ import asyncio +import fcntl import logging import os import time @@ -7,7 +8,7 @@ from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.exceptions import RequestValidationError -from devplacepy.config import STATIC_DIR, PORT +from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids from devplacepy.templating import templates from devplacepy.utils import get_current_user, time_ago @@ -26,6 +27,20 @@ _rate_limit_store = defaultdict(list) RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60")) RATE_WINDOW = 60 +_service_lock_handle = None + + +def acquire_service_lock() -> bool: + global _service_lock_handle + handle = open(SERVICE_LOCK_FILE, "w") + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return False + _service_lock_handle = handle + return True + class UploadStaticFiles(StaticFiles): async def get_response(self, path, scope): response = await super().get_response(path, scope) @@ -145,9 +160,12 @@ async def startup(): from devplacepy.push import ensure_certificates ensure_certificates() if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): - news_service = NewsService() - service_manager.register(news_service) - asyncio.create_task(service_manager.start_all()) + if acquire_service_lock(): + service_manager.register(NewsService()) + asyncio.create_task(service_manager.start_all()) + logger.info(f"Background services started in worker pid {os.getpid()}") + else: + logger.info(f"Worker pid {os.getpid()} declined service lock; another worker owns background services") logger.info(f"DevPlace started on port {PORT}") diff --git a/devplacepy/routers/auth.py b/devplacepy/routers/auth.py index 948e2e6..df08772 100644 --- a/devplacepy/routers/auth.py +++ b/devplacepy/routers/auth.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse, HTMLResponse from devplacepy.database import get_table from devplacepy.templating import templates -from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge +from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache from devplacepy.seo import base_seo_context from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm @@ -205,6 +205,7 @@ async def logout(request: Request): session = sessions.find_one(session_token=token) if session: sessions.delete(id=session["id"]) + clear_session_cache(token) response = RedirectResponse(url="/", status_code=302) response.delete_cookie("session") return response diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index 611b654..0133de3 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -152,6 +152,9 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]): content = data.content.strip() receiver_uid = data.receiver_uid + if not get_table("users").find_one(uid=receiver_uid): + return RedirectResponse(url="/messages", status_code=302) + messages_table = get_table("messages") msg_uid = generate_uid() messages_table.insert({ diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index 22c1930..de6ee7c 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -39,8 +39,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota }) did_upvote = value == 1 - up_count = votes.count(target_uid=target_uid, value=1) - down_count = votes.count(target_uid=target_uid, value=-1) + up_count = votes.count(target_uid=target_uid, target_type=target_type, value=1) + down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1) net = up_count - down_count update_target_stars(target_type, target_uid, net) diff --git a/devplacepy/static/js/Avatar.js b/devplacepy/static/js/Avatar.js index b81cb61..9f4a1e4 100644 --- a/devplacepy/static/js/Avatar.js +++ b/devplacepy/static/js/Avatar.js @@ -1,7 +1,14 @@ export class Avatar { - static imgHtml(username, size = 24) { - const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`; - return ``; + static imgElement(username, size = 24) { + const img = document.createElement("img"); + img.src = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`; + img.className = "avatar-img"; + img.style.width = `${size}px`; + img.style.height = `${size}px`; + img.style.borderRadius = "50%"; + img.alt = ""; + img.loading = "lazy"; + return img; } } diff --git a/devplacepy/static/js/MentionInput.js b/devplacepy/static/js/MentionInput.js index 3a3571a..7e02d65 100644 --- a/devplacepy/static/js/MentionInput.js +++ b/devplacepy/static/js/MentionInput.js @@ -75,7 +75,9 @@ export class MentionInput { item.type = "button"; item.className = "mention-dropdown-item"; item.dataset.username = r.username; - item.innerHTML = Avatar.imgHtml(r.username) + "@" + r.username + ""; + const label = document.createElement("span"); + label.textContent = "@" + r.username; + item.append(Avatar.imgElement(r.username), label); item.addEventListener("mousedown", (e) => { e.preventDefault(); this.insert(r.username); diff --git a/devplacepy/static/js/MessageSearch.js b/devplacepy/static/js/MessageSearch.js index c77b75f..c9bf5ac 100644 --- a/devplacepy/static/js/MessageSearch.js +++ b/devplacepy/static/js/MessageSearch.js @@ -41,7 +41,9 @@ export class MessageSearch { const item = document.createElement("a"); item.className = "search-dropdown-item"; item.href = `/messages?with_uid=${r.uid}`; - item.innerHTML = `${Avatar.imgHtml(r.username)}${r.username}`; + const label = document.createElement("span"); + label.textContent = r.username; + item.append(Avatar.imgElement(r.username), label); dropdown.appendChild(item); } DomUtils.show(dropdown); diff --git a/devplacepy/static/js/ProfileEditor.js b/devplacepy/static/js/ProfileEditor.js index f7acf6c..b298c3a 100644 --- a/devplacepy/static/js/ProfileEditor.js +++ b/devplacepy/static/js/ProfileEditor.js @@ -34,6 +34,7 @@ export class ProfileEditor { const tag = document.createElement("span"); tag.className = "platform-tag"; tag.textContent = val; + tag.dataset.value = val; const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "x"; @@ -57,7 +58,7 @@ export class ProfileEditor { const updatePlatforms = () => { const values = []; tagsContainer.querySelectorAll(".platform-tag").forEach((t) => { - values.push(t.textContent.replace("x", "").trim()); + values.push(t.dataset.value); }); hiddenInput.value = values.join(","); }; diff --git a/devplacepy/templates/_comment_section.html b/devplacepy/templates/_comment_section.html index 4d4f9af..ec82627 100644 --- a/devplacepy/templates/_comment_section.html +++ b/devplacepy/templates/_comment_section.html @@ -20,7 +20,7 @@ {{ item.author['username'] if item.author else '?' }} - {{ item.author['username'] if item.author else 'Unknown' }} + {% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %} {{ item.time_ago }}
{{ item.comment['content'] }}
diff --git a/devplacepy/templates/_post_card.html b/devplacepy/templates/_post_card.html index f0ec215..34b2a21 100644 --- a/devplacepy/templates/_post_card.html +++ b/devplacepy/templates/_post_card.html @@ -35,7 +35,7 @@
- +
{% endif %} diff --git a/devplacepy/templates/_post_header.html b/devplacepy/templates/_post_header.html index 4d3de44..6924cc0 100644 --- a/devplacepy/templates/_post_header.html +++ b/devplacepy/templates/_post_header.html @@ -1,7 +1,7 @@
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}