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 'Unknown' }}
+ {% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
{{ item.time_ago }}