Refactor..
Some checks failed
DevPlace CI / test (push) Failing after 1m22s

This commit is contained in:
retoor 2026-05-23 08:34:13 +02:00
parent 18c9f20090
commit 8c9da9df98
34 changed files with 211 additions and 367 deletions

View File

@ -60,7 +60,6 @@ def cmd_news_sanitize(args):
def cmd_attachments_prune(args):
from devplacepy.database import db
from devplacepy.config import STATIC_DIR
import os
deleted_records = 0
deleted_files = 0
freed_bytes = 0

View File

@ -326,6 +326,31 @@ def resolve_by_slug(table, slug):
return entry
VOTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"project": "projects",
"gist": "gists",
"comment": "comments",
}
STAR_TARGETS: set[str] = {"post", "project", "gist"}
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name or target_type not in STAR_TARGETS:
return
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return None
row = get_table(table_name).find_one(uid=target_uid)
return row["user_uid"] if row else None
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))

View File

@ -11,7 +11,7 @@ from devplacepy.config import STATIC_DIR, PORT
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
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService

View File

@ -1,10 +1,9 @@
import logging
from typing import Annotated
from datetime import datetime
from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema

View File

@ -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
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
@ -82,13 +82,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(uid, "Member")
token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302)

View File

@ -5,8 +5,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__)
@ -60,49 +59,20 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Comment",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Comment")
if target_type == "post":
if parent_uid:
comments_table = get_table("comments")
parent = comments_table.find_one(uid=parent_uid)
parent = get_table("comments").find_one(uid=parent_uid)
if parent and parent["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": parent["user_uid"],
"type": "reply",
"message": f"{user['username']} replied to your comment",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(parent["user_uid"])
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"])
else:
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
if not post:
post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": post["user_uid"],
"type": "comment",
"message": f"{user['username']} commented on your post",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(post["user_uid"])
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"])
create_mention_notifications(content, user["uid"], redirect_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")

View File

@ -3,9 +3,10 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.utils import get_current_user
from devplacepy.seo import base_seo_context, site_url, website_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -48,19 +49,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
if not posts:
return [], next_cursor
uids = [p["user_uid"] for p in posts]
post_uids = [p["uid"] for p in posts]
authors = get_users_by_uids(uids)
counts = get_comment_counts_by_post_uids(post_uids)
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = []
for post in posts:
result.append({
"post": post,
"author": authors.get(post["user_uid"]),
"time_ago": time_ago(post["created_at"]),
"comment_count": counts.get(post["uid"], 0),
})
result = enrich_items(posts, "post", authors, {"comment_count": counts})
return result, next_cursor

View File

@ -3,8 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.utils import generate_uid, require_user, create_notification
logger = logging.getLogger(__name__)
router = APIRouter()
@ -30,17 +29,7 @@ async def follow_user(request: Request, username: str):
"created_at": datetime.now(timezone.utc).isoformat(),
})
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target["uid"],
"type": "follow",
"message": f"{user['username']} started following you",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target["uid"])
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"])
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)

View File

@ -4,11 +4,11 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -37,19 +37,8 @@ def get_gists_list(user_uid=None, language=None):
if not all_gists:
return []
from devplacepy.database import get_users_by_uids
uids = [g["user_uid"] for g in all_gists]
users_map = get_users_by_uids(uids)
result = []
for g in all_gists:
author = users_map.get(g["user_uid"])
result.append({
"gist": g,
"author": author,
"time_ago": time_ago(g["created_at"]),
})
return result
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
return enrich_items(all_gists, "gist", users_map)
@router.get("", response_class=HTMLResponse)
@ -82,23 +71,10 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
@router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist:
detail = load_detail("gists", "gist", gist_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Gist not found")
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids([gist["user_uid"]])
author = users_map.get(gist["user_uid"])
is_owner = user and user["uid"] == gist["user_uid"]
ups, downs = get_vote_counts([gist["uid"]])
star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0)
comments = load_comments("gist", gist["uid"])
gist_attachments = get_attachments("gist", gist["uid"])
gist = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@ -118,13 +94,13 @@ async def gist_detail(request: Request, gist_slug: str):
"request": request,
"user": user,
"gist": gist,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
"time_ago": time_ago(gist["created_at"]),
"comments": comments,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"languages": LANGUAGES,
"attachments": gist_attachments,
"attachments": detail["attachments"],
})
@ -167,46 +143,18 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
@router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist or gist["user_uid"] != user["uid"]:
return RedirectResponse(url="/gists", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
if language not in {l[0] for l in LANGUAGES}:
language = "plaintext"
gists.update({
"uid": gist["uid"],
"title": title,
"description": description or None,
"source_code": source_code,
return edit_content_item("gists", user, gist_slug, {
"title": data.title.strip(),
"description": data.description.strip() or None,
"source_code": data.source_code.strip(),
"language": language,
}, ["uid"])
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
}, "/gists")
@router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if gist and gist["user_uid"] == user["uid"]:
from devplacepy.attachments import delete_target_attachments
delete_target_attachments("gist", gist["uid"])
if "comments" in db.tables:
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
delete_target_attachments("comment", c["uid"])
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
if "votes" in db.tables:
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
gists.delete(id=gist["id"])
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
return RedirectResponse(url="/gists", status_code=302)
return delete_content_item("gists", "gist", user, gist_slug, "/gists")

View File

@ -68,8 +68,9 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
if "messages" in db.tables:
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})

