Compare commits

..
2 Commits
Author SHA1 Message Date
retoor 49853e079b Update. 2026-05-23 08:31:16 +02:00
retoor 2afb2038e5 iUpdate 2026-05-23 08:27:04 +02:00
85 changed files with 498 additions and 1745 deletions
+3
View File
@@ -32,3 +32,6 @@ jobs:
name: failure-screenshots name: failure-screenshots
path: /tmp/devplace_test_screenshots/ path: /tmp/devplace_test_screenshots/
- name: Deploy to production
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/master'
run: make deploy
-3
View File
@@ -3,9 +3,6 @@ __pycache__/
*.egg-info/ *.egg-info/
.env .env
devplace.db* devplace.db*
notification-private.pem
notification-private.pkcs8.pem
notification-public.pem
.pytest_cache/ .pytest_cache/
.opencode .opencode
devplacepy/static/uploads/attachments/ devplacepy/static/uploads/attachments/
+13 -9
View File
@@ -55,13 +55,10 @@ make locust-headless # Locust in headless CLI mode (for CI)
1. **Emoji shortcodes** → Unicode emoji (`:fire:` → 🔥, 80+ shortcodes) 1. **Emoji shortcodes** → Unicode emoji (`:fire:` → 🔥, 80+ shortcodes)
2. **Markdown parse** → via `marked` with GFM tables, line breaks 2. **Markdown parse** → via `marked` with GFM tables, line breaks
3. **Sanitize**`DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output 3. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks
4. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks 4. **Image URLs** → standalone `.jpg/.png/.gif` URLs become `<img>` tags
5. **Image URLs**standalone `.jpg/.png/.gif` URLs become `<img>` tags 5. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
6. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players 6. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
7. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
**Sanitization is the XSS control.** Content is rendered client-side from `element.textContent`, so Jinja autoescaping does not protect it. `DOMPurify.sanitize` (vendored at `static/vendor/purify.min.js`, loaded `defer` in `base.html`) runs on the raw `marked` output before `processMedia` injects the trusted YouTube iframes, so user payloads are removed while our embeds survive. It is fail-closed: `render()` throws if `DOMPurify` is missing rather than emitting unsanitized HTML — never relax this into a `typeof` skip.
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched. **Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
@@ -74,7 +71,6 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
```html ```html
<script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script> <script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script> <script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script defer src="/static/js/ContentRenderer.js"></script> <script defer src="/static/js/ContentRenderer.js"></script>
<script defer src="/static/js/EmojiPicker.js"></script> <script defer src="/static/js/EmojiPicker.js"></script>
@@ -567,11 +563,19 @@ All tests must pass. Tests stop at first failure (`-x`).
### Step 7: Run full suite again (only if asked by user) ### Step 7: Run full suite again (only if asked by user)
```bash ```bash
hawk .
make test make test
make test-headed # visual confirmation make test-headed # visual confirmation
``` ```
### Step 8: Document ### Step 8: Visual verification (if UI changed)
```bash
falcon take --output /tmp/verify.png
falcon describe /tmp/verify.png
```
### Step 9: Document
- Update `AGENTS.md` if new conventions introduced - Update `AGENTS.md` if new conventions introduced
- Update `README.md` if new routes, config, or dependencies added - Update `README.md` if new routes, config, or dependencies added
+3 -66
View File
@@ -38,10 +38,9 @@ devplacepy/
database.py # dataset connection, index creation database.py # dataset connection, index creation
templating.py # Shared Jinja2 environment + globals templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder avatar.py # Multiavatar generation, URL builder
utils.py # Password hashing, session mgmt, time_ago, notification hook utils.py # Password hashing, session mgmt, time_ago
models.py # Pydantic schemas models.py # Pydantic schemas
push.py # Web push crypto, VAPID keys, encrypt/send/register routers/ # One file per domain (auth, feed, posts, ...)
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files static/css/ # Page-specific CSS files
static/js/ # Application.js (ES6 module) static/js/ # Application.js (ES6 module)
@@ -68,7 +67,6 @@ devplacepy/
| `/services` | Background service monitoring (status, logs) | | `/services` | Background service monitoring (status, logs) |
| `/admin` | Admin panel (user management, news curation, settings) | | `/admin` | Admin panel (user management, news curation, settings) |
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) | | `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
| `(none)` | `/push.json` (VAPID key + subscribe), `/service-worker.js`, `/manifest.json` (push + PWA) |
## Configuration ## Configuration
@@ -76,7 +74,6 @@ devplacepy/
|---------|---------|---------| |---------|---------|---------|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string | | `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string |
| `SECRET_KEY` | hardcoded fallback | Session signing key | | `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
## Background Services ## Background Services
@@ -113,66 +110,6 @@ News articles have detail pages at `/news/{slug}` with full comment support (sam
CLI: `devplace news clear` - delete all news from local database. CLI: `devplace news clear` - delete all news from local database.
## Push notifications & PWA
Authenticated users can receive native web push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`PyJWT`, `httpx`) against the Web Push Protocol — no third-party push wrapper.
### Events
Every event that already produces an in-app notification also sends a web push,
because both share a single funnel — `create_notification()` in `utils.py`:
| Event | Recipient |
|-------|-----------|
| Direct message received | receiver |
| Comment on your post | post author |
| Reply to your comment | comment author |
| `@mention` in any content | mentioned user |
| Upvote on your content | content owner |
| New follower | followed user |
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
return `404`/`410` are soft-deleted.
### VAPID keys
The server identity is three PEM files generated once at startup in the repository
root: `notification-private.pem`, `notification-private.pkcs8.pem`,
`notification-public.pem`. They are git-ignored.
**These keys are the application's identity to the push services. If they are lost or
regenerated, every existing subscription becomes permanently undeliverable.** Persist
them across deployments and back them up; do not regenerate them.
### Opt-in
The browser requires the first permission prompt to originate from a user gesture, so
opt-in is exposed as a button in both the top navigation (bell-with-slash icon) and on
the `/notifications` page. After opt-in, the subscription is refreshed silently on
every page load. `PushManager.js` owns registration, subscription, and the opt-in UI.
### PWA
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
button (`PwaInstaller.js`) make the app installable. The service worker uses a
network-first strategy for navigations and falls back to `static/offline.html` when
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
| File | Role |
|------|------|
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
| `static/manifest.json` | PWA manifest (icons, display, theme) |
| `static/offline.html` | Offline fallback page |
## Database ## Database
SQLite via `dataset` with production-oriented pragmas set on every connection: SQLite via `dataset` with production-oriented pragmas set on every connection:
@@ -190,7 +127,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
## Testing ## Testing
- **274 tests** across 23 files: Playwright integration + unit tests - **148 tests** across 14 files: Playwright integration + unit tests
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it) - Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Server starts as subprocess on port 10501 with isolated temp database - Server starts as subprocess on port 10501 with isolated temp database
- Test users `alice_test` / `bob_test` seeded via HTTP at session start - Test users `alice_test` / `bob_test` seeded via HTTP at session start
+2 -3
View File
@@ -186,14 +186,13 @@ def link_attachments(uids, target_type, target_uid):
if not uids: if not uids:
return return
attachments = get_table("attachments") attachments = get_table("attachments")
for raw in uids: for uid in uids:
for uid in str(raw).split(","):
uid = uid.strip() uid = uid.strip()
if not uid: if not uid:
continue continue
existing = attachments.find_one(uid=uid) existing = attachments.find_one(uid=uid)
if existing: if existing:
attachments.update({"id": existing["id"], "target_type": target_type, "target_uid": target_uid}, ["id"]) attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid): def delete_attachment(uid):
+1
View File
@@ -60,6 +60,7 @@ def cmd_news_sanitize(args):
def cmd_attachments_prune(args): def cmd_attachments_prune(args):
from devplacepy.database import db from devplacepy.database import db
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
import os
deleted_records = 0 deleted_records = 0
deleted_files = 0 deleted_files = 0
freed_bytes = 0 freed_bytes = 0
-5
View File
@@ -12,8 +12,3 @@ SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production
SESSION_MAX_AGE = 86400 * 7 SESSION_MAX_AGE = 86400 * 7
PORT = 10500 PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/") SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
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"
VAPID_SUB = environ.get("DEVPLACE_VAPID_SUB", "mailto:retoor@molodetz.nl")
-77
View File
@@ -1,77 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Any
from fastapi.responses import RedirectResponse
from devplacepy.database import (
get_table,
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
load_comments,
db,
)
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments
from devplacepy.utils import time_ago
logger = logging.getLogger(__name__)
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if not item or item["user_uid"] != user["uid"]:
return RedirectResponse(url=redirect_fail, status_code=302)
table.update({"uid": item["uid"], **update_fields}, ["uid"])
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
table = get_table(table_name)
item = resolve_by_slug(table, slug)
if item and item["user_uid"] == user["uid"]:
delete_target_attachments(target_type, item["uid"])
if "comments" in db.tables:
comments = get_table("comments")
for comment in comments.find(target_uid=item["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"])
if inline_image_field:
delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"])
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
return RedirectResponse(url=redirect_url, status_code=302)
def load_detail(table_name: str, target_type: str, slug: str, user: dict | None) -> dict | None:
item = resolve_by_slug(get_table(table_name), slug)
if not item:
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]])
return {
"item": item,
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"comments": load_comments(target_type, item["uid"]),
"attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]),
}
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at") -> list:
extra_maps = extra_maps or {}
enriched = []
for item in items:
entry = {
key: item,
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
}
for name, source in extra_maps.items():
entry[name] = source(item) if callable(source) else source.get(item["uid"])
enriched.append(entry)
return enriched
-110
View File
@@ -50,7 +50,6 @@ def init_db():
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"]) _index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(db, "notifications", "idx_notifications_user", ["user_uid"]) _index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"]) _index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "sessions", "idx_sessions_token", ["session_token"]) _index(db, "sessions", "idx_sessions_token", ["session_token"])
_index(db, "projects", "idx_projects_user", ["user_uid"]) _index(db, "projects", "idx_projects_user", ["user_uid"])
_index(db, "badges", "idx_badges_user", ["user_uid"]) _index(db, "badges", "idx_badges_user", ["user_uid"])
@@ -58,7 +57,6 @@ def init_db():
_index(db, "follows", "idx_follows_following", ["following_uid"]) _index(db, "follows", "idx_follows_following", ["following_uid"])
_index(db, "password_resets", "idx_password_resets_token", ["token"]) _index(db, "password_resets", "idx_password_resets_token", ["token"])
_index(db, "gists", "idx_gists_user_uid", ["user_uid"]) _index(db, "gists", "idx_gists_user_uid", ["user_uid"])
_index(db, "gists", "idx_gists_language", ["language"])
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"]) _index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"]) _index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
@@ -321,63 +319,6 @@ def get_site_stats() -> dict:
return stats return stats
_gist_languages_cache = TTLCache(ttl=60)
def get_gist_languages() -> set[str]:
cached = _gist_languages_cache.get("codes")
if cached is not None:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query("SELECT DISTINCT language FROM gists"):
language = row.get("language")
if language:
codes.add(language)
_gist_languages_cache.set("codes", codes)
return codes
_STARRED_CONTENT_TABLES = ("posts", "projects", "gists")
_top_authors_cache = TTLCache(ttl=60)
def get_top_authors(limit: int = 5) -> list:
cached = _top_authors_cache.get("top")
if cached is not None:
return cached
sources = [table for table in _STARRED_CONTENT_TABLES if table in db.tables]
if not sources:
_top_authors_cache.set("top", [])
return []
union = " UNION ALL ".join(f"SELECT user_uid, stars FROM {table}" for table in sources)
rows = db.query(
f"SELECT user_uid, SUM(stars) AS total FROM ({union}) "
f"GROUP BY user_uid HAVING total > 0 ORDER BY total DESC LIMIT {int(limit)}"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
authors = []
for uid, total in ranked:
user = users_map.get(uid)
if user:
author = dict(user)
author["stars"] = total
authors.append(author)
_top_authors_cache.set("top", authors)
return authors
def get_user_stars(user_uid: str) -> int:
total = 0
for table in _STARRED_CONTENT_TABLES:
if table in db.tables:
for row in db.query(f"SELECT COALESCE(SUM(stars), 0) AS s FROM {table} WHERE user_uid = :u", u=user_uid):
total += row["s"] or 0
return total
def resolve_by_slug(table, slug): def resolve_by_slug(table, slug):
entry = table.find_one(slug=slug) entry = table.find_one(slug=slug)
if not entry: if not entry:
@@ -385,57 +326,6 @@ def resolve_by_slug(table, slug):
return entry return entry
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "bug":
return f"/bugs?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
if target_type == "comment":
comment = get_table("comments").find_one(uid=target_uid)
if not comment:
return "/feed"
parent_url = resolve_object_url(comment.get("target_type", "post"), comment.get("target_uid") or comment.get("post_uid", ""))
return f"{parent_url}#comment-{target_uid}"
return "/feed"
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): def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page)) total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages)) page = max(1, min(page, total_pages))
+2 -5
View File
@@ -11,8 +11,8 @@ 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.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.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
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, push 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.manager import service_manager
from devplacepy.services.news import NewsService from devplacepy.services.news import NewsService
@@ -108,7 +108,6 @@ app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow") app.include_router(follow.router, prefix="/follow")
app.include_router(admin.router, prefix="/admin") app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router) app.include_router(seo.router)
app.include_router(push.router)
app.include_router(bugs.router, prefix="/bugs") app.include_router(bugs.router, prefix="/bugs")
app.include_router(gists.router, prefix="/gists") app.include_router(gists.router, prefix="/gists")
app.include_router(news.router, prefix="/news") app.include_router(news.router, prefix="/news")
@@ -141,8 +140,6 @@ async def rate_limit_middleware(request: Request, call_next):
@app.on_event("startup") @app.on_event("startup")
async def startup(): async def startup():
init_db() init_db()
from devplacepy.push import ensure_certificates
ensure_certificates()
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"): if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
news_service = NewsService() news_service = NewsService()
service_manager.register(news_service) service_manager.register(news_service)
-277
View File
@@ -1,277 +0,0 @@
# retoor <retoor@molodetz.nl>
import base64
import json
import logging
import os
import random
import time
import uuid
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
import jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy.config import (
VAPID_PRIVATE_KEY_FILE,
VAPID_PRIVATE_KEY_PKCS8_FILE,
VAPID_PUBLIC_KEY_FILE,
VAPID_SUB,
)
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = "86400"
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201)
def generate_private_key() -> None:
if not VAPID_PRIVATE_KEY_FILE.exists():
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
VAPID_PRIVATE_KEY_FILE.write_bytes(pem)
logger.info("Generated VAPID private key at %s", VAPID_PRIVATE_KEY_FILE)
def generate_pkcs8_private_key() -> None:
if not VAPID_PRIVATE_KEY_PKCS8_FILE.exists():
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
VAPID_PRIVATE_KEY_PKCS8_FILE.write_bytes(pem)
logger.info("Generated VAPID PKCS8 private key at %s", VAPID_PRIVATE_KEY_PKCS8_FILE)
def generate_public_key() -> None:
if not VAPID_PUBLIC_KEY_FILE.exists():
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
VAPID_PUBLIC_KEY_FILE.write_bytes(pem)
logger.info("Generated VAPID public key at %s", VAPID_PUBLIC_KEY_FILE)
def ensure_certificates() -> None:
generate_private_key()
generate_pkcs8_private_key()
generate_public_key()
def hkdf(input_key: bytes, salt: bytes, info: bytes, length: int) -> bytes:
return HKDF(
algorithm=SHA256(),
length=length,
salt=salt,
info=info,
backend=default_backend(),
).derive(input_key)
def browser_base64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
_keys: dict[str, Any] = {}
def _load_keys() -> dict[str, Any]:
if _keys:
return _keys
ensure_certificates()
private_key = serialization.load_pem_private_key(
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
)
public_key = serialization.load_pem_public_key(
VAPID_PUBLIC_KEY_FILE.read_bytes(), backend=default_backend()
)
uncompressed_point = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
_keys["private_key_pem"] = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
_keys["public_key_point"] = uncompressed_point
_keys["public_key_base64"] = browser_base64(uncompressed_point)
logger.debug("Loaded VAPID key material into cache")
return _keys
def public_key_standard_b64() -> str:
point = _load_keys()["public_key_point"]
return base64.b64encode(point).decode("utf-8").rstrip("=")
def create_notification_authorization(push_url: str) -> str:
target = urlparse(push_url)
audience = f"{target.scheme}://{target.netloc}"
issued_at = int(time.time())
return jwt.encode(
{
"sub": VAPID_SUB,
"aud": audience,
"exp": issued_at + JWT_LIFETIME_SECONDS,
"nbf": issued_at,
"iat": issued_at,
"jti": str(uuid.uuid4()),
},
_load_keys()["private_key_pem"],
algorithm="ES256",
)
def create_notification_info_with_payload(
endpoint: str, auth: str, p256dh: str, payload: str
) -> dict[str, Any]:
message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
message_public_key_bytes = message_private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
salt = os.urandom(16)
user_key_bytes = base64.urlsafe_b64decode(p256dh + "==")
shared_secret = message_private_key.exchange(
ec.ECDH(),
ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), user_key_bytes),
)
encryption_key = hkdf(
shared_secret,
base64.urlsafe_b64decode(auth + "=="),
b"Content-Encoding: auth\x00",
32,
)
context = (
b"P-256\x00"
+ len(user_key_bytes).to_bytes(2, "big")
+ user_key_bytes
+ len(message_public_key_bytes).to_bytes(2, "big")
+ message_public_key_bytes
)
nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12)
content_encryption_key = hkdf(
encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16
)
padding_length = random.randint(0, 16)
padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length
data = AESGCM(content_encryption_key).encrypt(
nonce, padding + payload.encode("utf-8"), None
)
return {
"headers": {
"Authorization": f"WebPush {create_notification_authorization(endpoint)}",
"Crypto-Key": f"dh={browser_base64(message_public_key_bytes)}; p256ecdsa={_load_keys()['public_key_base64']}",
"Encryption": f"salt={browser_base64(salt)}",
"Content-Encoding": "aesgcm",
"Content-Length": str(len(data)),
"Content-Type": "application/octet-stream",
},
"data": data,
}
def _mark_subscription_dead(subscription_id: int) -> None:
get_table("push_registration").update(
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
["id"],
)
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
registrations = list(
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
)
if not registrations:
logger.debug("No active push subscriptions for user %s", user_uid)
return
body = json.dumps(payload)
async with httpx.AsyncClient(timeout=10.0) as client:
for subscription in registrations:
endpoint = subscription["endpoint"]
try:
notification_info = create_notification_info_with_payload(
endpoint,
subscription["key_auth"],
subscription["key_p256dh"],
body,
)
headers = {**notification_info["headers"], "TTL": PUSH_TTL_SECONDS}
response = await client.post(
endpoint, headers=headers, content=notification_info["data"]
)
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
continue
if response.status_code in ACCEPTED_STATUSES:
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
_mark_subscription_dead(subscription["id"])
else:
logger.warning(
"Push rejected (%s) for %s via %s", response.status_code, user_uid, endpoint
)
async def register(
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
) -> dict[str, Any]:
table = get_table("push_registration")
existing = table.find_one(
user_uid=user_uid,
endpoint=endpoint,
key_auth=key_auth,
key_p256dh=key_p256dh,
deleted_at=None,
)
if existing:
logger.debug("Push subscription already registered for user %s", user_uid)
return existing
record = {
"uid": generate_uid(),
"user_uid": user_uid,
"endpoint": endpoint,
"key_auth": key_auth,
"key_p256dh": key_p256dh,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
}
table.insert(record)
logger.info("Registered push subscription for user %s", user_uid)
return record
+2 -1
View File
@@ -1,9 +1,10 @@
import logging import logging
from typing import Annotated from typing import Annotated
from datetime import datetime
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache 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.templating import templates from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache 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 from devplacepy.seo import base_seo_context, site_url, website_schema
+8 -2
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import templates 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
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
@@ -82,7 +82,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
award_badge(uid, "Member") badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.now(timezone.utc).isoformat(),
})
token = create_session(uid) token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302) response = RedirectResponse(url="/feed", status_code=302)
+59 -11
View File
@@ -3,15 +3,36 @@ from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_object_url from devplacepy.database import get_table, resolve_by_slug
from devplacepy.attachments import link_attachments, delete_target_attachments from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.models import CommentForm from devplacepy.models import CommentForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def resolve_target_redirect(target_type, target_uid):
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "bug":
return f"/bugs?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
return "/bugs"
@router.post("/create") @router.post("/create")
async def create_comment(request: Request, data: Annotated[CommentForm, Form()]): async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
user = require_user(request) user = require_user(request)
@@ -20,7 +41,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
target_type = data.target_type target_type = data.target_type
parent_uid = data.parent_uid parent_uid = data.parent_uid
redirect_url = resolve_object_url(target_type, target_uid) redirect_url = resolve_target_redirect(target_type, target_uid)
comment_uid = generate_uid() comment_uid = generate_uid()
insert = { insert = {
@@ -39,24 +60,51 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids: if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid) link_attachments(data.attachment_uids, "comment", comment_uid)
award_badge(user["uid"], "First Comment") badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}" if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Comment",
"created_at": datetime.now(timezone.utc).isoformat(),
})
if target_type == "post": if target_type == "post":
if parent_uid: if parent_uid:
parent = get_table("comments").find_one(uid=parent_uid) comments_table = get_table("comments")
parent = comments_table.find_one(uid=parent_uid)
if parent and parent["user_uid"] != user["uid"]: if parent and parent["user_uid"] != user["uid"]:
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url) 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"])
else: else:
posts = get_table("posts") posts = get_table("posts")
post = posts.find_one(uid=target_uid) post = posts.find_one(uid=target_uid)
if not post: if not post:
post = posts.find_one(slug=target_uid) post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]: if post and post["user_uid"] != user["uid"]:
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url) 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_mention_notifications(content, user["uid"], comment_url) create_mention_notifications(content, user["uid"], redirect_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}") logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
return RedirectResponse(url=redirect_url, status_code=302) return RedirectResponse(url=redirect_url, status_code=302)
@@ -76,5 +124,5 @@ async def delete_comment(request: Request, comment_uid: str):
get_table("votes").delete(target_uid=comment_uid, target_type="comment") get_table("votes").delete(target_uid=comment_uid, target_type="comment")
comments.delete(id=comment["id"]) comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}") logger.info(f"Comment {comment_uid} deleted by {user['username']}")
redirect_url = resolve_object_url(target_type, target_uid) redirect_url = resolve_target_redirect(target_type, target_uid)
return RedirectResponse(url=redirect_url, status_code=302) return RedirectResponse(url=redirect_url, status_code=302)
+17 -8
View File
@@ -1,12 +1,11 @@
import logging import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse 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, get_top_authors 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.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -49,10 +48,19 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
if not posts: if not posts:
return [], next_cursor return [], next_cursor
authors = get_users_by_uids([p["user_uid"] for p in posts]) uids = [p["user_uid"] for p in posts]
counts = get_comment_counts_by_post_uids([p["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)
result = enrich_items(posts, "post", authors, {"comment_count": counts}) 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),
})
return result, next_cursor return result, next_cursor
@@ -60,8 +68,9 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None): async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None):
user = get_current_user(request) user = get_current_user(request)
posts, next_cursor = get_feed_posts(user, tab, topic, before) posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users")
stats = get_site_stats() stats = get_site_stats()
top_authors = get_top_authors(5) top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
daily_topic = get_daily_topic() daily_topic = get_daily_topic()
post_uids_list = [item["post"]["uid"] for item in posts] post_uids_list = [item["post"]["uid"] for item in posts]
+13 -2
View File
@@ -3,7 +3,8 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.utils import generate_uid, require_user, create_notification from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -29,7 +30,17 @@ async def follow_user(request: Request, username: str):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}") 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"])
logger.info(f"{user['username']} followed {username}") logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302) return RedirectResponse(url=f"/profile/{username}", status_code=302)
+74 -23
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications 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, software_source_code_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -37,8 +37,19 @@ def get_gists_list(user_uid=None, language=None):
if not all_gists: if not all_gists:
return [] return []
users_map = get_users_by_uids([g["user_uid"] for g in all_gists]) from devplacepy.database import get_users_by_uids
return enrich_items(all_gists, "gist", users_map) 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
@router.get("", response_class=HTMLResponse) @router.get("", response_class=HTMLResponse)
@@ -65,17 +76,29 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
"total_count": total_count, "total_count": total_count,
"current_language": language, "current_language": language,
"languages": LANGUAGES, "languages": LANGUAGES,
"gist_language_codes": get_gist_languages(),
}) })
@router.get("/{gist_slug}", response_class=HTMLResponse) @router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str): async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request) user = get_current_user(request)
detail = load_detail("gists", "gist", gist_slug, user) gists = get_table("gists")
if not detail: gist = resolve_by_slug(gists, gist_slug)
if not gist:
raise HTTPException(status_code=404, detail="Gist not found") raise HTTPException(status_code=404, detail="Gist not found")
gist = detail["item"]
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"])
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@@ -95,13 +118,13 @@ async def gist_detail(request: Request, gist_slug: str):
"request": request, "request": request,
"user": user, "user": user,
"gist": gist, "gist": gist,
"author": detail["author"], "author": author,
"is_owner": detail["is_owner"], "is_owner": is_owner,
"star_count": detail["star_count"], "star_count": star_count,
"time_ago": detail["time_ago"], "time_ago": time_ago(gist["created_at"]),
"comments": detail["comments"], "comments": comments,
"languages": LANGUAGES, "languages": LANGUAGES,
"attachments": detail["attachments"], "attachments": gist_attachments,
}) })
@@ -144,18 +167,46 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
@router.post("/edit/{gist_slug}") @router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]): async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request) 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 language = data.language
if language not in {l[0] for l in LANGUAGES}:
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
language = "plaintext" language = "plaintext"
return edit_content_item("gists", user, gist_slug, {
"title": data.title.strip(), gists.update({
"description": data.description.strip() or None, "uid": gist["uid"],
"source_code": data.source_code.strip(), "title": title,
"description": description or None,
"source_code": source_code,
"language": language, "language": language,
}, "/gists") }, ["uid"])
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
@router.post("/delete/{gist_slug}") @router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str): async def delete_gist(request: Request, gist_slug: str):
user = require_user(request) user = require_user(request)
return delete_content_item("gists", "gist", user, gist_slug, "/gists") 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)
-2
View File
@@ -68,7 +68,6 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m) msgs.append(m)
msgs.sort(key=lambda m: m["created_at"]) msgs.sort(key=lambda m: m["created_at"])
if "messages" in db.tables:
with db: 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) db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
@@ -174,7 +173,6 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
"type": "message", "type": "message",
"message": f"{user['username']} sent you a message", "message": f"{user['username']} sent you a message",
"related_uid": user["uid"], "related_uid": user["uid"],
"target_url": f"/messages?with_uid={user['uid']}",
"read": False, "read": False,
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
+1 -1
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.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
-13
View File
@@ -86,19 +86,6 @@ async def notifications_page(request: Request):
}) })
@router.get("/open/{notification_uid}")
async def open_notification(request: Request, notification_uid: str):
user = require_user(request)
notifications_table = get_table("notifications")
n = notifications_table.find_one(uid=notification_uid)
if not n or n["user_uid"] != user["uid"]:
return RedirectResponse(url="/notifications", status_code=302)
if not n["read"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url=n.get("target_url") or "/notifications", status_code=302)
@router.post("/mark-read/{notification_uid}") @router.post("/mark-read/{notification_uid}")
async def mark_read(request: Request, notification_uid: str): async def mark_read(request: Request, notification_uid: str):
user = require_user(request) user = require_user(request)
+34 -9
View File
@@ -4,12 +4,11 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS
from devplacepy.database import get_table, load_comments, db, resolve_by_slug from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.content import edit_content_item, delete_content_item from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
from devplacepy.models import PostForm, PostEditForm from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,7 +48,15 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
award_badge(user["uid"], "First Post") 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(),
})
if data.attachment_uids: if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid) link_attachments(data.attachment_uids, "post", uid)
@@ -129,14 +136,32 @@ async def view_post(request: Request, post_slug: str):
@router.post("/edit/{post_slug}") @router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]): async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request) user = require_user(request)
return edit_content_item("posts", user, post_slug, { 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"],
"content": data.content.strip(), "content": data.content.strip(),
"title": data.title.strip() or None, "title": data.title.strip() or None,
"topic": data.topic, "topic": data.topic,
}, "/feed") }, ["uid"])
logger.info(f"Post {post['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
@router.post("/delete/{post_slug}") @router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str): async def delete_post(request: Request, post_slug: str):
user = require_user(request) user = require_user(request)
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image") 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)
+4 -5
View File
@@ -3,10 +3,10 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache 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, profile_page_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/search") @router.get("/search")
async def search_users(request: Request, q: str = ""): async def search_users(request: Request, q: str = ""):
require_user_api(request) require_user(request)
if not q or len(q) < 1: if not q or len(q) < 1:
return JSONResponse({"results": []}) return JSONResponse({"results": []})
if "users" in db.tables: if "users" in db.tables:
@@ -35,7 +35,6 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
profile_user = users.find_one(username=username) profile_user = users.find_one(username=username)
if not profile_user: if not profile_user:
return RedirectResponse(url="/feed", status_code=302) return RedirectResponse(url="/feed", status_code=302)
profile_user["stars"] = get_user_stars(profile_user["uid"])
posts = [] posts = []
if tab == "posts": if tab == "posts":
+31 -14
View File
@@ -5,12 +5,11 @@ from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.content import load_detail, delete_content_item from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.attachments import link_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications 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, software_application_schema from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -83,10 +82,22 @@ async def projects_page(
@router.get("/{project_slug}", response_class=HTMLResponse) @router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str): async def project_detail(request: Request, project_slug: str):
user = get_current_user(request) user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user) projects = get_table("projects")
if not detail: project = resolve_by_slug(projects, project_slug)
if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
project = detail["item"]
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"])
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@@ -105,19 +116,25 @@ async def project_detail(request: Request, project_slug: str):
"request": request, "request": request,
"user": user, "user": user,
"project": project, "project": project,
"author": detail["author"], "author": author,
"is_owner": detail["is_owner"], "is_owner": is_owner,
"star_count": detail["star_count"], "star_count": star_count,
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [], "platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": detail["comments"], "comments": comments,
"attachments": detail["attachments"], "attachments": project_attachments,
}) })
@router.post("/delete/{project_slug}") @router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str): async def delete_project(request: Request, project_slug: str):
user = require_user(request) user = require_user(request)
return delete_content_item("projects", "project", user, project_slug, "/projects") 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)
@router.post("/create") @router.post("/create")
-64
View File
@@ -1,64 +0,0 @@
# retoor <retoor@molodetz.nl>
import logging
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.utils import require_user_api
logger = logging.getLogger(__name__)
router = APIRouter()
WELCOME_PAYLOAD = {
"title": "DevPlace",
"message": "Push notifications enabled.",
"icon": "/static/apple-touch-icon.png",
"url": "/notifications",
}
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()})
@router.post("/push.json")
async def push_register(request: Request) -> JSONResponse:
user = require_user_api(request)
try:
body = await request.json()
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None
if not (isinstance(keys, dict) and body.get("endpoint") and keys.get("p256dh") and keys.get("auth")):
return JSONResponse({"error": "Invalid request"}, status_code=400)
await push.register(
user_uid=user["uid"],
endpoint=body["endpoint"],
key_auth=keys["auth"],
key_p256dh=keys["p256dh"],
)
try:
await push.notify_user(user["uid"], WELCOME_PAYLOAD)
except Exception as exc:
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
return JSONResponse({"registered": True})
@router.get("/service-worker.js")
async def service_worker() -> FileResponse:
return FileResponse(
STATIC_DIR / "service-worker.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@router.get("/manifest.json")
async def manifest() -> FileResponse:
return FileResponse(STATIC_DIR / "manifest.json", media_type="application/manifest+json")
+3 -3
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user_api from devplacepy.utils import require_user
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -12,7 +12,7 @@ router = APIRouter()
@router.post("/upload") @router.post("/upload")
async def upload_file(request: Request): async def upload_file(request: Request):
user = require_user_api(request) user = require_user(request)
form = await request.form() form = await request.form()
file = form.get("file") file = form.get("file")
@@ -49,7 +49,7 @@ async def upload_file(request: Request):
@router.delete("/delete/{attachment_uid}") @router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str): async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user_api(request) user = require_user(request)
att = get_table("attachments").find_one(uid=attachment_uid) att = get_table("attachments").find_one(uid=attachment_uid)
if not att: if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404) return JSONResponse({"error": "Attachment not found"}, status_code=404)
+42 -16
View File
@@ -2,16 +2,15 @@ import logging
from typing import Annotated from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url from devplacepy.database import get_table
from devplacepy.utils import generate_uid, require_user, create_notification from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import VoteForm from devplacepy.models import VoteForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}") @router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]): async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
@@ -40,19 +39,46 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
down_count = votes.count(target_uid=target_uid, value=-1) down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count net = up_count - down_count
update_target_stars(target_type, target_uid, net) 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"])
if value == 1 and target_type in NOTIFY_ON_VOTE: if value == 1:
owner_uid = get_target_owner_uid(target_type, target_uid) target_owner_uid = None
if owner_uid and owner_uid != user["uid"]: if target_type == "post":
target_url = resolve_object_url(target_type, target_uid) target_owner = posts.find_one(uid=target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url) 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 request.headers.get("x-requested-with") == "fetch": if target_owner_uid and target_owner_uid != user["uid"]:
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type) label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
current_value = int(current["value"]) if current else 0 notifications = get_table("notifications")
logger.debug("ajax vote response target=%s/%s net=%s value=%s", target_type, target_uid, net, current_value) notifications.insert({
return JSONResponse({"net": net, "up": up_count, "down": down_count, "value": current_value}) "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)
referer = request.headers.get("Referer", "/feed") referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302) return RedirectResponse(url=referer, status_code=302)
+4 -17
View File
@@ -1,5 +1,6 @@
import json import json
import logging import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom from xml.dom import minidom
from devplacepy.config import SITE_URL from devplacepy.config import SITE_URL
@@ -151,17 +152,6 @@ def software_source_code_schema(gist, base_url):
} }
def _json_ld_dumps(payload):
raw = json.dumps(payload, ensure_ascii=False)
return (
raw.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
.replace("", "\\u2028")
.replace("", "\\u2029")
)
def combine(schemas): def combine(schemas):
if not schemas: if not schemas:
return None return None
@@ -172,12 +162,9 @@ def combine(schemas):
cleaned.append(s) cleaned.append(s)
if not cleaned: if not cleaned:
return None return None
payload = ( if len(cleaned) == 1:
{"@context": "https://schema.org", **cleaned[0]} return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
if len(cleaned) == 1 return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
else {"@context": "https://schema.org", "@graph": cleaned}
)
return _json_ld_dumps(payload)
DEFAULT_OG_IMAGE = "/static/og-default.png" DEFAULT_OG_IMAGE = "/static/og-default.png"
+3 -3
View File
@@ -126,12 +126,12 @@
.admin-btn-sm { .admin-btn-sm {
font-size: 0.6875rem; font-size: 0.6875rem;
padding: 0.25rem 0.375rem; padding: 0.2rem 0.4rem;
} }
.admin-select { .admin-select {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.375rem; padding: 0.2rem 0.4rem;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-input); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
@@ -152,7 +152,7 @@
.admin-input-sm { .admin-input-sm {
width: 110px; width: 110px;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.375rem; padding: 0.2rem 0.4rem;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-input); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
+20 -27
View File
@@ -408,12 +408,12 @@ img {
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: var(--white); color: #fff;
} }
.btn-primary:hover { .btn-primary:hover {
background: var(--accent-hover); background: var(--accent-hover);
color: var(--white); color: #fff;
} }
.btn-secondary { .btn-secondary {
@@ -461,13 +461,13 @@ img {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.badge-devlog { background: var(--topic-devlog); color: var(--white); } .badge-devlog { background: var(--topic-devlog); color: #fff; }
.badge-showcase { background: var(--topic-showcase); color: var(--white); } .badge-showcase { background: var(--topic-showcase); color: #fff; }
.badge-question { background: var(--topic-question); color: var(--white); } .badge-question { background: var(--topic-question); color: #fff; }
.badge-rant { background: var(--topic-rant); color: var(--white); } .badge-rant { background: var(--topic-rant); color: #fff; }
.badge-fun { background: var(--topic-fun); color: #000; } .badge-fun { background: var(--topic-fun); color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); } .badge-random { background: var(--border-light); color: var(--text-secondary); }
.badge-signals { background: var(--topic-signals); color: var(--white); } .badge-signals { background: var(--topic-signals); color: #fff; }
.avatar { .avatar {
width: 40px; width: 40px;
@@ -479,7 +479,7 @@ img {
justify-content: center; justify-content: center;
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
color: var(--white); color: #fff;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
} }
@@ -531,11 +531,11 @@ img {
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); } .topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); } .topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; } .topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; } .topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
.topnav-icon:hover { color: var(--text-primary); } .topnav-icon:hover { color: var(--text-primary); }
.nav-badge { .nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white); padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
font-size: 0.6875rem; font-weight: 700; font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
@@ -723,6 +723,16 @@ img {
to { opacity: 1; transform: translateY(0); } 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 { .icon {
font-size: 1rem; font-size: 1rem;
width: 20px; width: 20px;
@@ -969,20 +979,3 @@ img {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
} }
.card-link-host {
position: relative;
}
.card-link {
position: absolute;
inset: 0;
z-index: 1;
}
.card-link-host a:not(.card-link),
.card-link-host button,
.card-link-host form {
position: relative;
z-index: 2;
}
+1 -1
View File
@@ -71,7 +71,7 @@
.post-card:hover { .post-card:hover {
border-color: var(--border-light); border-color: var(--border-light);
box-shadow: var(--shadow-sm); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.post-header { .post-header {
+1 -1
View File
@@ -39,7 +39,7 @@
.gist-card:hover { .gist-card:hover {
border-color: var(--border-light); border-color: var(--border-light);
box-shadow: var(--shadow-sm); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.gist-card-header { .gist-card-header {
+3 -3
View File
@@ -56,7 +56,7 @@
} }
.news-card-body { .news-card-body {
padding: 1rem; padding: 1.125rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.625rem;
@@ -104,7 +104,7 @@
} }
.news-card-title { .news-card-title {
font-size: 1.125rem; font-size: 1.0625rem;
font-weight: 700; font-weight: 700;
line-height: 1.4; line-height: 1.4;
margin: 0; margin: 0;
@@ -270,7 +270,7 @@
color: var(--accent); color: var(--accent);
} }
@media (max-width: 768px) { @media (max-width: 680px) {
.news-grid { .news-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
-1
View File
@@ -31,7 +31,6 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer;
} }
.notification-card.unread { .notification-card.unread {
-16
View File
@@ -115,22 +115,6 @@
min-width: 0; min-width: 0;
} }
.comment-highlight {
animation: comment-highlight-fade 2s ease-out;
border-radius: var(--radius);
}
@keyframes comment-highlight-fade {
from {
background: var(--accent-light);
box-shadow: 0 0 0 4px var(--accent-light);
}
to {
background: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
.comment-header { .comment-header {
display: flex; display: flex;
align-items: center; align-items: center;
+5 -5
View File
@@ -18,9 +18,9 @@
.service-card { .service-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border-color);
border-radius: var(--radius); border-radius: 8px;
padding: var(--space-lg); padding: 16px;
} }
.service-header { .service-header {
@@ -32,7 +32,7 @@
.service-name { .service-name {
font-weight: 600; font-weight: 600;
font-size: 1.125rem; font-size: 1.1rem;
color: var(--text-primary); color: var(--text-primary);
text-transform: capitalize; text-transform: capitalize;
} }
@@ -60,7 +60,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
font-size: 0.8125rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
-14
View File
@@ -28,20 +28,6 @@
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3); --shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); --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-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace; --font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

-6
View File
@@ -1,30 +1,24 @@
import { ModalManager } from "./ModalManager.js"; import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js"; import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js"; import { VoteManager } from "./VoteManager.js";
import { NotificationManager } from "./NotificationManager.js";
import { MessageSearch } from "./MessageSearch.js"; import { MessageSearch } from "./MessageSearch.js";
import { ProfileEditor } from "./ProfileEditor.js"; import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js"; import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js"; import { CommentManager } from "./CommentManager.js";
import { ContentEnhancer } from "./ContentEnhancer.js"; import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js"; import { DomUtils } from "./DomUtils.js";
import { PushManager } from "./PushManager.js";
import { PwaInstaller } from "./PwaInstaller.js";
class Application { class Application {
constructor() { constructor() {
this.modals = new ModalManager(); this.modals = new ModalManager();
this.forms = new FormManager(); this.forms = new FormManager();
this.votes = new VoteManager(); this.votes = new VoteManager();
this.notifications = new NotificationManager();
this.messageSearch = new MessageSearch(); this.messageSearch = new MessageSearch();
this.profile = new ProfileEditor(); this.profile = new ProfileEditor();
this.mobileNav = new MobileNav(); this.mobileNav = new MobileNav();
this.comments = new CommentManager(); this.comments = new CommentManager();
this.content = new ContentEnhancer(); this.content = new ContentEnhancer();
this.dom = new DomUtils(); this.dom = new DomUtils();
this.push = new PushManager();
this.pwa = new PwaInstaller();
} }
} }
+2 -3
View File
@@ -1,5 +1,3 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader { export class AttachmentUploader {
constructor(form) { constructor(form) {
this.form = form; this.form = form;
@@ -174,7 +172,8 @@ export class AttachmentUploader {
} }
showError(msg) { showError(msg) {
Toast.flash(this.errorEl, msg, 5000, ""); this.errorEl.textContent = msg;
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
} }
} }
-8
View File
@@ -1,8 +0,0 @@
export class Avatar {
static imgHtml(username, size = 24) {
const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
return `<img src="${url}" class="avatar-img" style="width:${size}px;height:${size}px;border-radius:50%" alt="" loading="lazy">`;
}
}
window.Avatar = Avatar;
-5
View File
@@ -51,11 +51,6 @@ export class ContentRenderer {
html = "<p>" + text.replace(/\n/g, "<br>") + "</p>"; html = "<p>" + text.replace(/\n/g, "<br>") + "</p>";
} }
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
html = DOMPurify.sanitize(html);
html = this.processMedia(html); html = this.processMedia(html);
return html; return html;
+17 -4
View File
@@ -1,11 +1,10 @@
import { Toast } from "./Toast.js";
export class DomUtils { export class DomUtils {
constructor() { constructor() {
this.initClipboardCopy(); this.initClipboardCopy();
this.initShareButtons(); this.initShareButtons();
this.initTogglers(); this.initTogglers();
this.initStopPropagation(); this.initStopPropagation();
this.initCardLinks();
} }
initClipboardCopy() { initClipboardCopy() {
@@ -15,7 +14,9 @@ export class DomUtils {
if (!source) return; if (!source) return;
try { try {
await navigator.clipboard.writeText(source.textContent); await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000); const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch { } catch {
// silently fail // silently fail
} }
@@ -31,7 +32,9 @@ export class DomUtils {
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href; const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000); const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
} catch { } catch {
// silently fail // silently fail
} }
@@ -53,4 +56,14 @@ export class DomUtils {
el.addEventListener("click", (e) => e.stopPropagation()); el.addEventListener("click", (e) => e.stopPropagation());
}); });
} }
initCardLinks() {
document.querySelectorAll("[data-href]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-stop-propagation]")) return;
const href = el.dataset.href;
if (href) window.location.href = href;
});
});
}
} }
+9 -3
View File
@@ -1,5 +1,3 @@
import { TextInput } from "./TextInput.js";
export class EmojiPicker { export class EmojiPicker {
constructor(textarea) { constructor(textarea) {
this.textarea = textarea; this.textarea = textarea;
@@ -37,7 +35,15 @@ export class EmojiPicker {
} }
insert(unicode) { insert(unicode) {
TextInput.insertAtCursor(this.textarea, 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 }));
} }
toggle() { toggle() {
-23
View File
@@ -1,23 +0,0 @@
export class Http {
static async getJson(url) {
const response = await fetch(url);
return response.json();
}
static postForm(action, data = {}) {
const form = document.createElement("form");
form.method = "POST";
form.action = action;
for (const [name, value] of Object.entries(data)) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
}
window.Http = Http;
+8 -7
View File
@@ -1,7 +1,3 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
export class MentionInput { export class MentionInput {
constructor(element) { constructor(element) {
this.input = element; this.input = element;
@@ -54,7 +50,8 @@ export class MentionInput {
async fetch(query) { async fetch(query) {
try { try {
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query)); const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
const data = await resp.json();
const results = data.results || []; const results = data.results || [];
if (results.length === 0) { if (results.length === 0) {
this.dropdown.style.display = "none"; this.dropdown.style.display = "none";
@@ -74,7 +71,7 @@ export class MentionInput {
item.type = "button"; item.type = "button";
item.className = "mention-dropdown-item"; item.className = "mention-dropdown-item";
item.dataset.username = r.username; item.dataset.username = r.username;
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>"; 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.addEventListener("mousedown", (e) => { item.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
this.insert(r.username); this.insert(r.username);
@@ -124,7 +121,11 @@ export class MentionInput {
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1); let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
before = before.replace(/@+$/, ""); before = before.replace(/@+$/, "");
after = after.replace(/^@+/, ""); after = after.replace(/^@+/, "");
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2); 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 }));
this.dropdown.style.display = "none"; this.dropdown.style.display = "none";
this.lastMatch = null; this.lastMatch = null;
} }
+3 -5
View File
@@ -1,6 +1,3 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
export class MessageSearch { export class MessageSearch {
constructor() { constructor() {
this.initMessageSearch(); this.initMessageSearch();
@@ -29,7 +26,8 @@ export class MessageSearch {
} }
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`); const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || []; const results = data.results || [];
if (results.length === 0) { if (results.length === 0) {
dropdown.style.display = "none"; dropdown.style.display = "none";
@@ -40,7 +38,7 @@ export class MessageSearch {
const item = document.createElement("a"); const item = document.createElement("a");
item.className = "search-dropdown-item"; item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`; item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`; 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>`;
dropdown.appendChild(item); dropdown.appendChild(item);
} }
dropdown.style.display = "block"; dropdown.style.display = "block";
@@ -1,23 +0,0 @@
export class NotificationManager {
constructor() {
this.initHashScroll();
}
initHashScroll() {
const hash = window.location.hash;
if (!hash.startsWith("#comment-")) {
return;
}
window.addEventListener("load", () => {
const target = document.getElementById(hash.slice(1));
if (!target) {
return;
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("comment-highlight");
setTimeout(() => target.classList.remove("comment-highlight"), 2000);
});
});
}
}
-76
View File
@@ -1,76 +0,0 @@
// retoor <retoor@molodetz.nl>
export class PushManager {
constructor() {
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
if (!this.supported) {
return;
}
this.triggers = Array.from(document.querySelectorAll("[data-push-enable]"));
this.bindTriggers();
this.refreshTriggerVisibility();
this.register(true).catch((error) => console.error("Push silent register failed:", error));
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.optIn();
});
});
}
refreshTriggerVisibility() {
const granted = Notification.permission === "granted";
this.triggers.forEach((trigger) => {
trigger.hidden = granted;
});
}
async optIn() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
await this.register(false);
}
}
async register(silent) {
try {
const registration = await navigator.serviceWorker.register("/service-worker.js");
await registration.update();
await navigator.serviceWorker.ready;
if (Notification.permission !== "granted") {
this.refreshTriggerVisibility();
return;
}
const keyResponse = await fetch("/push.json");
const keyData = await keyResponse.json();
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
const response = await fetch("/push.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
if (!response.ok) {
throw new Error("Bad status code from server.");
}
this.refreshTriggerVisibility();
} catch (error) {
console.error("Error registering push notifications:", error);
if (!silent) {
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
}
}
}
}
-51
View File
@@ -1,51 +0,0 @@
// retoor <retoor@molodetz.nl>
export class PwaInstaller {
constructor() {
this.deferredPrompt = null;
this.triggers = Array.from(document.querySelectorAll("[data-pwa-install]"));
if (!this.triggers.length) {
return;
}
this.bindTriggers();
window.addEventListener("beforeinstallprompt", (event) => this.onPromptAvailable(event));
window.addEventListener("appinstalled", () => this.hideTriggers());
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.install();
});
});
}
onPromptAvailable(event) {
event.preventDefault();
this.deferredPrompt = event;
this.showTriggers();
}
showTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = false;
});
}
hideTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = true;
});
}
async install() {
if (!this.deferredPrompt) {
return;
}
this.deferredPrompt.prompt();
await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.hideTriggers();
}
}
+2 -3
View File
@@ -1,5 +1,3 @@
import { Http } from "./Http.js";
class ServiceMonitor { class ServiceMonitor {
constructor() { constructor() {
this.pollInterval = 5000; this.pollInterval = 5000;
@@ -31,7 +29,8 @@ class ServiceMonitor {
async pollServices() { async pollServices() {
try { try {
const data = await Http.getJson("/admin/services/data"); const resp = await fetch("/admin/services/data");
const data = await resp.json();
const container = document.getElementById("services-list"); const container = document.getElementById("services-list");
if (!container) return; if (!container) return;
for (const svc of data.services) { for (const svc of data.services) {
-17
View File
@@ -1,17 +0,0 @@
export class TextInput {
static applyValue(element, value, caretPos) {
element.value = value;
element.setSelectionRange(caretPos, caretPos);
element.focus();
element.dispatchEvent(new Event("input", { bubbles: true }));
}
static insertAtCursor(element, text) {
const start = element.selectionStart;
const end = element.selectionEnd;
const value = element.value.substring(0, start) + text + element.value.substring(end);
TextInput.applyValue(element, value, start + text.length);
}
}
window.TextInput = TextInput;
-13
View File
@@ -1,13 +0,0 @@
export class Toast {
static flash(element, message, ms = 2000, revertTo = null) {
const original = revertTo === null ? element.textContent : revertTo;
element.textContent = message;
setTimeout(() => {
if (element.textContent === message) {
element.textContent = original;
}
}, ms);
}
}
window.Toast = Toast;
+29 -38
View File
@@ -1,52 +1,43 @@
import { Toast } from "./Toast.js";
export class VoteManager { export class VoteManager {
constructor() { constructor() {
this.initVoteButtons(); this.initVoteButtons();
this.initNotificationDismiss();
} }
initVoteButtons() { initVoteButtons() {
document.querySelectorAll('form[action^="/votes/"] button[type="submit"]').forEach((button) => { document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
const form = button.closest("form"); btn.addEventListener("click", async () => {
button.addEventListener("click", (event) => { const targetUid = btn.dataset.target;
event.preventDefault(); const targetType = btn.dataset.type || "post";
event.stopPropagation(); const value = btn.dataset.vote;
this.cast(form, button);
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();
}); });
}); });
} }
async cast(form, button) { initNotificationDismiss() {
const action = form.getAttribute("action"); document.querySelectorAll(".notification-dismiss").forEach((btn) => {
const value = form.querySelector('input[name="value"]').value; btn.addEventListener("click", async () => {
try { const uid = btn.dataset.uid;
const response = await fetch(action, { if (!uid) {
method: "POST", return;
headers: { }
"X-Requested-With": "fetch", const form = document.createElement("form");
"Content-Type": "application/x-www-form-urlencoded", form.method = "POST";
}, form.action = `/notifications/mark-read/${uid}`;
body: new URLSearchParams({ value }), document.body.appendChild(form);
form.submit();
}); });
if (!response.ok) {
throw new Error(`vote failed with status ${response.status}`);
}
const result = await response.json();
this.render(action, result);
} catch (error) {
console.error("vote failed", error);
Toast.flash(button, "Error", 1500);
}
}
render(action, result) {
const targetUid = action.split("/").pop();
document.querySelectorAll(`[data-vote-count="${targetUid}"]`).forEach((counter) => {
counter.textContent = result.net;
});
document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => {
const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10);
button.classList.toggle("voted", result.value !== 0 && formValue === result.value);
}); });
} }
} }
-34
View File
@@ -1,34 +0,0 @@
{
"id": "/?source=pwa",
"name": "DevPlace",
"short_name": "DevPlace",
"description": "The Developer Social Network",
"start_url": "/feed?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"lang": "en",
"dir": "ltr",
"background_color": "#0f0a1a",
"theme_color": "#0f0a1a",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<!-- retoor <retoor@molodetz.nl> -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline - DevPlace</title>
<style>
:root {
--bg-primary: #0f0a1a;
--bg-card: #221436;
--accent: #ff6b35;
--text-primary: #f0e8f8;
--text-muted: #9a8db0;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 1.5rem;
}
.offline-card {
max-width: 360px;
text-align: center;
background: var(--bg-card);
border-radius: 16px;
padding: 2.5rem 2rem;
}
.offline-mark {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
border-radius: 16px;
background: var(--accent);
color: var(--bg-primary);
font-size: 2.75rem;
font-weight: 700;
line-height: 72px;
}
h1 { font-size: 1.375rem; margin: 0 0 0.5rem; }
p { color: var(--text-muted); line-height: 1.5; margin: 0 0 1.5rem; }
button {
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
</style>
</head>
<body>
<div class="offline-card">
<div class="offline-mark">D</div>
<h1>You are offline</h1>
<p>DevPlace could not reach the network. Check your connection and try again.</p>
<button type="button" onclick="location.reload()">Retry</button>
</div>
</body>
</html>
-76
View File
@@ -1,76 +0,0 @@
// retoor <retoor@molodetz.nl>
const CACHE_NAME = "devplace-shell-v1";
const OFFLINE_URL = "/static/offline.html";
const PRECACHE_URLS = [
OFFLINE_URL,
"/manifest.json",
"/static/icon-192.png",
"/static/icon-512.png",
];
const DEFAULT_URL = "/notifications";
const DEFAULT_ICON = "/static/icon-192.png";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") {
return;
}
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_URL))
);
});
function isClientOpen(url) {
return clients.matchAll().then((matchedClients) =>
matchedClients.some((client) => client.url === url && "focus" in client)
);
}
self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
if (!self.Notification || self.Notification.permission !== "granted") {
return;
}
const data = event.data ? event.data.json() : {};
const url = data.url || DEFAULT_URL;
if (await isClientOpen(url)) {
return;
}
const title = data.title || "DevPlace";
const message = data.message || "You have a new notification.";
const icon = data.icon || DEFAULT_ICON;
await self.registration.showNotification(title, {
body: message,
icon: icon,
badge: icon,
tag: "devplace-notification",
data: data,
});
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : DEFAULT_URL;
event.waitUntil(clients.openWindow(url));
});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>
+2 -2
View File
@@ -8,14 +8,14 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button> <button type="submit" class="comment-vote-btn">+</button>
</form> </form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span> <span class="comment-vote-count">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button> <button type="submit" class="comment-vote-btn">-</button>
</form> </form>
</div> </div>
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}"> <div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header"> <div class="comment-header">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}"> <a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
-12
View File
@@ -24,11 +24,6 @@
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#x1f4bb;</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#x1f4bb;</text></svg>">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DevPlace">
<link rel="stylesheet" href="/static/css/variables.css"> <link rel="stylesheet" href="/static/css/variables.css">
<link rel="stylesheet" href="/static/css/base.css"> <link rel="stylesheet" href="/static/css/base.css">
@@ -63,12 +58,6 @@
</div> </div>
<div class="topnav-right"> <div class="topnav-right">
{% if user %} {% if user %}
<button type="button" class="topnav-icon" data-pwa-install hidden title="Install DevPlace app" aria-label="Install DevPlace app">
<span class="nav-bell">&#x2B07;&#xFE0F;</span>
</button>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">&#x1F515;</span>
</button>
<a href="/notifications" class="topnav-icon"> <a href="/notifications" class="topnav-icon">
<span class="nav-bell">&#x1F514;</span> <span class="nav-bell">&#x1F514;</span>
{% set unread_count = get_unread_count(user["uid"]) %} {% set unread_count = get_unread_count(user["uid"]) %}
@@ -167,7 +156,6 @@
<script defer src="/static/vendor/marked.umd.js"></script> <script defer src="/static/vendor/marked.umd.js"></script>
<script defer src="/static/vendor/highlight.min.js"></script> <script defer src="/static/vendor/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="/static/vendor/emoji-picker-element/index.js"></script> <script type="module" src="/static/vendor/emoji-picker-element/index.js"></script>
<script type="module" src="/static/js/Application.js"></script> <script type="module" src="/static/js/Application.js"></script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
+3 -3
View File
@@ -86,12 +86,12 @@
<div class="post-votes"> <div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button> <button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span> <span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button> <button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post"></button>
</form> </form>
</div> </div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn"> <a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
+1 -1
View File
@@ -47,7 +47,7 @@
{% if user %} {% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;"> <form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button> <button type="submit" class="gist-star-btn">&#x2606; {{ star_count }}</button>
</form> </form>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}
+3 -8
View File
@@ -15,17 +15,15 @@
<span class="icon">&#x1F4CB;</span>All <span class="icon">&#x1F4CB;</span>All
</a> </a>
{% for code, name in languages %} {% for code, name in languages %}
{% if code != 'plaintext' and code in gist_language_codes %} {% if code != 'plaintext' %}
<a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}"> <a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>{{ name }} <span class="icon">&#x1F4DD;</span>{{ name }}
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if 'plaintext' in gist_language_codes %}
<a href="/gists?language=plaintext" class="sidebar-link {% if current_language == 'plaintext' %}active{% endif %}"> <a href="/gists?language=plaintext" class="sidebar-link {% if current_language == 'plaintext' %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>Plain Text <span class="icon">&#x1F4DD;</span>Plain Text
</a> </a>
{% endif %}
</div> </div>
{% if user %} {% if user %}
@@ -49,15 +47,12 @@
<div class="gists-grid"> <div class="gists-grid">
{% for item in gists %} {% for item in gists %}
<div class="gist-card fade-in card-link-host"> <div class="gist-card fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %}
{% include "_card_link.html" %}
<div class="gist-card-header"> <div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3> <h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation> <form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button> <button type="submit" class="gist-card-star">&#x2606; {{ item.gist.get('stars', 0) }}</button>
</form> </form>
</div> </div>
+4 -7
View File
@@ -6,7 +6,6 @@
<div class="notifications-page"> <div class="notifications-page">
<div class="notifications-header"> <div class="notifications-header">
<h2>Notifications</h2> <h2>Notifications</h2>
<button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">&#x1F514;</span>Enable push</button>
<form method="POST" action="/notifications/mark-all-read" style="display:inline;"> <form method="POST" action="/notifications/mark-all-read" style="display:inline;">
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button> <button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button>
</form> </form>
@@ -17,20 +16,18 @@
<div class="notification-group"> <div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div> <div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %} {% for item in group.entries %}
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}"> {% set target_url = item.notification.get('target_url', '') %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
{% set actor_username = item.actor['username'] if item.actor else '#' %} {% set actor_username = item.actor['username'] if item.actor else '#' %}
{% set _href = "/notifications/open/" ~ item.notification['uid'] %}
{% set _label = item.notification['message'] %}
{% include "_card_link.html" %}
<a href="/profile/{{ actor_username }}" style="flex-shrink:0"> <a href="/profile/{{ actor_username }}" style="flex-shrink:0">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy"> <img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a> </a>
<div class="notification-body"> <div class="notification-body">
<div class="notification-text">{{ item.notification['message'] }}</div> <div class="notification-text">{% if target_url %}<a href="{{ target_url }}" style="color:inherit;text-decoration:none">{% endif %}{{ item.notification['message'] }}{% if target_url %}</a>{% endif %}</div>
<div class="notification-time">{{ item.time_ago }}</div> <div class="notification-time">{{ item.time_ago }}</div>
</div> </div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;"> <form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss">&times;</button> <button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">&times;</button>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -41,7 +41,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button> <button type="submit" class="post-action-btn vote-up">+</button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span> <span class="post-vote-count">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button> <button type="submit" class="post-action-btn vote-down"></button>
+1 -1
View File
@@ -183,7 +183,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button> <button type="submit" class="post-action-btn vote-up">+</button>
</form> </form>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span> <span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button> <button type="submit" class="post-action-btn vote-down"></button>
+1 -1
View File
@@ -136,7 +136,7 @@
{% if user %} {% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;"> <form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button> <button type="submit" class="project-star-btn">&#x2606; {{ star_count }}</button>
</form> </form>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}
+2 -5
View File
@@ -53,15 +53,12 @@
<div class="projects-grid"> <div class="projects-grid">
{% for project in projects %} {% for project in projects %}
<div class="project-card fade-in card-link-host"> <div class="project-card fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %}
{% include "_card_link.html" %}
<div class="project-card-header"> <div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3> <h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form"> <form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button> <button type="submit" class="project-card-star">&#x2606;</button>
</form> </form>
</div> </div>
+6
View File
@@ -5,6 +5,12 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.avatar import avatar_url from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date 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)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60) _unread_cache = TTLCache(ttl=60)
+14 -73
View File
@@ -1,4 +1,3 @@
import asyncio
import html import html
import re import re
import secrets import secrets
@@ -8,7 +7,7 @@ from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.config import SESSION_MAX_AGE from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -87,13 +86,6 @@ def require_admin(request: Request):
return user 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: def strip_html(text: str) -> str:
if not text: if not text:
return "" return ""
@@ -148,74 +140,13 @@ def extract_mentions(content: str) -> list[str]:
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content) return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
PUSH_ICON = "/static/apple-touch-icon.png"
DEFAULT_PUSH_URL = "/notifications"
_push_tasks: set[asyncio.Task] = set()
async def _safe_notify(user_uid: str, payload: dict[str, str]) -> None:
from devplacepy import push
try:
await push.notify_user(user_uid, payload)
except Exception as e:
logger.warning("Push delivery failed for %s: %s", user_uid, e)
def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
payload = {
"title": "DevPlace",
"message": message,
"icon": PUSH_ICON,
"url": target_url or DEFAULT_PUSH_URL,
}
task = loop.create_task(_safe_notify(user_uid, payload))
_push_tasks.add(task)
task.add_done_callback(_push_tasks.discard)
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)
_schedule_push(user_uid, message, target_url)
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: def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content) usernames = extract_mentions(content)
if not usernames: if not usernames:
return return
from devplacepy.templating import clear_unread_cache
users = get_table("users") users = get_table("users")
actor = users.find_one(uid=actor_uid) notifs = get_table("notifications")
if not actor:
return
actor_username = actor["username"]
seen = set() seen = set()
for username in usernames: for username in usernames:
if username in seen: if username in seen:
@@ -223,7 +154,17 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
seen.add(username) seen.add(username)
mentioned = users.find_one(username=username) mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid: if mentioned and mentioned["uid"] != actor_uid:
create_notification(mentioned["uid"], "mention", f"@{actor_username} mentioned you", actor_uid, target_url) 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"])
def format_date(dt_str: str, include_time: bool = False) -> str: def format_date(dt_str: str, include_time: bool = False) -> str:
-2
View File
@@ -13,8 +13,6 @@ dependencies = [
"python-dotenv", "python-dotenv",
"aiofiles", "aiofiles",
"httpx", "httpx",
"cryptography",
"PyJWT",
"multiavatar", "multiavatar",
"locust", "locust",
"Pillow", "Pillow",
+1 -1
View File
@@ -133,7 +133,7 @@ def test_feed_topnav_navigation(alice):
def test_topnav_notification_bell(alice): def test_topnav_notification_bell(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
bell = page.locator(".topnav-icon[href='/notifications']") bell = page.locator(".topnav-icon").first
assert bell.is_visible() assert bell.is_visible()
+1 -1
View File
@@ -53,7 +53,7 @@ def test_messages_header_visible(alice):
def test_messages_bell_icon(alice): def test_messages_bell_icon(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/messages") page.goto(f"{BASE_URL}/messages")
bell = page.locator(".topnav-icon[href='/notifications']") bell = page.locator(".topnav-icon").first
assert bell.is_visible() assert bell.is_visible()
+1 -180
View File
@@ -26,7 +26,7 @@ def test_notifications_navigation(alice):
def test_notifications_bell_visible(alice): def test_notifications_bell_visible(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/feed") page.goto(f"{BASE_URL}/feed")
bell = page.locator(".topnav-icon[href='/notifications']") bell = page.locator(".topnav-icon").first
assert bell.is_visible() assert bell.is_visible()
@@ -332,182 +332,3 @@ def test_reply_notification(app_server, browser, seeded_db):
ctx_a.close() ctx_a.close()
ctx_b.close() ctx_b.close()
def test_mention_notification_names_actor(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Naming check @alice_test please read")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1500)
texts = pa.locator(".notification-text").all_text_contents()
assert any("@bob_test mentioned you" in t for t in texts), f"mention message must name the actor (bob): {texts}"
assert not any("@alice_test mentioned you" in t for t in texts), f"mention message must not name the mentioned user (alice): {texts}"
ctx_a.close()
ctx_b.close()
def test_comment_notification_click_opens_comment(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for click navigation test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
textarea = pb.locator("form.comment-form textarea[name='content']").first
textarea.wait_for(state="visible", timeout=10000)
textarea.fill("Bob comment that alice should jump to")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="commented on your post").first
card.wait_for(state="visible", timeout=10000)
href = card.locator("a.card-link").get_attribute("href")
assert href.startswith("/notifications/open/"), f"unexpected href: {href}"
card.locator("a.card-link").click()
pa.wait_for_url("**/posts/**", timeout=10000, wait_until="domcontentloaded")
assert "#comment-" in pa.url, f"click did not deep-link to a comment: {pa.url}"
comment_uid = pa.url.split("#comment-")[1]
pa.locator(f"#comment-{comment_uid}").wait_for(state="visible", timeout=10000)
pa.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
target = pa.locator(f'.notification-card:has(a.card-link[href="{href}"])')
target.wait_for(state="visible", timeout=10000)
assert "unread" not in (target.get_attribute("class") or ""), "opening a notification should mark it read"
ctx_a.close()
ctx_b.close()
def test_follow_notification_click_opens_profile(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
follow_btn = pb.locator("button:has-text('Follow')")
if follow_btn.is_visible():
follow_btn.click()
pb.wait_for_timeout(1000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="started following you").first
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded")
assert pa.url.endswith("/profile/bob_test"), f"follow notification should open the follower profile: {pa.url}"
ctx_a.close()
ctx_b.close()
def test_message_notification_click_opens_conversation(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
msg_input = pb.locator("input[name='content']").first
msg_input.wait_for(state="visible", timeout=10000)
msg_input.fill("Click-through message from bob")
pb.locator("button[type='submit']").last.click()
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card").filter(has_text="sent you a message").first
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded")
assert "with_uid=" in pa.url, f"message notification should open the conversation: {pa.url}"
ctx_a.close()
ctx_b.close()
def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pb.locator(".feed-fab").first.click()
pb.fill("#post-content", "Post for vote click-through test")
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_path = "/posts/" + pb.url.split("/posts/")[1]
pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
vote_btn.wait_for(state="visible", timeout=10000)
vote_btn.click()
pa.wait_for_timeout(1500)
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pb.locator(".notification-card").filter(has_text="++'d").first
card.wait_for(state="visible", timeout=10000)
card.locator("a.card-link").click()
pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded")
assert post_path in pb.url, f"vote notification should open the voted post: {pb.url}"
ctx_a.close()
ctx_b.close()
+3 -19
View File
@@ -1,5 +1,3 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies
@@ -46,23 +44,9 @@ def test_post_vote_increment(alice):
page, _ = alice page, _ = alice
create_post(page, "showcase", "Vote increment test") create_post(page, "showcase", "Vote increment test")
page.locator(".post-action-btn.vote-up").first.click() page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1") page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
count = page.locator(".post-vote-count").first.text_content().strip()
assert count == "1", f"expected vote count 1, got {count!r}"
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value").first
return int(value.text_content().strip())
def test_profile_stars_reflect_content_votes(alice):
page, user = alice
before = _profile_stars(page, user["username"])
create_post(page, "devlog", "Reputation contribution post")
page.locator(".post-action-btn.vote-up").first.click()
expect(page.locator(".post-vote-count").first).to_have_text("1")
after = _profile_stars(page, user["username"])
assert after == before + 1, f"expected stars {before + 1}, got {after}"
def test_post_comments_section(alice): def test_post_comments_section(alice):
+1 -4
View File
@@ -1,5 +1,3 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies
@@ -20,10 +18,9 @@ def test_project_vote(alice):
page, _ = alice page, _ = alice
_create_project(page, "Votable Project") _create_project(page, "Votable Project")
star = "form[action*='/votes/project/'] button" star = "form[action*='/votes/project/'] button"
count = "form[action*='/votes/project/'] .vote-count-value"
before = int(page.locator(star).first.inner_text().strip("")) before = int(page.locator(star).first.inner_text().strip(""))
page.locator(star).first.click() page.locator(star).first.click()
expect(page.locator(count).first).to_have_text(str(before + 1)) page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
after = int(page.locator(star).first.inner_text().strip("")) after = int(page.locator(star).first.inner_text().strip(""))
assert after == before + 1 assert after == before + 1
+4 -109
View File
@@ -1,25 +1,19 @@
import io import io
import re import time
import uuid
import requests import requests
from PIL import Image from PIL import Image
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
def _user(prefix="up"): def _session():
s = requests.Session() s = requests.Session()
name = f"{prefix}_{uuid.uuid4().hex[:10]}" name = f"up_{int(time.time() * 1000)}"
s.post(f"{BASE_URL}/auth/signup", data={ s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "username": name,
"email": f"{name}@test.dev", "email": f"{name}@test.dev",
"password": "secret123", "password": "secret123",
"confirm_password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True) }, allow_redirects=True)
return s, name
def _session():
s, _ = _user()
return s return s
@@ -29,12 +23,6 @@ def _png_bytes():
return buf.getvalue() return buf.getvalue()
def _upload(s, name="a.png"):
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")})
assert r.status_code == 201, r.text
return r.json()["uid"]
def test_upload_allowed_png(app_server): def test_upload_allowed_png(app_server):
s = _session() s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}) r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
@@ -72,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server):
def test_upload_requires_login(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) r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
assert r.status_code == 401 assert r.status_code in (302, 303)
def test_delete_own_allowed_other_user_forbidden(app_server): def test_delete_own_allowed_other_user_forbidden(app_server):
@@ -81,96 +69,3 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
bob = _session() bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403 assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200 assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200
def test_post_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post body with two attachments here",
"title": "attach post", "topic": "random",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert "/posts/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the post"
def test_comment_links_multiple_attachments(app_server):
s = _session()
post = s.post(f"{BASE_URL}/posts/create", data={
"content": "Host post for comment attachments", "title": "host", "topic": "random",
}, allow_redirects=True)
target_uid = re.search(r'name="target_uid"\s+value="([^"]+)"', post.text).group(1)
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/comments/create", data={
"content": "Comment with attachments",
"target_uid": target_uid, "target_type": "post",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the comment"
def test_project_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/projects/create", data={
"title": "Attach Project", "description": "Project with attachments",
"project_type": "software", "platforms": "linux", "status": "In Development",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert "/projects/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the project"
def test_gist_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/gists/create", data={
"title": "Attach Gist", "description": "Gist with attachments",
"source_code": "print('hi')", "language": "python",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert "/gists/" in r.url, r.url
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the gist"
def test_bug_links_multiple_attachments(app_server):
s = _session()
u1, u2 = _upload(s), _upload(s)
r = s.post(f"{BASE_URL}/bugs/create", data={
"title": "Attach Bug", "description": "Bug report with attachments",
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the bug"
def test_message_links_multiple_attachments(app_server):
alice = _session()
bob, bob_name = _user("bob")
found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()["results"]
bob_uid = found[0]["uid"]
u1, u2 = _upload(alice), _upload(alice)
r = alice.post(f"{BASE_URL}/messages/send", data={
"content": "Message with attachments", "receiver_uid": bob_uid,
"attachment_uids": f"{u1},{u2}",
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed in the conversation"
def test_single_attachment_links_to_post(app_server):
s = _session()
u1 = _upload(s)
r = s.post(f"{BASE_URL}/posts/create", data={
"content": "Post body with one attachment", "title": "one", "topic": "random",
"attachment_uids": u1,
}, allow_redirects=True)
assert r.status_code == 200, r.text[:300]
assert u1 in r.text, "single attachment must be linked and displayed"