Compare commits

...
16 Commits
Author SHA1 Message Date
retoor 51832664c4 Updatex
DevPlace CI / test (push) Successful in 6m22s
2026-05-27 22:03:12 +02:00
retoor 4e26ad740e Update. 2026-05-27 21:07:02 +02:00
retoor 0cc22eb889 Update
DevPlace CI / test (push) Failing after 1m30s
2026-05-27 21:06:18 +02:00
retoor a5c71fd2f8 Update
DevPlace CI / test (push) Successful in 6m10s
2026-05-25 16:16:53 +02:00
retoor 347e5f0f31 Update
DevPlace CI / test (push) Successful in 5m33s
2026-05-23 10:54:45 +02:00
retoor fe0ed5b7e6 Upate
DevPlace CI / test (push) Successful in 5m27s
2026-05-23 10:35:40 +02:00
retoor df5e8cdca0 Update
DevPlace CI / test (push) Successful in 5m32s
2026-05-23 10:24:54 +02:00
retoor cb12887b12 Upate
DevPlace CI / test (push) Failing after 3m15s
2026-05-23 10:16:56 +02:00
retoor 895cca26d0 Upate
DevPlace CI / test (push) Failing after 1m54s
2026-05-23 10:08:26 +02:00
retoor d50003ce50 Update
DevPlace CI / test (push) Failing after 1m12s
2026-05-23 10:03:55 +02:00
retoor 8029050df4 Update 2026-05-23 10:03:27 +02:00
retoor cc703e3a5d Update
DevPlace CI / test (push) Failing after 5m16s
2026-05-23 09:01:11 +02:00
retoor ccc0ee4d61 Update.
DevPlace CI / test (push) Failing after 5m19s
2026-05-23 08:45:53 +02:00
retoor 0e61d42cf9 iUpdate 2026-05-23 08:45:53 +02:00
retoor c01dff4c00 Done
DevPlace CI / test (push) Has been cancelled
2026-05-23 08:41:47 +02:00
retoor 8c9da9df98 Refactor..
DevPlace CI / test (push) Failing after 1m22s
2026-05-23 08:34:13 +02:00
86 changed files with 1751 additions and 495 deletions
+1
View File
@@ -31,3 +31,4 @@ jobs:
with:
name: failure-screenshots
path: /tmp/devplace_test_screenshots/
+3
View File
@@ -3,6 +3,9 @@ __pycache__/
*.egg-info/
.env
devplace.db*
notification-private.pem
notification-private.pkcs8.pem
notification-public.pem
.pytest_cache/
.opencode
devplacepy/static/uploads/attachments/
+9 -13
View File
@@ -55,10 +55,13 @@ make locust-headless # Locust in headless CLI mode (for CI)
1. **Emoji shortcodes** → Unicode emoji (`:fire:` → 🔥, 80+ shortcodes)
2. **Markdown parse** → via `marked` with GFM tables, line breaks
3. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks
4. **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. **All URLs** → become `<a>` links with `target="_blank"` and `rel="noopener"`
3. **Sanitize**`DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output
4. **Code syntax highlight**`highlight.js` on all `<pre><code>` blocks
5. **Image URLs**standalone `.jpg/.png/.gif` URLs become `<img>` tags
6. **YouTube URLs**`youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
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.
@@ -71,6 +74,7 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
```html
<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="/static/vendor/purify.min.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/EmojiPicker.js"></script>
@@ -563,19 +567,11 @@ All tests must pass. Tests stop at first failure (`-x`).
### Step 7: Run full suite again (only if asked by user)
```bash
hawk .
make test
make test-headed # visual confirmation
```
### Step 8: Visual verification (if UI changed)
```bash
falcon take --output /tmp/verify.png
falcon describe /tmp/verify.png
```
### Step 9: Document
### Step 8: Document
- Update `AGENTS.md` if new conventions introduced
- Update `README.md` if new routes, config, or dependencies added
+5
View File
@@ -73,3 +73,8 @@ docker-logs:
docker-clean:
docker compose down -v
deploy:
git checkout production
git merge master
git push origin production
+66 -3
View File
@@ -38,9 +38,10 @@ devplacepy/
database.py # dataset connection, index creation
templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder
utils.py # Password hashing, session mgmt, time_ago
utils.py # Password hashing, session mgmt, time_ago, notification hook
models.py # Pydantic schemas
routers/ # One file per domain (auth, feed, posts, ...)
push.py # Web push crypto, VAPID keys, encrypt/send/register
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files
static/js/ # Application.js (ES6 module)
@@ -67,6 +68,7 @@ devplacepy/
| `/services` | Background service monitoring (status, logs) |
| `/admin` | Admin panel (user management, news curation, settings) |
| `(none)` | `/robots.txt`, `/sitemap.xml` (SEO) |
| `(none)` | `/push.json` (VAPID key + subscribe), `/service-worker.js`, `/manifest.json` (push + PWA) |
## Configuration
@@ -74,6 +76,7 @@ devplacepy/
|---------|---------|---------|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string |
| `SECRET_KEY` | hardcoded fallback | Session signing key |
| `DEVPLACE_VAPID_SUB` | `mailto:retoor@molodetz.nl` | Contact address in the VAPID JWT `sub` claim |
## Background Services
@@ -110,6 +113,66 @@ News articles have detail pages at `/news/{slug}` with full comment support (sam
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
SQLite via `dataset` with production-oriented pragmas set on every connection:
@@ -127,7 +190,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
## Testing
- **148 tests** across 14 files: Playwright integration + unit tests
- **274 tests** across 23 files: Playwright integration + unit tests
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Server starts as subprocess on port 10501 with isolated temp database
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
+8 -7
View File
@@ -186,13 +186,14 @@ def link_attachments(uids, target_type, target_uid):
if not uids:
return
attachments = get_table("attachments")
for uid in uids:
uid = uid.strip()
if not uid:
continue
existing = attachments.find_one(uid=uid)
if existing:
attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
for raw in uids:
for uid in str(raw).split(","):
uid = uid.strip()
if not uid:
continue
existing = attachments.find_one(uid=uid)
if existing:
attachments.update({"id": existing["id"], "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid):
-1
View File
@@ -60,7 +60,6 @@ def cmd_news_sanitize(args):
def cmd_attachments_prune(args):
from devplacepy.database import db
from devplacepy.config import STATIC_DIR
import os
deleted_records = 0
deleted_files = 0
freed_bytes = 0
+5
View File
@@ -12,3 +12,8 @@ SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production
SESSION_MAX_AGE = 86400 * 7
PORT = 10500
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
@@ -0,0 +1,77 @@
# 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,6 +50,7 @@ def init_db():
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
_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, "projects", "idx_projects_user", ["user_uid"])
_index(db, "badges", "idx_badges_user", ["user_uid"])
@@ -57,6 +58,7 @@ def init_db():
_index(db, "follows", "idx_follows_following", ["following_uid"])
_index(db, "password_resets", "idx_password_resets_token", ["token"])
_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_target", ["target_type", "target_uid"])
@@ -319,6 +321,63 @@ def get_site_stats() -> dict:
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):
entry = table.find_one(slug=slug)
if not entry:
@@ -326,6 +385,57 @@ def resolve_by_slug(table, slug):
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):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
+5 -2
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.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.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.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads, push
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
@@ -108,6 +108,7 @@ app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)
app.include_router(bugs.router, prefix="/bugs")
app.include_router(gists.router, prefix="/gists")
app.include_router(news.router, prefix="/news")
@@ -140,6 +141,8 @@ async def rate_limit_middleware(request: Request, call_next):
@app.on_event("startup")
async def startup():
init_db()
from devplacepy.push import ensure_certificates
ensure_certificates()
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
news_service = NewsService()
service_manager.register(news_service)
+277
View File
@@ -0,0 +1,277 @@
# 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
+1 -2
View File
@@ -1,10 +1,9 @@
import logging
from typing import Annotated
from datetime import datetime
from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema
+2 -8
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
@@ -82,13 +82,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(uid, "Member")
token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302)
+11 -59
View File
@@ -3,36 +3,15 @@ from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.database import get_table, resolve_object_url
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
from devplacepy.models import CommentForm
logger = logging.getLogger(__name__)
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")
async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
user = require_user(request)
@@ -41,7 +20,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
target_type = data.target_type
parent_uid = data.parent_uid
redirect_url = resolve_target_redirect(target_type, target_uid)
redirect_url = resolve_object_url(target_type, target_uid)
comment_uid = generate_uid()
insert = {
@@ -60,51 +39,24 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid)
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Comment",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Comment")
comment_url = f"{redirect_url}#comment-{comment_uid}"
if target_type == "post":
if parent_uid:
comments_table = get_table("comments")
parent = comments_table.find_one(uid=parent_uid)
parent = get_table("comments").find_one(uid=parent_uid)
if parent and parent["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": parent["user_uid"],
"type": "reply",
"message": f"{user['username']} replied to your comment",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(parent["user_uid"])
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url)
else:
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
if not post:
post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": post["user_uid"],
"type": "comment",
"message": f"{user['username']} commented on your post",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(post["user_uid"])
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url)
create_mention_notifications(content, user["uid"], redirect_url)
create_mention_notifications(content, user["uid"], comment_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
return RedirectResponse(url=redirect_url, status_code=302)
@@ -124,5 +76,5 @@ async def delete_comment(request: Request, comment_uid: str):
get_table("votes").delete(target_uid=comment_uid, target_type="comment")
comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
redirect_url = resolve_target_redirect(target_type, target_uid)
redirect_url = resolve_object_url(target_type, target_uid)
return RedirectResponse(url=redirect_url, status_code=302)
+8 -17
View File
@@ -1,11 +1,12 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
from devplacepy.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.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
from devplacepy.utils import get_current_user
from devplacepy.seo import base_seo_context, site_url, website_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -48,19 +49,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
if not posts:
return [], next_cursor
uids = [p["user_uid"] for p in posts]
post_uids = [p["uid"] for p in posts]
authors = get_users_by_uids(uids)
counts = get_comment_counts_by_post_uids(post_uids)
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = []
for post in posts:
result.append({
"post": post,
"author": authors.get(post["user_uid"]),
"time_ago": time_ago(post["created_at"]),
"comment_count": counts.get(post["uid"], 0),
})
result = enrich_items(posts, "post", authors, {"comment_count": counts})
return result, next_cursor
@@ -68,9 +60,8 @@ 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):
user = get_current_user(request)
posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users")
stats = get_site_stats()
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
top_authors = get_top_authors(5)
daily_topic = get_daily_topic()
post_uids_list = [item["post"]["uid"] for item in posts]
+2 -13
View File
@@ -3,8 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from devplacepy.utils import generate_uid, require_user, create_notification
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -30,17 +29,7 @@ async def follow_user(request: Request, username: str):
"created_at": datetime.now(timezone.utc).isoformat(),
})
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target["uid"],
"type": "follow",
"message": f"{user['username']} started following you",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target["uid"])
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302)
+23 -74
View File
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.attachments import get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -37,19 +37,8 @@ def get_gists_list(user_uid=None, language=None):
if not all_gists:
return []
from devplacepy.database import get_users_by_uids
uids = [g["user_uid"] for g in all_gists]
users_map = get_users_by_uids(uids)
result = []
for g in all_gists:
author = users_map.get(g["user_uid"])
result.append({
"gist": g,
"author": author,
"time_ago": time_ago(g["created_at"]),
})
return result
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
return enrich_items(all_gists, "gist", users_map)
@router.get("", response_class=HTMLResponse)
@@ -76,29 +65,17 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
"total_count": total_count,
"current_language": language,
"languages": LANGUAGES,
"gist_language_codes": get_gist_languages(),
})
@router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist:
detail = load_detail("gists", "gist", gist_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Gist not found")
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids([gist["user_uid"]])
author = users_map.get(gist["user_uid"])
is_owner = user and user["uid"] == gist["user_uid"]
ups, downs = get_vote_counts([gist["uid"]])
star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0)
comments = load_comments("gist", gist["uid"])
gist_attachments = get_attachments("gist", gist["uid"])
gist = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -118,13 +95,13 @@ async def gist_detail(request: Request, gist_slug: str):
"request": request,
"user": user,
"gist": gist,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
"time_ago": time_ago(gist["created_at"]),
"comments": comments,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"languages": LANGUAGES,
"attachments": gist_attachments,
"attachments": detail["attachments"],
})
@@ -167,46 +144,18 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
@router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist or gist["user_uid"] != user["uid"]:
return RedirectResponse(url="/gists", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
if language not in {l[0] for l in LANGUAGES}:
language = "plaintext"
gists.update({
"uid": gist["uid"],
"title": title,
"description": description or None,
"source_code": source_code,
return edit_content_item("gists", user, gist_slug, {
"title": data.title.strip(),
"description": data.description.strip() or None,
"source_code": data.source_code.strip(),
"language": language,
}, ["uid"])
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
}, "/gists")
@router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if gist and gist["user_uid"] == user["uid"]:
from devplacepy.attachments import delete_target_attachments
delete_target_attachments("gist", gist["uid"])
if "comments" in db.tables:
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
delete_target_attachments("comment", c["uid"])
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
if "votes" in db.tables:
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
gists.delete(id=gist["id"])
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
return RedirectResponse(url="/gists", status_code=302)
return delete_content_item("gists", "gist", user, gist_slug, "/gists")
+4 -2
View File
@@ -68,8 +68,9 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
if "messages" in db.tables:
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
@@ -173,6 +174,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
"type": "message",
"message": f"{user['username']} sent you a message",
"related_uid": user["uid"],
"target_url": f"/messages?with_uid={user['uid']}",
"read": False,
"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.templating import templates
from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema
logger = logging.getLogger(__name__)
router = APIRouter()
+13
View File
@@ -86,6 +86,19 @@ 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}")
async def mark_read(request: Request, notification_uid: str):
user = require_user(request)
+9 -34
View File
@@ -4,11 +4,12 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
from devplacepy.content import edit_content_item, delete_content_item
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
@@ -48,15 +49,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
badges = get_table("badges")
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
if not existing:
badges.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"badge_name": "First Post",
"created_at": datetime.now(timezone.utc).isoformat(),
})
award_badge(user["uid"], "First Post")
if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid)
@@ -136,32 +129,14 @@ async def view_post(request: Request, post_slug: str):
@router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if not post or post["user_uid"] != user["uid"]:
return RedirectResponse(url="/feed", status_code=302)
posts.update({
"uid": post["uid"],
return edit_content_item("posts", user, post_slug, {
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
}, ["uid"])
logger.info(f"Post {post['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
}, "/feed")
@router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str):
user = require_user(request)
posts = get_table("posts")
post = resolve_by_slug(posts, post_slug)
if post and post["user_uid"] == user["uid"]:
delete_target_attachments("post", post["uid"])
get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"])
delete_inline_image(post.get("image"))
posts.delete(id=post["id"])
logger.info(f"Post {post['uid']} deleted by {user['username']}")
return RedirectResponse(url="/feed", status_code=302)
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
+5 -4
View File
@@ -3,10 +3,10 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.database import get_table, db, get_user_stars
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user(request)
require_user_api(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
@@ -35,6 +35,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
profile_user = users.find_one(username=username)
if not profile_user:
return RedirectResponse(url="/feed", status_code=302)
profile_user["stars"] = get_user_stars(profile_user["uid"])
posts = []
if tab == "posts":
+14 -31
View File
@@ -5,11 +5,12 @@ from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
from devplacepy.content import load_detail, delete_content_item
from devplacepy.attachments import link_attachments
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -82,22 +83,10 @@ async def projects_page(
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str):
user = get_current_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if not project:
detail = load_detail("projects", "project", project_slug, user)
if not detail:
raise HTTPException(status_code=404, detail="Project not found")
users_map = get_users_by_uids([project["user_uid"]])
author = users_map.get(project["user_uid"])
is_owner = user and user["uid"] == project["user_uid"]
ups, downs = get_vote_counts([project["uid"]])
star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0)
comments = load_comments("project", project["uid"])
project_attachments = get_attachments("project", project["uid"])
project = detail["item"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -116,25 +105,19 @@ async def project_detail(request: Request, project_slug: str):
"request": request,
"user": user,
"project": project,
"author": author,
"is_owner": is_owner,
"star_count": star_count,
"author": detail["author"],
"is_owner": detail["is_owner"],
"star_count": detail["star_count"],
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": comments,
"attachments": project_attachments,
"comments": detail["comments"],
"attachments": detail["attachments"],
})
@router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str):
user = require_user(request)
projects = get_table("projects")
project = resolve_by_slug(projects, project_slug)
if project and project["user_uid"] == user["uid"]:
delete_target_attachments("project", project["uid"])
projects.delete(id=project["id"])
logger.info(f"Project {project['uid']} deleted by {user['username']}")
return RedirectResponse(url="/projects", status_code=302)
return delete_content_item("projects", "project", user, project_slug, "/projects")
@router.post("/create")
+64
View File
@@ -0,0 +1,64 @@
# 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.responses import JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user
from devplacepy.utils import require_user_api
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__)
@@ -12,7 +12,7 @@ router = APIRouter()
@router.post("/upload")
async def upload_file(request: Request):
user = require_user(request)
user = require_user_api(request)
form = await request.form()
file = form.get("file")
@@ -49,7 +49,7 @@ async def upload_file(request: Request):
@router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str):
user = require_user(request)
user = require_user_api(request)
att = get_table("attachments").find_one(uid=attachment_uid)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
+16 -42
View File
@@ -2,15 +2,16 @@ import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache
from devplacepy.utils import generate_uid, require_user
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.models import VoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
@@ -39,46 +40,19 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count
if target_type == "post":
posts = get_table("posts")
posts.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "project":
projects = get_table("projects")
projects.update({"uid": target_uid, "stars": net}, ["uid"])
elif target_type == "gist":
gists = get_table("gists")
gists.update({"uid": target_uid, "stars": net}, ["uid"])
update_target_stars(target_type, target_uid, net)
if value == 1:
target_owner_uid = None
if target_type == "post":
target_owner = posts.find_one(uid=target_uid)
if target_owner:
target_owner_uid = target_owner["user_uid"]
elif target_type == "comment":
comments = get_table("comments")
target_comment = comments.find_one(uid=target_uid)
if target_comment:
target_owner_uid = target_comment["user_uid"]
elif target_type == "gist":
gists = get_table("gists")
target_gist = gists.find_one(uid=target_uid)
if target_gist:
target_owner_uid = target_gist["user_uid"]
if value == 1 and target_type in NOTIFY_ON_VOTE:
owner_uid = get_target_owner_uid(target_type, target_uid)
if owner_uid and owner_uid != user["uid"]:
target_url = resolve_object_url(target_type, target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
if target_owner_uid and target_owner_uid != user["uid"]:
label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": target_owner_uid,
"type": "vote",
"message": f"{user['username']} ++'d your {label}",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(target_owner_uid)
if request.headers.get("x-requested-with") == "fetch":
current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
current_value = int(current["value"]) if current else 0
logger.debug("ajax vote response target=%s/%s net=%s value=%s", target_type, target_uid, net, current_value)
return JSONResponse({"net": net, "up": up_count, "down": down_count, "value": current_value})
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)
+17 -4
View File
@@ -1,6 +1,5 @@
import json
import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
@@ -152,6 +151,17 @@ 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):
if not schemas:
return None
@@ -162,9 +172,12 @@ def combine(schemas):
cleaned.append(s)
if not cleaned:
return None
if len(cleaned) == 1:
return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
payload = (
{"@context": "https://schema.org", **cleaned[0]}
if len(cleaned) == 1
else {"@context": "https://schema.org", "@graph": cleaned}
)
return _json_ld_dumps(payload)
DEFAULT_OG_IMAGE = "/static/og-default.png"
+3 -3
View File
@@ -126,12 +126,12 @@
.admin-btn-sm {
font-size: 0.6875rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
}
.admin-select {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
@@ -152,7 +152,7 @@
.admin-input-sm {
width: 110px;
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius);
background: var(--bg-input);
color: var(--text-primary);
+27 -20
View File
@@ -408,12 +408,12 @@ img {
.btn-primary {
background: var(--accent);
color: #fff;
color: var(--white);
}
.btn-primary:hover {
background: var(--accent-hover);
color: #fff;
color: var(--white);
}
.btn-secondary {
@@ -461,13 +461,13 @@ img {
letter-spacing: 0.05em;
}
.badge-devlog { background: var(--topic-devlog); color: #fff; }
.badge-showcase { background: var(--topic-showcase); color: #fff; }
.badge-question { background: var(--topic-question); color: #fff; }
.badge-rant { background: var(--topic-rant); color: #fff; }
.badge-devlog { background: var(--topic-devlog); color: var(--white); }
.badge-showcase { background: var(--topic-showcase); color: var(--white); }
.badge-question { background: var(--topic-question); color: var(--white); }
.badge-rant { background: var(--topic-rant); color: var(--white); }
.badge-fun { background: var(--topic-fun); color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); }
.badge-signals { background: var(--topic-signals); color: #fff; }
.badge-signals { background: var(--topic-signals); color: var(--white); }
.avatar {
width: 40px;
@@ -479,7 +479,7 @@ img {
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #fff;
color: var(--white);
flex-shrink: 0;
overflow: hidden;
}
@@ -531,11 +531,11 @@ img {
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.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-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
.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:hover { color: var(--text-primary); }
.nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white);
font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
@@ -723,16 +723,6 @@ img {
to { opacity: 1; transform: translateY(0); }
}
.post-author-link {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.post-author-link:hover {
color: var(--accent);
}
.icon {
font-size: 1rem;
width: 20px;
@@ -979,3 +969,20 @@ img {
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 {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.post-header {
+1 -1
View File
@@ -39,7 +39,7 @@
.gist-card:hover {
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-sm);
}
.gist-card-header {
+3 -3
View File
@@ -56,7 +56,7 @@
}
.news-card-body {
padding: 1.125rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
@@ -104,7 +104,7 @@
}
.news-card-title {
font-size: 1.0625rem;
font-size: 1.125rem;
font-weight: 700;
line-height: 1.4;
margin: 0;
@@ -270,7 +270,7 @@
color: var(--accent);
}
@media (max-width: 680px) {
@media (max-width: 768px) {
.news-grid {
grid-template-columns: 1fr;
}
+1
View File
@@ -31,6 +31,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
cursor: pointer;
}
.notification-card.unread {
+16
View File
@@ -115,6 +115,22 @@
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 {
display: flex;
align-items: center;
+5 -5
View File
@@ -18,9 +18,9 @@
.service-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-lg);
}
.service-header {
@@ -32,7 +32,7 @@
.service-name {
font-weight: 600;
font-size: 1.1rem;
font-size: 1.125rem;
color: var(--text-primary);
text-transform: capitalize;
}
@@ -60,7 +60,7 @@
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
font-size: 0.8rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
+14
View File
@@ -28,6 +28,20 @@
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--white: #fff;
--overlay-dark: rgba(0, 0, 0, 0.7);
--overlay-light: rgba(255, 255, 255, 0.05);
--space-xs: 0.25rem;
--space-sm: 0.375rem;
--space-base: 0.5rem;
--space-md: 0.75rem;
--space-lg: 1rem;
--space-xl: 1.25rem;
--space-2xl: 1.5rem;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+6
View File
@@ -1,24 +1,30 @@
import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { NotificationManager } from "./NotificationManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js";
import { PushManager } from "./PushManager.js";
import { PwaInstaller } from "./PwaInstaller.js";
class Application {
constructor() {
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
this.notifications = new NotificationManager();
this.messageSearch = new MessageSearch();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();
this.content = new ContentEnhancer();
this.dom = new DomUtils();
this.push = new PushManager();
this.pwa = new PwaInstaller();
}
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader {
constructor(form) {
this.form = form;
@@ -172,8 +174,7 @@ export class AttachmentUploader {
}
showError(msg) {
this.errorEl.textContent = msg;
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
Toast.flash(this.errorEl, msg, 5000, "");
}
}
+8
View File
@@ -0,0 +1,8 @@
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,6 +51,11 @@ export class ContentRenderer {
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);
return html;
+4 -17
View File
@@ -1,10 +1,11 @@
import { Toast } from "./Toast.js";
export class DomUtils {
constructor() {
this.initClipboardCopy();
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
}
initClipboardCopy() {
@@ -14,9 +15,7 @@ export class DomUtils {
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
Toast.flash(btn, "Copied!", 2000);
} catch {
// silently fail
}
@@ -32,9 +31,7 @@ export class DomUtils {
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try {
await navigator.clipboard.writeText(url);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
Toast.flash(btn, "Copied!", 1000);
} catch {
// silently fail
}
@@ -56,14 +53,4 @@ export class DomUtils {
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;
});
});
}
}
+3 -9
View File
@@ -1,3 +1,5 @@
import { TextInput } from "./TextInput.js";
export class EmojiPicker {
constructor(textarea) {
this.textarea = textarea;
@@ -35,15 +37,7 @@ export class EmojiPicker {
}
insert(unicode) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
ta.value = text.substring(0, start) + unicode + text.substring(end);
const newPos = start + unicode.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
TextInput.insertAtCursor(this.textarea, unicode);
}
toggle() {
+23
View File
@@ -0,0 +1,23 @@
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;
+7 -8
View File
@@ -1,3 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
export class MentionInput {
constructor(element) {
this.input = element;
@@ -50,8 +54,7 @@ export class MentionInput {
async fetch(query) {
try {
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
const data = await resp.json();
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const results = data.results || [];
if (results.length === 0) {
this.dropdown.style.display = "none";
@@ -71,7 +74,7 @@ export class MentionInput {
item.type = "button";
item.className = "mention-dropdown-item";
item.dataset.username = r.username;
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>';
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
item.addEventListener("mousedown", (e) => {
e.preventDefault();
this.insert(r.username);
@@ -121,11 +124,7 @@ export class MentionInput {
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
before = before.replace(/@+$/, "");
after = after.replace(/^@+/, "");
this.input.value = before + "@" + username + " " + after;
const newPos = before.length + username.length + 2;
this.input.setSelectionRange(newPos, newPos);
this.input.focus();
this.input.dispatchEvent(new Event("input", { bubbles: true }));
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
this.dropdown.style.display = "none";
this.lastMatch = null;
}
+5 -3
View File
@@ -1,3 +1,6 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
export class MessageSearch {
constructor() {
this.initMessageSearch();
@@ -26,8 +29,7 @@ export class MessageSearch {
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
@@ -38,7 +40,7 @@ export class MessageSearch {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,76 @@
// 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
@@ -0,0 +1,51 @@
// 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();
}
}
+3 -2
View File
@@ -1,3 +1,5 @@
import { Http } from "./Http.js";
class ServiceMonitor {
constructor() {
this.pollInterval = 5000;
@@ -29,8 +31,7 @@ class ServiceMonitor {
async pollServices() {
try {
const resp = await fetch("/admin/services/data");
const data = await resp.json();
const data = await Http.getJson("/admin/services/data");
const container = document.getElementById("services-list");
if (!container) return;
for (const svc of data.services) {
+17
View File
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,13 @@
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;
+38 -29
View File
@@ -1,43 +1,52 @@
import { Toast } from "./Toast.js";
export class VoteManager {
constructor() {
this.initVoteButtons();
this.initNotificationDismiss();
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.querySelectorAll('form[action^="/votes/"] button[type="submit"]').forEach((button) => {
const form = button.closest("form");
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this.cast(form, button);
});
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
async cast(form, button) {
const action = form.getAttribute("action");
const value = form.querySelector('input[name="value"]').value;
try {
const response = await fetch(action, {
method: "POST",
headers: {
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ value }),
});
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
@@ -0,0 +1,34 @@
{
"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
@@ -0,0 +1,69 @@
<!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
@@ -0,0 +1,76 @@
// 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
@@ -0,0 +1 @@
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>
+5 -5
View File
@@ -8,14 +8,14 @@
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button>
</form>
<span class="comment-vote-count">{{ item.votes.up - item.votes.down }}</span>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button>
</form>
</div>
<div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header">
<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">
@@ -29,10 +29,10 @@
{% include "_attachment_display.html" %}
{% endif %}
<div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span>Reply</button>
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
@@ -67,7 +67,7 @@
data-max-size="{{ max_upload_size_mb() }}"
data-max-files="{{ max_attachments_per_resource() }}"
data-allowed-types="{{ allowed_file_types() }}"></div>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span>Post</button>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span> Post</button>
</div>
</form>
{% endif %}
+3 -3
View File
@@ -52,13 +52,13 @@
{% if u['uid'] != user['uid'] %}
<form method="POST" action="/admin/users/{{ u['uid'] }}/toggle" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm">
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span>{% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
</button>
</form>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span>Password</button>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span> Password</button>
<form id="pw-{{ u['uid'] }}" method="POST" action="/admin/users/{{ u['uid'] }}/password" class="admin-pw-form hidden">
<input type="password" name="password" placeholder="New password" minlength="6" class="admin-input-sm">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span>Set</button>
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span> Set</button>
</form>
{% else %}
<span class="admin-text-muted">You</span>
+14 -2
View File
@@ -24,6 +24,11 @@
<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="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/base.css">
@@ -58,6 +63,12 @@
</div>
<div class="topnav-right">
{% 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">
<span class="nav-bell">&#x1F514;</span>
{% set unread_count = get_unread_count(user["uid"]) %}
@@ -74,11 +85,11 @@
</div>
</a>
<div class="dropdown-menu">
<a href="/auth/logout" class="dropdown-item"><span class="icon">🚪</span>Logout</a>
<a href="/auth/logout" class="dropdown-item"><span class="icon">🚪</span> Logout</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span>Login</a>
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a>
{% endif %}
<button class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">&#x2630;</button>
@@ -156,6 +167,7 @@
<script defer src="/static/vendor/marked.umd.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/js/Application.js"></script>
{% block extra_js %}{% endblock %}
+3 -3
View File
@@ -86,12 +86,12 @@
<div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button>
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post"></button>
<button type="submit" class="post-action-btn vote-down"></button>
</form>
</div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
+1 -1
View File
@@ -30,7 +30,7 @@
<label for="email">Email address</label>
<input type="email" id="email" name="email" required maxlength="255" placeholder="you@example.com">
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span>Send Reset Link</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span> Send Reset Link</button>
</form>
<div class="auth-footer">
+1 -1
View File
@@ -47,7 +47,7 @@
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; {{ star_count }}</button>
<button type="submit" class="gist-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
</form>
{% endif %}
{% if is_owner %}
+8 -3
View File
@@ -15,15 +15,17 @@
<span class="icon">&#x1F4CB;</span>All
</a>
{% for code, name in languages %}
{% if code != 'plaintext' %}
{% if code != 'plaintext' and code in gist_language_codes %}
<a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>{{ name }}
</a>
{% endif %}
{% endfor %}
{% if 'plaintext' in gist_language_codes %}
<a href="/gists?language=plaintext" class="sidebar-link {% if current_language == 'plaintext' %}active{% endif %}">
<span class="icon">&#x1F4DD;</span>Plain Text
</a>
{% endif %}
</div>
{% if user %}
@@ -47,12 +49,15 @@
<div class="gists-grid">
{% for item in gists %}
<div class="gist-card fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
<div class="gist-card fade-in card-link-host">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %}
{% include "_card_link.html" %}
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; {{ item.gist.get('stars', 0) }}</button>
<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>
</form>
</div>
+6 -6
View File
@@ -9,7 +9,7 @@
<section class="landing-hero">
<h1>Devplace.net &mdash; The Developer <span>Social Network</span></h1>
<p>Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.</p>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span>Join DevPlace Free</a>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span> Join DevPlace Free</a>
<div class="landing-features">
<div class="landing-feature">
@@ -117,11 +117,11 @@
<footer class="landing-footer">
<p>&copy; DevPlace &mdash; The Developer Social Network</p>
<div class="landing-footer-links">
<a href="/feed"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span>News</a>
<a href="/projects"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span>Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span>Sign Up</a>
<a href="/feed"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span> News</a>
<a href="/projects"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span> Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span> Sign Up</a>
<a href="/bugs"><span class="icon">&#x1F41B;</span> Bug Report</a>
</div>
</footer>
+1 -1
View File
@@ -40,7 +40,7 @@
<a href="/auth/forgot-password">Forgot your password?</a>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span>Sign in</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span> Sign in</button>
</form>
<div class="auth-footer">
+7 -4
View File
@@ -6,6 +6,7 @@
<div class="notifications-page">
<div class="notifications-header">
<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;">
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button>
</form>
@@ -16,18 +17,20 @@
<div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %}
{% set target_url = item.notification.get('target_url', '') %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
{% 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">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a>
<div class="notification-body">
<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-text">{{ item.notification['message'] }}</div>
<div class="notification-time">{{ item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">&times;</button>
<button type="submit" class="notification-dismiss">&times;</button>
</form>
</div>
{% endfor %}
+1 -1
View File
@@ -41,7 +41,7 @@
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ post.get('stars', 0) }}</span>
<span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
+5 -5
View File
@@ -145,10 +145,10 @@
<a href="/feed" class="back-link">&larr; Back</a>
<div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span>Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span> Activity</a>
<button class="btn-ghost btn-icon profile-tab-btn">&#x25B3;</button>
</div>
@@ -183,7 +183,7 @@
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button>
</form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button>
+2 -2
View File
@@ -136,12 +136,12 @@
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; {{ star_count }}</button>
<button type="submit" class="project-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
</form>
{% endif %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
+5 -2
View File
@@ -53,12 +53,15 @@
<div class="projects-grid">
{% for project in projects %}
<div class="project-card fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
<div class="project-card fade-in card-link-host">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %}
{% include "_card_link.html" %}
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606;</button>
<button type="submit" class="project-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
</form>
</div>
+1 -1
View File
@@ -34,7 +34,7 @@
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span>Reset Password</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span> Reset Password</button>
</form>
</div>
</div>
+1 -1
View File
@@ -47,7 +47,7 @@
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span>Create account</button>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span> Create account</button>
</form>
<div class="auth-footer">
-6
View File
@@ -5,12 +5,6 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.seo import (
site_url, combine,
website_schema, breadcrumb_schema,
discussion_forum_posting, profile_page_schema,
software_application_schema, truncate,
)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
+73 -14
View File
@@ -1,3 +1,4 @@
import asyncio
import html
import re
import secrets
@@ -7,7 +8,7 @@ from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@@ -86,6 +87,13 @@ def require_admin(request: Request):
return user
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
return user
def strip_html(text: str) -> str:
if not text:
return ""
@@ -140,13 +148,74 @@ def extract_mentions(content: str) -> list[str]:
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:
usernames = extract_mentions(content)
if not usernames:
return
from devplacepy.templating import clear_unread_cache
users = get_table("users")
notifs = get_table("notifications")
actor = users.find_one(uid=actor_uid)
if not actor:
return
actor_username = actor["username"]
seen = set()
for username in usernames:
if username in seen:
@@ -154,17 +223,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
seen.add(username)
mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid:
notifs.insert({
"uid": generate_uid(),
"user_uid": mentioned["uid"],
"type": "mention",
"message": f"@{username} mentioned you",
"related_uid": actor_uid,
"target_url": target_url,
"read": False,
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(mentioned["uid"])
create_notification(mentioned["uid"], "mention", f"@{actor_username} mentioned you", actor_uid, target_url)
def format_date(dt_str: str, include_time: bool = False) -> str:
+2
View File
@@ -13,6 +13,8 @@ dependencies = [
"python-dotenv",
"aiofiles",
"httpx",
"cryptography",
"PyJWT",
"multiavatar",
"locust",
"Pillow",
+1 -1
View File
@@ -133,7 +133,7 @@ def test_feed_topnav_navigation(alice):
def test_topnav_notification_bell(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
bell = page.locator(".topnav-icon").first
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
+1 -1
View File
@@ -53,7 +53,7 @@ def test_messages_header_visible(alice):
def test_messages_bell_icon(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages")
bell = page.locator(".topnav-icon").first
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
+180 -1
View File
@@ -26,7 +26,7 @@ def test_notifications_navigation(alice):
def test_notifications_bell_visible(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed")
bell = page.locator(".topnav-icon").first
bell = page.locator(".topnav-icon[href='/notifications']")
assert bell.is_visible()
@@ -332,3 +332,182 @@ def test_reply_notification(app_server, browser, seeded_db):
ctx_a.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()
+19 -3
View File
@@ -1,3 +1,5 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@@ -44,9 +46,23 @@ def test_post_vote_increment(alice):
page, _ = alice
create_post(page, "showcase", "Vote increment test")
page.locator(".post-action-btn.vote-up").first.click()
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}"
expect(page.locator(".post-vote-count").first).to_have_text("1")
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):
+4 -1
View File
@@ -1,3 +1,5 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@@ -18,9 +20,10 @@ def test_project_vote(alice):
page, _ = alice
_create_project(page, "Votable Project")
star = "form[action*='/votes/project/'] button"
count = "form[action*='/votes/project/'] .vote-count-value"
before = int(page.locator(star).first.inner_text().strip(""))
page.locator(star).first.click()
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
expect(page.locator(count).first).to_have_text(str(before + 1))
after = int(page.locator(star).first.inner_text().strip(""))
assert after == before + 1
+109 -4
View File
@@ -1,19 +1,25 @@
import io
import time
import re
import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
def _session():
def _user(prefix="up"):
s = requests.Session()
name = f"up_{int(time.time() * 1000)}"
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
}, allow_redirects=True)
return s, name
def _session():
s, _ = _user()
return s
@@ -23,6 +29,12 @@ def _png_bytes():
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):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
@@ -60,7 +72,7 @@ def test_uploaded_file_served_as_attachment(app_server):
def test_upload_requires_login(app_server):
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
assert r.status_code in (302, 303)
assert r.status_code == 401
def test_delete_own_allowed_other_user_forbidden(app_server):
@@ -69,3 +81,96 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
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"