View File

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema
logger = logging.getLogger(__name__)
router = APIRouter()

View File

@ -4,11 +4,12 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
from devplacepy.content import edit_content_item, delete_content_item
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
@ -48,15 +49,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Post",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Post")
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
@ -136,32 +129,14 @@ async def view_post(request: Request, post_slug: str):
@router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post or post["user_uid"] != user["uid"]:
return RedirectResponse(url="/feed", status_code=302)
posts.update({
"uid": post["uid"],
return edit_content_item("posts", user, post_slug, {
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
}, ["uid"])
logger.info(f"Post {post['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
}, "/feed")
@router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if post and post["user_uid"] == user["uid"]:
delete_target_attachments("post", post["uid"])
get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"])
delete_inline_image(post.get("image"))
posts.delete(id=post["id"])
logger.info(f"Post {post['uid']} deleted by {user['username']}")
return RedirectResponse(url="/feed", status_code=302)
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")

View File

@ -5,8 +5,8 @@ from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user(request)
require_user_api(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:

View File

@ -5,11 +5,12 @@ from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
from devplacepy.content import load_detail, delete_content_item
from devplacepy.attachments import link_attachments
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@ -82,22 +83,10 @@ async def projects_page(
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str):
user = get_current_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if not project:
detail = load_detail("projects", "project", project_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Project not found")
users_map = get_users_by_uids([project["user_uid"]])
author = users_map.get(project["user_uid"])
is_owner = user and user["uid"] == project["user_uid"]
ups, downs = get_vote_counts([project["uid"]])
star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0)
comments = load_comments("project", project["uid"])
project_attachments = get_attachments("project", project["uid"])
project = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@ -116,25 +105,19 @@ async def project_detail(request: Request, project_slug: str):
"request": request,
"user": user,
"project": project,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": comments,
"attachments": project_attachments,
"comments": detail["comments"],
"attachments": detail["attachments"],
})
@router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str):
user = require_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if project and project["user_uid"] == user["uid"]:
delete_target_attachments("project", project["uid"])
projects.delete(id=project["id"])
logger.info(f"Project {project['uid']} deleted by {user['username']}")
return RedirectResponse(url="/projects", status_code=302)
return delete_content_item("projects", "project", user, project_slug, "/projects")
@router.post("/create")

View File

@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user
from devplacepy.utils import require_user_api
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__)
@ -12,7 +12,7 @@ router = APIRouter()
@router.post("/upload")
async def upload_file(request: Request):
user = require_user(request)
user = require_user_api(request)
form = await request.form()
file = form.get("file")
@ -49,7 +49,7 @@ async def upload_file(request: Request):
@router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user(request)
user = require_user_api(request)
att = get_table("attachments").find_one(uid=attachment_uid)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)

View File

@ -3,14 +3,15 @@ from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
@ -39,46 +40,12 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count
if target_type == "post":
posts = get_table("posts")
posts.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "project":
projects = get_table("projects")
projects.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "gist":
gists = get_table("gists")
gists.update({"uid": target_uid, "stars": net}, ["uid"])
update_target_stars(target_type, target_uid, net)
if value == 1:
target_owner_uid = None
if target_type == "post":
target_owner = posts.find_one(uid=target_uid)
if target_owner:
target_owner_uid = target_owner["user_uid"]
elif target_type == "comment":
comments = get_table("comments")
target_comment = comments.find_one(uid=target_uid)
if target_comment:
target_owner_uid = target_comment["user_uid"]
elif target_type == "gist":
gists = get_table("gists")
target_gist = gists.find_one(uid=target_uid)
if target_gist:
target_owner_uid = target_gist["user_uid"]
if target_owner_uid and target_owner_uid != user["uid"]:
label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target_owner_uid,
"type": "vote",
"message": f"{user['username']} ++'d your {label}",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target_owner_uid)
if value == 1 and target_type in NOTIFY_ON_VOTE:
owner_uid = get_target_owner_uid(target_type, target_uid)
if owner_uid and owner_uid != user["uid"]:
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"])
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)

View File

@ -1,6 +1,5 @@
import json
import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL

View File

@ -126,12 +126,12 @@
.admin-btn-sm {
font-size: 0.6875rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
}
.admin-select {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
@ -152,7 +152,7 @@
.admin-input-sm {
width: 110px;
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);

View File

@ -408,12 +408,12 @@ img {
.btn-primary {
background: var(--accent);
color: #fff;
color: var(--white);
}
.btn-primary:hover {
background: var(--accent-hover);
color: #fff;
color: var(--white);
}
.btn-secondary {
@ -461,13 +461,13 @@ img {
letter-spacing: 0.05em;
}
.badge-devlog { background: var(--topic-devlog); color: #fff; }
.badge-showcase { background: var(--topic-showcase); color: #fff; }
.badge-question { background: var(--topic-question); color: #fff; }
.badge-rant { background: var(--topic-rant); color: #fff; }
.badge-devlog { background: var(--topic-devlog); color: var(--white); }
.badge-showcase { background: var(--topic-showcase); color: var(--white); }
.badge-question { background: var(--topic-question); color: var(--white); }
.badge-rant { background: var(--topic-rant); color: var(--white); }
.badge-fun { background: var(--topic-fun); color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); }
.badge-signals { background: var(--topic-signals); color: #fff; }
.badge-signals { background: var(--topic-signals); color: var(--white); }
.avatar {
width: 40px;
@ -479,7 +479,7 @@ img {
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #fff;
color: var(--white);
flex-shrink: 0;
overflow: hidden;
}
@ -535,7 +535,7 @@ img {
.topnav-icon:hover { color: var(--text-primary); }
.nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white);
font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
@ -723,16 +723,6 @@ img {
to { opacity: 1; transform: translateY(0); }
}
.post-author-link {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.post-author-link:hover {
color: var(--accent);
}
.icon {
font-size: 1rem;
width: 20px;

View File

@ -71,7 +71,7 @@
.post-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.post-header {

View File

@ -39,7 +39,7 @@
.gist-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.gist-card-header {

View File

@ -56,7 +56,7 @@
}
.news-card-body {
padding: 1.125rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
@ -104,7 +104,7 @@
}
.news-card-title {
font-size: 1.0625rem;
font-size: 1.125rem;
font-weight: 700;
line-height: 1.4;
margin: 0;
@ -270,7 +270,7 @@
color: var(--accent);
}
@media (max-width: 680px) {
@media (max-width: 768px) {
.news-grid {
grid-template-columns: 1fr;
}

View File

@ -18,9 +18,9 @@
.service-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-lg);
}
.service-header {
@ -32,7 +32,7 @@
.service-name {
font-weight: 600;
font-size: 1.1rem;
font-size: 1.125rem;
color: var(--text-primary);
text-transform: capitalize;
}
@ -60,7 +60,7 @@
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
font-size: 0.8rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}

View File

@ -28,6 +28,20 @@
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--white: #fff;
--overlay-dark: rgba(0, 0, 0, 0.7);
--overlay-light: rgba(255, 255, 255, 0.05);
--space-xs: 0.25rem;
--space-sm: 0.375rem;
--space-base: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.25rem;
--space-2xl: 1.5rem;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;

View File

@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader {
constructor(form) {
this.form = form;
@ -172,8 +174,7 @@ export class AttachmentUploader {
}
showError(msg) {
this.errorEl.textContent = msg;
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
Toast.flash(this.errorEl, msg, 5000, "");
}
}

View File

@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class DomUtils {
constructor() {
this.initClipboardCopy();
@ -14,9 +16,7 @@ export class DomUtils {
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
@ -32,9 +32,7 @@ export class DomUtils {
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}

View File

@ -1,3 +1,5 @@
import { TextInput } from "./TextInput.js";
export class EmojiPicker {
constructor(textarea) {
this.textarea = textarea;
@ -35,15 +37,7 @@ export class EmojiPicker {
}
insert(unicode) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
ta.value = text.substring(0, start) + unicode + text.substring(end);
const newPos = start + unicode.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
TextInput.insertAtCursor(this.textarea, unicode);
}
toggle() {

View File

@ -1,3 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
export class MentionInput {
constructor(element) {
this.input = element;
@ -50,8 +54,7 @@ export class MentionInput {
async fetch(query) {
try {
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
const data = await resp.json();
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
this.dropdown.style.display = "none";
@ -71,7 +74,7 @@ export class MentionInput {
item.type = "button";
item.className = "mention-dropdown-item";
item.dataset.username = r.username;
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>';
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.insert(r.username);
@ -121,11 +124,7 @@ export class MentionInput {
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
this.input.value = before + "@" + username + " " + after;
const newPos = before.length + username.length + 2;
this.input.setSelectionRange(newPos, newPos);
this.input.focus();
this.input.dispatchEvent(new Event("input", { bubbles: true }));
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
this.dropdown.style.display = "none";
this.lastMatch = null;
}

View File

@ -1,3 +1,6 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
export class MessageSearch {
constructor() {
this.initMessageSearch();
@ -26,8 +29,7 @@ export class MessageSearch {
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
@ -38,7 +40,7 @@ export class MessageSearch {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";

View File

@ -1,3 +1,5 @@
import { Http } from "./Http.js";
class ServiceMonitor {
constructor() {
this.pollInterval = 5000;
@ -29,8 +31,7 @@ class ServiceMonitor {
async pollServices() {
try {
const resp = await fetch("/admin/services/data");
const data = await resp.json();
const data = await Http.getJson("/admin/services/data");
const container = document.getElementById("services-list");
if (!container) return;
for (const svc of data.services) {

View File

@ -1,3 +1,5 @@
import { Http } from "./Http.js";
export class VoteManager {
constructor() {
this.initVoteButtons();
@ -6,37 +8,22 @@ export class VoteManager {
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
btn.addEventListener("click", () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
Http.postForm(`/votes/${targetType}/${targetUid}`, { value: btn.dataset.vote });
});
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
btn.addEventListener("click", () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
Http.postForm(`/notifications/mark-read/${uid}`);
});
});
}

View File

@ -5,12 +5,6 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.seo import (
site_url, combine,
website_schema, breadcrumb_schema,
discussion_forum_posting, profile_page_schema,
software_application_schema, truncate,
)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)

View File

@ -7,7 +7,7 @@ from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@ -86,6 +86,13 @@ def require_admin(request: Request):
return user
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return user
def strip_html(text: str) -> str:
if not text:
return ""
@ -140,13 +147,39 @@ def extract_mentions(content: str) -> list[str]:
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
def create_notification(user_uid: str, notification_type: str, message: str, related_uid: str, target_url: str | None = None) -> None:
from devplacepy.templating import clear_unread_cache
get_table("notifications").insert({
"uid": generate_uid(),
"user_uid": user_uid,
"type": notification_type,
"message": message,
"related_uid": related_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(user_uid)
def award_badge(user_uid: str, badge_name: str) -> bool:
badges = get_table("badges")
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
return False
badges.insert({
"uid": generate_uid(),
"user_uid": user_uid,
"badge_name": badge_name,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return True
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
if not usernames:
return
from devplacepy.templating import clear_unread_cache
users = get_table("users")
notifs = get_table("notifications")
seen = set()
for username in usernames:
if username in seen:
@ -154,17 +187,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
seen.add(username)
mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid:
notifs.insert({
"uid": generate_uid(),
"user_uid": mentioned["uid"],
"type": "mention",
"message": f"@{username} mentioned you",
"related_uid": actor_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(mentioned["uid"])
create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url)
def format_date(dt_str: str, include_time: bool = False) -> str:

View File

@ -60,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server):
def test_upload_requires_login(app_server):
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
assert r.status_code in (302, 303)
assert r.status_code == 401
def test_delete_own_allowed_other_user_forbidden(app_server):