Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49853e079b | ||
|
|
2afb2038e5 |
@@ -32,3 +32,6 @@ jobs:
|
||||
name: failure-screenshots
|
||||
path: /tmp/devplace_test_screenshots/
|
||||
|
||||
- name: Deploy to production
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
run: make deploy
|
||||
|
||||
@@ -3,9 +3,6 @@ __pycache__/
|
||||
*.egg-info/
|
||||
.env
|
||||
devplace.db*
|
||||
notification-private.pem
|
||||
notification-private.pkcs8.pem
|
||||
notification-public.pem
|
||||
.pytest_cache/
|
||||
.opencode
|
||||
devplacepy/static/uploads/attachments/
|
||||
|
||||
@@ -55,13 +55,10 @@ 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. **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.
|
||||
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"`
|
||||
|
||||
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
|
||||
|
||||
@@ -74,7 +71,6 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
|
||||
```html
|
||||
<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>
|
||||
@@ -567,11 +563,19 @@ All tests must pass. Tests stop at first failure (`-x`).
|
||||
### Step 7: Run full suite again (only if asked by user)
|
||||
|
||||
```bash
|
||||
hawk .
|
||||
make test
|
||||
make test-headed # visual confirmation
|
||||
```
|
||||
|
||||
### Step 8: Document
|
||||
### Step 8: Visual verification (if UI changed)
|
||||
|
||||
```bash
|
||||
falcon take --output /tmp/verify.png
|
||||
falcon describe /tmp/verify.png
|
||||
```
|
||||
|
||||
### Step 9: Document
|
||||
|
||||
- Update `AGENTS.md` if new conventions introduced
|
||||
- Update `README.md` if new routes, config, or dependencies added
|
||||
|
||||
@@ -38,10 +38,9 @@ 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, notification hook
|
||||
utils.py # Password hashing, session mgmt, time_ago
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
routers/ # One file per domain (auth, feed, posts, ...)
|
||||
templates/ # Jinja2 HTML templates
|
||||
static/css/ # Page-specific CSS files
|
||||
static/js/ # Application.js (ES6 module)
|
||||
@@ -68,7 +67,6 @@ 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
|
||||
|
||||
@@ -76,7 +74,6 @@ 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
|
||||
|
||||
@@ -113,66 +110,6 @@ News articles have detail pages at `/news/{slug}` with full comment support (sam
|
||||
|
||||
CLI: `devplace news clear` - delete all news from local database.
|
||||
|
||||
## 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:
|
||||
@@ -190,7 +127,7 @@ All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except -
|
||||
|
||||
## Testing
|
||||
|
||||
- **274 tests** across 23 files: Playwright integration + unit tests
|
||||
- **148 tests** across 14 files: Playwright integration + unit tests
|
||||
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
|
||||
- Server starts as subprocess on port 10501 with isolated temp database
|
||||
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
|
||||
|
||||
@@ -186,14 +186,13 @@ def link_attachments(uids, target_type, target_uid):
|
||||
if not uids:
|
||||
return
|
||||
attachments = get_table("attachments")
|
||||
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"])
|
||||
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"])
|
||||
|
||||
|
||||
def delete_attachment(uid):
|
||||
|
||||
@@ -60,6 +60,7 @@ 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
|
||||
|
||||
@@ -12,8 +12,3 @@ 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")
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
import logging
|
||||
from typing import Any
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
resolve_by_slug,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
load_comments,
|
||||
db,
|
||||
)
|
||||
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments
|
||||
from devplacepy.utils import time_ago
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
|
||||
table = get_table(table_name)
|
||||
item = resolve_by_slug(table, slug)
|
||||
if not item or item["user_uid"] != user["uid"]:
|
||||
return RedirectResponse(url=redirect_fail, status_code=302)
|
||||
table.update({"uid": item["uid"], **update_fields}, ["uid"])
|
||||
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)
|
||||
|
||||
|
||||
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
|
||||
table = get_table(table_name)
|
||||
item = resolve_by_slug(table, slug)
|
||||
if item and item["user_uid"] == user["uid"]:
|
||||
delete_target_attachments(target_type, item["uid"])
|
||||
if "comments" in db.tables:
|
||||
comments = get_table("comments")
|
||||
for comment in comments.find(target_uid=item["uid"]):
|
||||
delete_target_attachments("comment", comment["uid"])
|
||||
comments.delete(target_uid=item["uid"])
|
||||
if "votes" in db.tables:
|
||||
get_table("votes").delete(target_uid=item["uid"])
|
||||
if inline_image_field:
|
||||
delete_inline_image(item.get(inline_image_field))
|
||||
table.delete(id=item["id"])
|
||||
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
def load_detail(table_name: str, target_type: str, slug: str, user: dict | None) -> dict | None:
|
||||
item = resolve_by_slug(get_table(table_name), slug)
|
||||
if not item:
|
||||
return None
|
||||
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
|
||||
ups, downs = get_vote_counts([item["uid"]])
|
||||
return {
|
||||
"item": item,
|
||||
"author": author,
|
||||
"is_owner": bool(user and user["uid"] == item["user_uid"]),
|
||||
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
|
||||
"comments": load_comments(target_type, item["uid"]),
|
||||
"attachments": get_attachments(target_type, item["uid"]),
|
||||
"time_ago": time_ago(item["created_at"]),
|
||||
}
|
||||
|
||||
|
||||
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at") -> list:
|
||||
extra_maps = extra_maps or {}
|
||||
enriched = []
|
||||
for item in items:
|
||||
entry = {
|
||||
key: item,
|
||||
"author": authors.get(item["user_uid"]),
|
||||
"time_ago": time_ago(item[ts_field]),
|
||||
}
|
||||
for name, source in extra_maps.items():
|
||||
entry[name] = source(item) if callable(source) else source.get(item["uid"])
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
@@ -50,7 +50,6 @@ 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"])
|
||||
@@ -58,7 +57,6 @@ 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"])
|
||||
|
||||
@@ -321,63 +319,6 @@ 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:
|
||||
@@ -385,57 +326,6 @@ 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))
|
||||
|
||||
+2
-5
@@ -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
|
||||
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.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.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
|
||||
@@ -108,7 +108,6 @@ app.include_router(avatar.router, prefix="/avatar")
|
||||
app.include_router(follow.router, prefix="/follow")
|
||||
app.include_router(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")
|
||||
@@ -141,8 +140,6 @@ 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)
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from devplacepy.config import (
|
||||
VAPID_PRIVATE_KEY_FILE,
|
||||
VAPID_PRIVATE_KEY_PKCS8_FILE,
|
||||
VAPID_PUBLIC_KEY_FILE,
|
||||
VAPID_SUB,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JWT_LIFETIME_SECONDS = 60 * 60
|
||||
PUSH_TTL_SECONDS = "86400"
|
||||
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
|
||||
ACCEPTED_STATUSES = (200, 201)
|
||||
|
||||
|
||||
def generate_private_key() -> None:
|
||||
if not VAPID_PRIVATE_KEY_FILE.exists():
|
||||
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
VAPID_PRIVATE_KEY_FILE.write_bytes(pem)
|
||||
logger.info("Generated VAPID private key at %s", VAPID_PRIVATE_KEY_FILE)
|
||||
|
||||
|
||||
def generate_pkcs8_private_key() -> None:
|
||||
if not VAPID_PRIVATE_KEY_PKCS8_FILE.exists():
|
||||
private_key = serialization.load_pem_private_key(
|
||||
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
|
||||
)
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
VAPID_PRIVATE_KEY_PKCS8_FILE.write_bytes(pem)
|
||||
logger.info("Generated VAPID PKCS8 private key at %s", VAPID_PRIVATE_KEY_PKCS8_FILE)
|
||||
|
||||
|
||||
def generate_public_key() -> None:
|
||||
if not VAPID_PUBLIC_KEY_FILE.exists():
|
||||
private_key = serialization.load_pem_private_key(
|
||||
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
|
||||
)
|
||||
pem = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
VAPID_PUBLIC_KEY_FILE.write_bytes(pem)
|
||||
logger.info("Generated VAPID public key at %s", VAPID_PUBLIC_KEY_FILE)
|
||||
|
||||
|
||||
def ensure_certificates() -> None:
|
||||
generate_private_key()
|
||||
generate_pkcs8_private_key()
|
||||
generate_public_key()
|
||||
|
||||
|
||||
def hkdf(input_key: bytes, salt: bytes, info: bytes, length: int) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=SHA256(),
|
||||
length=length,
|
||||
salt=salt,
|
||||
info=info,
|
||||
backend=default_backend(),
|
||||
).derive(input_key)
|
||||
|
||||
|
||||
def browser_base64(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
_keys: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _load_keys() -> dict[str, Any]:
|
||||
if _keys:
|
||||
return _keys
|
||||
ensure_certificates()
|
||||
private_key = serialization.load_pem_private_key(
|
||||
VAPID_PRIVATE_KEY_FILE.read_bytes(), password=None, backend=default_backend()
|
||||
)
|
||||
public_key = serialization.load_pem_public_key(
|
||||
VAPID_PUBLIC_KEY_FILE.read_bytes(), backend=default_backend()
|
||||
)
|
||||
uncompressed_point = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
_keys["private_key_pem"] = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
_keys["public_key_point"] = uncompressed_point
|
||||
_keys["public_key_base64"] = browser_base64(uncompressed_point)
|
||||
logger.debug("Loaded VAPID key material into cache")
|
||||
return _keys
|
||||
|
||||
|
||||
def public_key_standard_b64() -> str:
|
||||
point = _load_keys()["public_key_point"]
|
||||
return base64.b64encode(point).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def create_notification_authorization(push_url: str) -> str:
|
||||
target = urlparse(push_url)
|
||||
audience = f"{target.scheme}://{target.netloc}"
|
||||
issued_at = int(time.time())
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": VAPID_SUB,
|
||||
"aud": audience,
|
||||
"exp": issued_at + JWT_LIFETIME_SECONDS,
|
||||
"nbf": issued_at,
|
||||
"iat": issued_at,
|
||||
"jti": str(uuid.uuid4()),
|
||||
},
|
||||
_load_keys()["private_key_pem"],
|
||||
algorithm="ES256",
|
||||
)
|
||||
|
||||
|
||||
def create_notification_info_with_payload(
|
||||
endpoint: str, auth: str, p256dh: str, payload: str
|
||||
) -> dict[str, Any]:
|
||||
message_private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
||||
message_public_key_bytes = message_private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
|
||||
salt = os.urandom(16)
|
||||
|
||||
user_key_bytes = base64.urlsafe_b64decode(p256dh + "==")
|
||||
shared_secret = message_private_key.exchange(
|
||||
ec.ECDH(),
|
||||
ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), user_key_bytes),
|
||||
)
|
||||
|
||||
encryption_key = hkdf(
|
||||
shared_secret,
|
||||
base64.urlsafe_b64decode(auth + "=="),
|
||||
b"Content-Encoding: auth\x00",
|
||||
32,
|
||||
)
|
||||
|
||||
context = (
|
||||
b"P-256\x00"
|
||||
+ len(user_key_bytes).to_bytes(2, "big")
|
||||
+ user_key_bytes
|
||||
+ len(message_public_key_bytes).to_bytes(2, "big")
|
||||
+ message_public_key_bytes
|
||||
)
|
||||
|
||||
nonce = hkdf(encryption_key, salt, b"Content-Encoding: nonce\x00" + context, 12)
|
||||
content_encryption_key = hkdf(
|
||||
encryption_key, salt, b"Content-Encoding: aesgcm\x00" + context, 16
|
||||
)
|
||||
|
||||
padding_length = random.randint(0, 16)
|
||||
padding = padding_length.to_bytes(2, "big") + b"\x00" * padding_length
|
||||
data = AESGCM(content_encryption_key).encrypt(
|
||||
nonce, padding + payload.encode("utf-8"), None
|
||||
)
|
||||
|
||||
return {
|
||||
"headers": {
|
||||
"Authorization": f"WebPush {create_notification_authorization(endpoint)}",
|
||||
"Crypto-Key": f"dh={browser_base64(message_public_key_bytes)}; p256ecdsa={_load_keys()['public_key_base64']}",
|
||||
"Encryption": f"salt={browser_base64(salt)}",
|
||||
"Content-Encoding": "aesgcm",
|
||||
"Content-Length": str(len(data)),
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
def _mark_subscription_dead(subscription_id: int) -> None:
|
||||
get_table("push_registration").update(
|
||||
{"id": subscription_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||
["id"],
|
||||
)
|
||||
logger.info("Soft-deleted dead push subscription id=%s", subscription_id)
|
||||
|
||||
|
||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
registrations = list(
|
||||
get_table("push_registration").find(user_uid=user_uid, deleted_at=None)
|
||||
)
|
||||
if not registrations:
|
||||
logger.debug("No active push subscriptions for user %s", user_uid)
|
||||
return
|
||||
|
||||
body = json.dumps(payload)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for subscription in registrations:
|
||||
endpoint = subscription["endpoint"]
|
||||
try:
|
||||
notification_info = create_notification_info_with_payload(
|
||||
endpoint,
|
||||
subscription["key_auth"],
|
||||
subscription["key_p256dh"],
|
||||
body,
|
||||
)
|
||||
headers = {**notification_info["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
response = await client.post(
|
||||
endpoint, headers=headers, content=notification_info["data"]
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
|
||||
continue
|
||||
|
||||
if response.status_code in ACCEPTED_STATUSES:
|
||||
logger.debug("Push delivered to %s via %s", user_uid, endpoint)
|
||||
elif response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
||||
_mark_subscription_dead(subscription["id"])
|
||||
else:
|
||||
logger.warning(
|
||||
"Push rejected (%s) for %s via %s", response.status_code, user_uid, endpoint
|
||||
)
|
||||
|
||||
|
||||
async def register(
|
||||
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
|
||||
) -> dict[str, Any]:
|
||||
table = get_table("push_registration")
|
||||
existing = table.find_one(
|
||||
user_uid=user_uid,
|
||||
endpoint=endpoint,
|
||||
key_auth=key_auth,
|
||||
key_p256dh=key_p256dh,
|
||||
deleted_at=None,
|
||||
)
|
||||
if existing:
|
||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||
return existing
|
||||
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"endpoint": endpoint,
|
||||
"key_auth": key_auth,
|
||||
"key_p256dh": key_p256dh,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
}
|
||||
table.insert(record)
|
||||
logger.info("Registered push subscription for user %s", user_uid)
|
||||
return record
|
||||
@@ -1,9 +1,10 @@
|
||||
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, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
|
||||
from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge
|
||||
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
||||
|
||||
@@ -82,7 +82,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
award_badge(uid, "Member")
|
||||
badges = get_table("badges")
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": uid,
|
||||
"badge_name": "Member",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
token = create_session(uid)
|
||||
response = RedirectResponse(url="/feed", status_code=302)
|
||||
|
||||
@@ -3,15 +3,36 @@ 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_object_url
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.attachments import link_attachments, delete_target_attachments
|
||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
|
||||
from devplacepy.models import CommentForm
|
||||
|
||||
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)
|
||||
@@ -20,7 +41,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
target_type = data.target_type
|
||||
parent_uid = data.parent_uid
|
||||
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
redirect_url = resolve_target_redirect(target_type, target_uid)
|
||||
|
||||
comment_uid = generate_uid()
|
||||
insert = {
|
||||
@@ -39,24 +60,51 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "comment", comment_uid)
|
||||
|
||||
award_badge(user["uid"], "First Comment")
|
||||
|
||||
comment_url = f"{redirect_url}#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(),
|
||||
})
|
||||
|
||||
if target_type == "post":
|
||||
if parent_uid:
|
||||
parent = get_table("comments").find_one(uid=parent_uid)
|
||||
comments_table = get_table("comments")
|
||||
parent = comments_table.find_one(uid=parent_uid)
|
||||
if parent and parent["user_uid"] != user["uid"]:
|
||||
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url)
|
||||
notifications = get_table("notifications")
|
||||
notifications.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": parent["user_uid"],
|
||||
"type": "reply",
|
||||
"message": f"{user['username']} replied to your comment",
|
||||
"related_uid": user["uid"],
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
clear_unread_cache(parent["user_uid"])
|
||||
else:
|
||||
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"]:
|
||||
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url)
|
||||
notifications = get_table("notifications")
|
||||
notifications.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": post["user_uid"],
|
||||
"type": "comment",
|
||||
"message": f"{user['username']} commented on your post",
|
||||
"related_uid": user["uid"],
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
clear_unread_cache(post["user_uid"])
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
create_mention_notifications(content, user["uid"], redirect_url)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
@@ -76,5 +124,5 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
get_table("votes").delete(target_uid=comment_uid, target_type="comment")
|
||||
comments.delete(id=comment["id"])
|
||||
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
redirect_url = resolve_target_redirect(target_type, target_uid)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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, get_top_authors
|
||||
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -49,10 +48,19 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
if not posts:
|
||||
return [], next_cursor
|
||||
|
||||
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])
|
||||
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)
|
||||
|
||||
result = enrich_items(posts, "post", authors, {"comment_count": counts})
|
||||
result = []
|
||||
for post in posts:
|
||||
result.append({
|
||||
"post": post,
|
||||
"author": authors.get(post["user_uid"]),
|
||||
"time_ago": time_ago(post["created_at"]),
|
||||
"comment_count": counts.get(post["uid"], 0),
|
||||
})
|
||||
return result, next_cursor
|
||||
|
||||
|
||||
@@ -60,8 +68,9 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None):
|
||||
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 = get_top_authors(5)
|
||||
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
|
||||
daily_topic = get_daily_topic()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid, require_user, create_notification
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
from devplacepy.utils import generate_uid, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -29,7 +30,17 @@ async def follow_user(request: Request, username: str):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}")
|
||||
notifications = get_table("notifications")
|
||||
notifications.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": target["uid"],
|
||||
"type": "follow",
|
||||
"message": f"{user['username']} started following you",
|
||||
"related_uid": user["uid"],
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
clear_unread_cache(target["uid"])
|
||||
|
||||
logger.info(f"{user['username']} followed {username}")
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
+74
-23
@@ -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, get_users_by_uids, get_gist_languages
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
|
||||
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.templating import templates
|
||||
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
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -37,8 +37,19 @@ def get_gists_list(user_uid=None, language=None):
|
||||
if not all_gists:
|
||||
return []
|
||||
|
||||
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
|
||||
return enrich_items(all_gists, "gist", users_map)
|
||||
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
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -65,17 +76,29 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
|
||||
"total_count": total_count,
|
||||
"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)
|
||||
detail = load_detail("gists", "gist", gist_slug, user)
|
||||
if not detail:
|
||||
gists = get_table("gists")
|
||||
gist = resolve_by_slug(gists, gist_slug)
|
||||
if not gist:
|
||||
raise HTTPException(status_code=404, detail="Gist not found")
|
||||
gist = detail["item"]
|
||||
|
||||
from devplacepy.database import get_users_by_uids
|
||||
users_map = get_users_by_uids([gist["user_uid"]])
|
||||
author = users_map.get(gist["user_uid"])
|
||||
|
||||
is_owner = user and user["uid"] == gist["user_uid"]
|
||||
|
||||
ups, downs = get_vote_counts([gist["uid"]])
|
||||
star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0)
|
||||
|
||||
comments = load_comments("gist", gist["uid"])
|
||||
|
||||
gist_attachments = get_attachments("gist", gist["uid"])
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
@@ -95,13 +118,13 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"gist": gist,
|
||||
"author": detail["author"],
|
||||
"is_owner": detail["is_owner"],
|
||||
"star_count": detail["star_count"],
|
||||
"time_ago": detail["time_ago"],
|
||||
"comments": detail["comments"],
|
||||
"author": author,
|
||||
"is_owner": is_owner,
|
||||
"star_count": star_count,
|
||||
"time_ago": time_ago(gist["created_at"]),
|
||||
"comments": comments,
|
||||
"languages": LANGUAGES,
|
||||
"attachments": detail["attachments"],
|
||||
"attachments": gist_attachments,
|
||||
})
|
||||
|
||||
|
||||
@@ -144,18 +167,46 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
|
||||
@router.post("/edit/{gist_slug}")
|
||||
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
|
||||
if language not in {l[0] for l in LANGUAGES}:
|
||||
|
||||
valid_languages = {l[0] for l in LANGUAGES}
|
||||
if language not in valid_languages:
|
||||
language = "plaintext"
|
||||
return edit_content_item("gists", user, gist_slug, {
|
||||
"title": data.title.strip(),
|
||||
"description": data.description.strip() or None,
|
||||
"source_code": data.source_code.strip(),
|
||||
|
||||
gists.update({
|
||||
"uid": gist["uid"],
|
||||
"title": title,
|
||||
"description": description or None,
|
||||
"source_code": source_code,
|
||||
"language": language,
|
||||
}, "/gists")
|
||||
}, ["uid"])
|
||||
|
||||
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{gist_slug}")
|
||||
async def delete_gist(request: Request, gist_slug: str):
|
||||
user = require_user(request)
|
||||
return delete_content_item("gists", "gist", user, gist_slug, "/gists")
|
||||
gists = get_table("gists")
|
||||
gist = resolve_by_slug(gists, gist_slug)
|
||||
if gist and gist["user_uid"] == user["uid"]:
|
||||
from devplacepy.attachments import delete_target_attachments
|
||||
delete_target_attachments("gist", gist["uid"])
|
||||
if "comments" in db.tables:
|
||||
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
|
||||
delete_target_attachments("comment", c["uid"])
|
||||
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
|
||||
if "votes" in db.tables:
|
||||
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
|
||||
gists.delete(id=gist["id"])
|
||||
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
|
||||
return RedirectResponse(url="/gists", status_code=302)
|
||||
|
||||
@@ -68,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
msgs.append(m)
|
||||
msgs.sort(key=lambda m: m["created_at"])
|
||||
|
||||
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)
|
||||
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})
|
||||
@@ -174,7 +173,6 @@ 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(),
|
||||
})
|
||||
|
||||
@@ -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, news_article_schema
|
||||
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -86,19 +86,6 @@ async def notifications_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/open/{notification_uid}")
|
||||
async def open_notification(request: Request, notification_uid: str):
|
||||
user = require_user(request)
|
||||
notifications_table = get_table("notifications")
|
||||
n = notifications_table.find_one(uid=notification_uid)
|
||||
if not n or n["user_uid"] != user["uid"]:
|
||||
return RedirectResponse(url="/notifications", status_code=302)
|
||||
if not n["read"]:
|
||||
notifications_table.update({"id": n["id"], "read": True}, ["id"])
|
||||
clear_unread_cache(user["uid"])
|
||||
return RedirectResponse(url=n.get("target_url") or "/notifications", status_code=302)
|
||||
|
||||
|
||||
@router.post("/mark-read/{notification_uid}")
|
||||
async def mark_read(request: Request, notification_uid: str):
|
||||
user = require_user(request)
|
||||
|
||||
@@ -4,12 +4,11 @@ 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, load_comments, db, resolve_by_slug
|
||||
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.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.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.models import PostForm, PostEditForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,7 +48,15 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
award_badge(user["uid"], "First Post")
|
||||
badges = get_table("badges")
|
||||
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
|
||||
if not existing:
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"badge_name": "First Post",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "post", uid)
|
||||
@@ -129,14 +136,32 @@ async def view_post(request: Request, post_slug: str):
|
||||
@router.post("/edit/{post_slug}")
|
||||
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
|
||||
user = require_user(request)
|
||||
return edit_content_item("posts", user, post_slug, {
|
||||
posts = get_table("posts")
|
||||
post = resolve_by_slug(posts, post_slug)
|
||||
if not post or post["user_uid"] != user["uid"]:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
|
||||
posts.update({
|
||||
"uid": post["uid"],
|
||||
"content": data.content.strip(),
|
||||
"title": data.title.strip() or None,
|
||||
"topic": data.topic,
|
||||
}, "/feed")
|
||||
}, ["uid"])
|
||||
|
||||
logger.info(f"Post {post['uid']} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{post_slug}")
|
||||
async def delete_post(request: Request, post_slug: str):
|
||||
user = require_user(request)
|
||||
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
|
||||
posts = get_table("posts")
|
||||
post = resolve_by_slug(posts, post_slug)
|
||||
if post and post["user_uid"] == user["uid"]:
|
||||
delete_target_attachments("post", post["uid"])
|
||||
get_table("comments").delete(post_uid=post["uid"])
|
||||
get_table("votes").delete(target_uid=post["uid"])
|
||||
delete_inline_image(post.get("image"))
|
||||
posts.delete(id=post["id"])
|
||||
logger.info(f"Post {post['uid']} deleted by {user['username']}")
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
|
||||
@@ -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, get_user_stars
|
||||
from devplacepy.database import get_table, db
|
||||
from devplacepy.templating import templates
|
||||
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
|
||||
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
|
||||
|
||||
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_api(request)
|
||||
require_user(request)
|
||||
if not q or len(q) < 1:
|
||||
return JSONResponse({"results": []})
|
||||
if "users" in db.tables:
|
||||
@@ -35,7 +35,6 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
profile_user = users.find_one(username=username)
|
||||
if not profile_user:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
|
||||
posts = []
|
||||
if tab == "posts":
|
||||
|
||||
@@ -5,12 +5,11 @@ 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_users_by_uids, get_site_stats
|
||||
from devplacepy.content import load_detail, delete_content_item
|
||||
from devplacepy.attachments import link_attachments
|
||||
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.templating import templates
|
||||
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
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -83,10 +82,22 @@ 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)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
projects = get_table("projects")
|
||||
project = resolve_by_slug(projects, project_slug)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
project = detail["item"]
|
||||
|
||||
users_map = get_users_by_uids([project["user_uid"]])
|
||||
author = users_map.get(project["user_uid"])
|
||||
|
||||
is_owner = user and user["uid"] == project["user_uid"]
|
||||
|
||||
ups, downs = get_vote_counts([project["uid"]])
|
||||
star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0)
|
||||
|
||||
comments = load_comments("project", project["uid"])
|
||||
|
||||
project_attachments = get_attachments("project", project["uid"])
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
@@ -105,19 +116,25 @@ async def project_detail(request: Request, project_slug: str):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"project": project,
|
||||
"author": detail["author"],
|
||||
"is_owner": detail["is_owner"],
|
||||
"star_count": detail["star_count"],
|
||||
"author": author,
|
||||
"is_owner": is_owner,
|
||||
"star_count": star_count,
|
||||
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
||||
"comments": detail["comments"],
|
||||
"attachments": detail["attachments"],
|
||||
"comments": comments,
|
||||
"attachments": project_attachments,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/delete/{project_slug}")
|
||||
async def delete_project(request: Request, project_slug: str):
|
||||
user = require_user(request)
|
||||
return delete_content_item("projects", "project", user, project_slug, "/projects")
|
||||
projects = get_table("projects")
|
||||
project = resolve_by_slug(projects, project_slug)
|
||||
if project and project["user_uid"] == user["uid"]:
|
||||
delete_target_attachments("project", project["uid"])
|
||||
projects.delete(id=project["id"])
|
||||
logger.info(f"Project {project['uid']} deleted by {user['username']}")
|
||||
return RedirectResponse(url="/projects", status_code=302)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from devplacepy import push
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import require_user_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
WELCOME_PAYLOAD = {
|
||||
"title": "DevPlace",
|
||||
"message": "Push notifications enabled.",
|
||||
"icon": "/static/apple-touch-icon.png",
|
||||
"url": "/notifications",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/push.json")
|
||||
async def push_public_key() -> JSONResponse:
|
||||
return JSONResponse({"publicKey": push.public_key_standard_b64()})
|
||||
|
||||
|
||||
@router.post("/push.json")
|
||||
async def push_register(request: Request) -> JSONResponse:
|
||||
user = require_user_api(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
keys = body.get("keys") if isinstance(body, dict) else None
|
||||
if not (isinstance(keys, dict) and body.get("endpoint") and keys.get("p256dh") and keys.get("auth")):
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
await push.register(
|
||||
user_uid=user["uid"],
|
||||
endpoint=body["endpoint"],
|
||||
key_auth=keys["auth"],
|
||||
key_p256dh=keys["p256dh"],
|
||||
)
|
||||
|
||||
try:
|
||||
await push.notify_user(user["uid"], WELCOME_PAYLOAD)
|
||||
except Exception as exc:
|
||||
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
|
||||
|
||||
return JSONResponse({"registered": True})
|
||||
|
||||
|
||||
@router.get("/service-worker.js")
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
STATIC_DIR / "service-worker.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/manifest.json")
|
||||
async def manifest() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "manifest.json", media_type="application/manifest+json")
|
||||
@@ -3,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_api
|
||||
from devplacepy.utils import require_user
|
||||
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_api(request)
|
||||
user = require_user(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_api(request)
|
||||
user = require_user(request)
|
||||
att = get_table("attachments").find_one(uid=attachment_uid)
|
||||
if not att:
|
||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||
|
||||
+42
-16
@@ -2,16 +2,15 @@ import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
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 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.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()]):
|
||||
@@ -40,19 +39,46 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
||||
down_count = votes.count(target_uid=target_uid, value=-1)
|
||||
net = up_count - down_count
|
||||
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
if target_type == "post":
|
||||
posts = get_table("posts")
|
||||
posts.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||
elif target_type == "project":
|
||||
projects = get_table("projects")
|
||||
projects.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||
elif target_type == "gist":
|
||||
gists = get_table("gists")
|
||||
gists.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||
|
||||
if value == 1 and target_type in NOTIFY_ON_VOTE:
|
||||
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 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 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})
|
||||
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)
|
||||
|
||||
referer = request.headers.get("Referer", "/feed")
|
||||
return RedirectResponse(url=referer, status_code=302)
|
||||
|
||||
+4
-17
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -151,17 +152,6 @@ def software_source_code_schema(gist, base_url):
|
||||
}
|
||||
|
||||
|
||||
def _json_ld_dumps(payload):
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
return (
|
||||
raw.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
.replace("
", "\\u2028")
|
||||
.replace("
", "\\u2029")
|
||||
)
|
||||
|
||||
|
||||
def combine(schemas):
|
||||
if not schemas:
|
||||
return None
|
||||
@@ -172,12 +162,9 @@ def combine(schemas):
|
||||
cleaned.append(s)
|
||||
if not cleaned:
|
||||
return None
|
||||
payload = (
|
||||
{"@context": "https://schema.org", **cleaned[0]}
|
||||
if len(cleaned) == 1
|
||||
else {"@context": "https://schema.org", "@graph": cleaned}
|
||||
)
|
||||
return _json_ld_dumps(payload)
|
||||
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)
|
||||
|
||||
|
||||
DEFAULT_OG_IMAGE = "/static/og-default.png"
|
||||
|
||||
@@ -126,12 +126,12 @@
|
||||
|
||||
.admin-btn-sm {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.admin-select {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
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.25rem 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
|
||||
@@ -408,12 +408,12 @@ img {
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -461,13 +461,13 @@ img {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.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-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-fun { background: var(--topic-fun); color: #000; }
|
||||
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
||||
.badge-signals { background: var(--topic-signals); color: var(--white); }
|
||||
.badge-signals { background: var(--topic-signals); color: #fff; }
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
@@ -479,7 +479,7 @@ img {
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--white);
|
||||
color: #fff;
|
||||
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; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
|
||||
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white);
|
||||
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
|
||||
font-size: 0.6875rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
@@ -723,6 +723,16 @@ 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;
|
||||
@@ -969,20 +979,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
.post-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
.gist-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gist-card-header {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.news-card-body {
|
||||
padding: 1rem;
|
||||
padding: 1.125rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.news-card-title {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
@@ -270,7 +270,7 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 680px) {
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
|
||||
@@ -115,22 +115,6 @@
|
||||
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;
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
|
||||
.service-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,20 +28,6 @@
|
||||
|
||||
--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.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,30 +1,24 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class AttachmentUploader {
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
@@ -174,7 +172,8 @@ export class AttachmentUploader {
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
Toast.flash(this.errorEl, msg, 5000, "");
|
||||
this.errorEl.textContent = msg;
|
||||
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export class Avatar {
|
||||
static imgHtml(username, size = 24) {
|
||||
const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
|
||||
return `<img src="${url}" class="avatar-img" style="width:${size}px;height:${size}px;border-radius:50%" alt="" loading="lazy">`;
|
||||
}
|
||||
}
|
||||
|
||||
window.Avatar = Avatar;
|
||||
@@ -51,11 +51,6 @@ 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;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class DomUtils {
|
||||
constructor() {
|
||||
this.initClipboardCopy();
|
||||
this.initShareButtons();
|
||||
this.initTogglers();
|
||||
this.initStopPropagation();
|
||||
this.initCardLinks();
|
||||
}
|
||||
|
||||
initClipboardCopy() {
|
||||
@@ -15,7 +14,9 @@ export class DomUtils {
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source.textContent);
|
||||
Toast.flash(btn, "Copied!", 2000);
|
||||
const original = btn.textContent;
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = original; }, 2000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
@@ -31,7 +32,9 @@ export class DomUtils {
|
||||
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Toast.flash(btn, "Copied!", 1000);
|
||||
const original = btn.textContent;
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = original; }, 1000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
@@ -53,4 +56,14 @@ 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TextInput } from "./TextInput.js";
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
this.textarea = textarea;
|
||||
@@ -37,7 +35,15 @@ export class EmojiPicker {
|
||||
}
|
||||
|
||||
insert(unicode) {
|
||||
TextInput.insertAtCursor(this.textarea, unicode);
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const text = ta.value;
|
||||
ta.value = text.substring(0, start) + unicode + text.substring(end);
|
||||
const newPos = start + unicode.length;
|
||||
ta.setSelectionRange(newPos, newPos);
|
||||
ta.focus();
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export class Http {
|
||||
static async getJson(url) {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static postForm(action, data = {}) {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = action;
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
window.Http = Http;
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
import { TextInput } from "./TextInput.js";
|
||||
|
||||
export class MentionInput {
|
||||
constructor(element) {
|
||||
this.input = element;
|
||||
@@ -54,7 +50,8 @@ export class MentionInput {
|
||||
|
||||
async fetch(query) {
|
||||
try {
|
||||
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
|
||||
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
|
||||
const data = await resp.json();
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
this.dropdown.style.display = "none";
|
||||
@@ -74,7 +71,7 @@ export class MentionInput {
|
||||
item.type = "button";
|
||||
item.className = "mention-dropdown-item";
|
||||
item.dataset.username = r.username;
|
||||
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
|
||||
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>';
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
this.insert(r.username);
|
||||
@@ -124,7 +121,11 @@ export class MentionInput {
|
||||
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
|
||||
before = before.replace(/@+$/, "");
|
||||
after = after.replace(/^@+/, "");
|
||||
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
|
||||
this.input.value = before + "@" + username + " " + after;
|
||||
const newPos = before.length + username.length + 2;
|
||||
this.input.setSelectionRange(newPos, newPos);
|
||||
this.input.focus();
|
||||
this.input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.dropdown.style.display = "none";
|
||||
this.lastMatch = null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { Avatar } from "./Avatar.js";
|
||||
|
||||
export class MessageSearch {
|
||||
constructor() {
|
||||
this.initMessageSearch();
|
||||
@@ -29,7 +26,8 @@ export class MessageSearch {
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||
const data = await resp.json();
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
dropdown.style.display = "none";
|
||||
@@ -40,7 +38,7 @@ export class MessageSearch {
|
||||
const item = document.createElement("a");
|
||||
item.className = "search-dropdown-item";
|
||||
item.href = `/messages?with_uid=${r.uid}`;
|
||||
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
|
||||
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
|
||||
dropdown.appendChild(item);
|
||||
}
|
||||
dropdown.style.display = "block";
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export class NotificationManager {
|
||||
constructor() {
|
||||
this.initHashScroll();
|
||||
}
|
||||
|
||||
initHashScroll() {
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith("#comment-")) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("load", () => {
|
||||
const target = document.getElementById(hash.slice(1));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
target.classList.add("comment-highlight");
|
||||
setTimeout(() => target.classList.remove("comment-highlight"), 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class PushManager {
|
||||
constructor() {
|
||||
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
if (!this.supported) {
|
||||
return;
|
||||
}
|
||||
this.triggers = Array.from(document.querySelectorAll("[data-push-enable]"));
|
||||
this.bindTriggers();
|
||||
this.refreshTriggerVisibility();
|
||||
this.register(true).catch((error) => console.error("Push silent register failed:", error));
|
||||
}
|
||||
|
||||
bindTriggers() {
|
||||
this.triggers.forEach((trigger) => {
|
||||
trigger.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.optIn();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refreshTriggerVisibility() {
|
||||
const granted = Notification.permission === "granted";
|
||||
this.triggers.forEach((trigger) => {
|
||||
trigger.hidden = granted;
|
||||
});
|
||||
}
|
||||
|
||||
async optIn() {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await this.register(false);
|
||||
}
|
||||
}
|
||||
|
||||
async register(silent) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/service-worker.js");
|
||||
await registration.update();
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
this.refreshTriggerVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const keyResponse = await fetch("/push.json");
|
||||
const keyData = await keyResponse.json();
|
||||
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey,
|
||||
});
|
||||
|
||||
const response = await fetch("/push.json", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Bad status code from server.");
|
||||
}
|
||||
|
||||
this.refreshTriggerVisibility();
|
||||
} catch (error) {
|
||||
console.error("Error registering push notifications:", error);
|
||||
if (!silent) {
|
||||
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class PwaInstaller {
|
||||
constructor() {
|
||||
this.deferredPrompt = null;
|
||||
this.triggers = Array.from(document.querySelectorAll("[data-pwa-install]"));
|
||||
if (!this.triggers.length) {
|
||||
return;
|
||||
}
|
||||
this.bindTriggers();
|
||||
window.addEventListener("beforeinstallprompt", (event) => this.onPromptAvailable(event));
|
||||
window.addEventListener("appinstalled", () => this.hideTriggers());
|
||||
}
|
||||
|
||||
bindTriggers() {
|
||||
this.triggers.forEach((trigger) => {
|
||||
trigger.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.install();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onPromptAvailable(event) {
|
||||
event.preventDefault();
|
||||
this.deferredPrompt = event;
|
||||
this.showTriggers();
|
||||
}
|
||||
|
||||
showTriggers() {
|
||||
this.triggers.forEach((trigger) => {
|
||||
trigger.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
hideTriggers() {
|
||||
this.triggers.forEach((trigger) => {
|
||||
trigger.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
async install() {
|
||||
if (!this.deferredPrompt) {
|
||||
return;
|
||||
}
|
||||
this.deferredPrompt.prompt();
|
||||
await this.deferredPrompt.userChoice;
|
||||
this.deferredPrompt = null;
|
||||
this.hideTriggers();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class ServiceMonitor {
|
||||
constructor() {
|
||||
this.pollInterval = 5000;
|
||||
@@ -31,7 +29,8 @@ class ServiceMonitor {
|
||||
|
||||
async pollServices() {
|
||||
try {
|
||||
const data = await Http.getJson("/admin/services/data");
|
||||
const resp = await fetch("/admin/services/data");
|
||||
const data = await resp.json();
|
||||
const container = document.getElementById("services-list");
|
||||
if (!container) return;
|
||||
for (const svc of data.services) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export class TextInput {
|
||||
static applyValue(element, value, caretPos) {
|
||||
element.value = value;
|
||||
element.setSelectionRange(caretPos, caretPos);
|
||||
element.focus();
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
static insertAtCursor(element, text) {
|
||||
const start = element.selectionStart;
|
||||
const end = element.selectionEnd;
|
||||
const value = element.value.substring(0, start) + text + element.value.substring(end);
|
||||
TextInput.applyValue(element, value, start + text.length);
|
||||
}
|
||||
}
|
||||
|
||||
window.TextInput = TextInput;
|
||||
@@ -1,13 +0,0 @@
|
||||
export class Toast {
|
||||
static flash(element, message, ms = 2000, revertTo = null) {
|
||||
const original = revertTo === null ? element.textContent : revertTo;
|
||||
element.textContent = message;
|
||||
setTimeout(() => {
|
||||
if (element.textContent === message) {
|
||||
element.textContent = original;
|
||||
}
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
window.Toast = Toast;
|
||||
@@ -1,52 +1,43 @@
|
||||
import { Toast } from "./Toast.js";
|
||||
|
||||
export class VoteManager {
|
||||
constructor() {
|
||||
this.initVoteButtons();
|
||||
this.initNotificationDismiss();
|
||||
}
|
||||
|
||||
initVoteButtons() {
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
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();
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"id": "/?source=pwa",
|
||||
"name": "DevPlace",
|
||||
"short_name": "DevPlace",
|
||||
"description": "The Developer Social Network",
|
||||
"start_url": "/feed?source=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"background_color": "#0f0a1a",
|
||||
"theme_color": "#0f0a1a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- retoor <retoor@molodetz.nl> -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Offline - DevPlace</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f0a1a;
|
||||
--bg-card: #221436;
|
||||
--accent: #ff6b35;
|
||||
--text-primary: #f0e8f8;
|
||||
--text-muted: #9a8db0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.offline-card {
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.offline-mark {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 1.25rem;
|
||||
border-radius: 16px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-size: 2.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 72px;
|
||||
}
|
||||
h1 { font-size: 1.375rem; margin: 0 0 0.5rem; }
|
||||
p { color: var(--text-muted); line-height: 1.5; margin: 0 0 1.5rem; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="offline-card">
|
||||
<div class="offline-mark">D</div>
|
||||
<h1>You are offline</h1>
|
||||
<p>DevPlace could not reach the network. Check your connection and try again.</p>
|
||||
<button type="button" onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const CACHE_NAME = "devplace-shell-v1";
|
||||
const OFFLINE_URL = "/static/offline.html";
|
||||
const PRECACHE_URLS = [
|
||||
OFFLINE_URL,
|
||||
"/manifest.json",
|
||||
"/static/icon-192.png",
|
||||
"/static/icon-512.png",
|
||||
];
|
||||
const DEFAULT_URL = "/notifications";
|
||||
const DEFAULT_ICON = "/static/icon-192.png";
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") {
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(OFFLINE_URL))
|
||||
);
|
||||
});
|
||||
|
||||
function isClientOpen(url) {
|
||||
return clients.matchAll().then((matchedClients) =>
|
||||
matchedClients.some((client) => client.url === url && "focus" in client)
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
event.waitUntil(handlePush(event));
|
||||
});
|
||||
|
||||
async function handlePush(event) {
|
||||
if (!self.Notification || self.Notification.permission !== "granted") {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data ? event.data.json() : {};
|
||||
const url = data.url || DEFAULT_URL;
|
||||
|
||||
if (await isClientOpen(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = data.title || "DevPlace";
|
||||
const message = data.message || "You have a new notification.";
|
||||
const icon = data.icon || DEFAULT_ICON;
|
||||
|
||||
await self.registration.showNotification(title, {
|
||||
body: message,
|
||||
icon: icon,
|
||||
badge: icon,
|
||||
tag: "devplace-notification",
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : DEFAULT_URL;
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
});
|
||||
-3
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>
|
||||
@@ -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" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
|
||||
<span class="comment-vote-count">{{ item.votes.up - item.votes.down }}</span>
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="comment-vote-btn">-</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<div class="comment-header">
|
||||
<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">💬</span> Reply</button>
|
||||
<button class="comment-action-btn" data-action="reply"><span class="icon">💬</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">🗑️</span> Delete</button>
|
||||
<button type="submit" class="comment-action-btn"><span class="icon">🗑️</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">📤</span> Post</button>
|
||||
<button type="submit" class="comment-form-submit"><span class="icon">📤</span>Post</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -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) %}⚡{% else %}🔒{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
|
||||
<span class="icon">{% if u.get('is_active', True) %}⚡{% else %}🔒{% 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">🔑</span> Password</button>
|
||||
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">🔑</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">💾</span> Set</button>
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">💾</span>Set</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="admin-text-muted">You</span>
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💻</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">
|
||||
@@ -63,12 +58,6 @@
|
||||
</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">⬇️</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">🔕</span>
|
||||
</button>
|
||||
<a href="/notifications" class="topnav-icon">
|
||||
<span class="nav-bell">🔔</span>
|
||||
{% set unread_count = get_unread_count(user["uid"]) %}
|
||||
@@ -85,11 +74,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">☰</button>
|
||||
@@ -167,7 +156,6 @@
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -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">+</button>
|
||||
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button>
|
||||
</form>
|
||||
<span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
|
||||
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
<button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post">−</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
|
||||
|
||||
@@ -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">📧</span> Send Reset Link</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">📧</span>Send Reset Link</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -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">☆ <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
|
||||
<button type="submit" class="gist-star-btn">☆ {{ star_count }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
|
||||
@@ -15,17 +15,15 @@
|
||||
<span class="icon">📋</span>All
|
||||
</a>
|
||||
{% for code, name in languages %}
|
||||
{% if code != 'plaintext' and code in gist_language_codes %}
|
||||
{% if code != 'plaintext' %}
|
||||
<a href="/gists?language={{ code }}" class="sidebar-link {% if current_language == code %}active{% endif %}">
|
||||
<span class="icon">📝</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">📝</span>Plain Text
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
@@ -49,15 +47,12 @@
|
||||
|
||||
<div class="gists-grid">
|
||||
{% for item in gists %}
|
||||
<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 fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
|
||||
<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">☆ <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
|
||||
<button type="submit" class="gist-card-star">☆ {{ item.gist.get('stars', 0) }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<section class="landing-hero">
|
||||
<h1>Devplace.net — 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">✨</span> Join DevPlace Free</a>
|
||||
<a href="/auth/signup" class="landing-cta"><span class="icon">✨</span>Join DevPlace Free</a>
|
||||
|
||||
<div class="landing-features">
|
||||
<div class="landing-feature">
|
||||
@@ -117,11 +117,11 @@
|
||||
<footer class="landing-footer">
|
||||
<p>© DevPlace — The Developer Social Network</p>
|
||||
<div class="landing-footer-links">
|
||||
<a href="/feed"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/news"><span class="icon">📰</span> News</a>
|
||||
<a href="/projects"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/auth/login"><span class="icon">🔑</span> Login</a>
|
||||
<a href="/auth/signup"><span class="icon">✨</span> Sign Up</a>
|
||||
<a href="/feed"><span class="icon">📝</span>Posts</a>
|
||||
<a href="/news"><span class="icon">📰</span>News</a>
|
||||
<a href="/projects"><span class="icon">🚀</span>Projects</a>
|
||||
<a href="/auth/login"><span class="icon">🔑</span>Login</a>
|
||||
<a href="/auth/signup"><span class="icon">✨</span>Sign Up</a>
|
||||
<a href="/bugs"><span class="icon">🐛</span> Bug Report</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<a href="/auth/forgot-password">Forgot your password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔑</span> Sign in</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔑</span>Sign in</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<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">🔔</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">✅</span>Clear</button>
|
||||
</form>
|
||||
@@ -17,20 +16,18 @@
|
||||
<div class="notification-group">
|
||||
<div class="notification-group-label">{{ group.label }}</div>
|
||||
{% for item in group.entries %}
|
||||
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
|
||||
{% set target_url = item.notification.get('target_url', '') %}
|
||||
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
|
||||
{% set actor_username = item.actor['username'] if item.actor else '#' %}
|
||||
{% set _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">{{ item.notification['message'] }}</div>
|
||||
<div class="notification-text">{% if target_url %}<a href="{{ target_url }}" style="color:inherit;text-decoration:none">{% endif %}{{ item.notification['message'] }}{% if target_url %}</a>{% endif %}</div>
|
||||
<div class="notification-time">{{ item.time_ago }}</div>
|
||||
</div>
|
||||
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
|
||||
<button type="submit" class="notification-dismiss">×</button>
|
||||
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
|
||||
<span class="post-vote-count">{{ post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
|
||||
@@ -145,10 +145,10 @@
|
||||
<a href="/feed" class="back-link">← 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">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">📝</span> Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">📝</span>Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span>Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">📝</span>Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span>Activity</a>
|
||||
<button class="btn-ghost btn-icon profile-tab-btn">△</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" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
|
||||
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span>
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
|
||||
@@ -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">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
|
||||
<button type="submit" class="project-star-btn">☆ {{ star_count }}</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">🗑️</span> Delete</button>
|
||||
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">🗑️</span>Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -53,15 +53,12 @@
|
||||
|
||||
<div class="projects-grid">
|
||||
{% for project in projects %}
|
||||
<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 fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
|
||||
<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">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
|
||||
<button type="submit" class="project-card-star">☆</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔒</span> Reset Password</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">🔒</span>Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit"><span class="icon">✨</span> Create account</button>
|
||||
<button type="submit" class="auth-submit"><span class="icon">✨</span>Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@@ -5,6 +5,12 @@ 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)
|
||||
|
||||
+14
-73
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import html
|
||||
import re
|
||||
import secrets
|
||||
@@ -8,7 +7,7 @@ from passlib.hash import pbkdf2_sha256
|
||||
from fastapi import Request, HTTPException, status
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.config import SESSION_MAX_AGE
|
||||
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,13 +86,6 @@ 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 ""
|
||||
@@ -148,74 +140,13 @@ 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")
|
||||
actor = users.find_one(uid=actor_uid)
|
||||
if not actor:
|
||||
return
|
||||
actor_username = actor["username"]
|
||||
notifs = get_table("notifications")
|
||||
seen = set()
|
||||
for username in usernames:
|
||||
if username in seen:
|
||||
@@ -223,7 +154,17 @@ 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:
|
||||
create_notification(mentioned["uid"], "mention", f"@{actor_username} mentioned you", actor_uid, target_url)
|
||||
notifs.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": mentioned["uid"],
|
||||
"type": "mention",
|
||||
"message": f"@{username} mentioned you",
|
||||
"related_uid": actor_uid,
|
||||
"target_url": target_url,
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
clear_unread_cache(mentioned["uid"])
|
||||
|
||||
|
||||
def format_date(dt_str: str, include_time: bool = False) -> str:
|
||||
|
||||
@@ -13,8 +13,6 @@ dependencies = [
|
||||
"python-dotenv",
|
||||
"aiofiles",
|
||||
"httpx",
|
||||
"cryptography",
|
||||
"PyJWT",
|
||||
"multiavatar",
|
||||
"locust",
|
||||
"Pillow",
|
||||
|
||||
+1
-1
@@ -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[href='/notifications']")
|
||||
bell = page.locator(".topnav-icon").first
|
||||
assert bell.is_visible()
|
||||
|
||||
|
||||
|
||||
@@ -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[href='/notifications']")
|
||||
bell = page.locator(".topnav-icon").first
|
||||
assert bell.is_visible()
|
||||
|
||||
|
||||
|
||||
+1
-180
@@ -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[href='/notifications']")
|
||||
bell = page.locator(".topnav-icon").first
|
||||
assert bell.is_visible()
|
||||
|
||||
|
||||
@@ -332,182 +332,3 @@ 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()
|
||||
|
||||
+3
-19
@@ -1,5 +1,3 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
|
||||
|
||||
@@ -46,23 +44,9 @@ def test_post_vote_increment(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "showcase", "Vote increment test")
|
||||
page.locator(".post-action-btn.vote-up").first.click()
|
||||
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}"
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
count = page.locator(".post-vote-count").first.text_content().strip()
|
||||
assert count == "1", f"expected vote count 1, got {count!r}"
|
||||
|
||||
|
||||
def test_post_comments_section(alice):
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
|
||||
|
||||
@@ -20,10 +18,9 @@ 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()
|
||||
expect(page.locator(count).first).to_have_text(str(before + 1))
|
||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
||||
after = int(page.locator(star).first.inner_text().strip("☆ "))
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
+4
-109
@@ -1,25 +1,19 @@
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
import requests
|
||||
from PIL import Image
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def _user(prefix="up"):
|
||||
def _session():
|
||||
s = requests.Session()
|
||||
name = f"{prefix}_{uuid.uuid4().hex[:10]}"
|
||||
name = f"up_{int(time.time() * 1000)}"
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name,
|
||||
"email": f"{name}@test.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
return s, name
|
||||
|
||||
|
||||
def _session():
|
||||
s, _ = _user()
|
||||
return s
|
||||
|
||||
|
||||
@@ -29,12 +23,6 @@ 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")})
|
||||
@@ -72,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server):
|
||||
|
||||
def test_upload_requires_login(app_server):
|
||||
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
assert r.status_code in (302, 303)
|
||||
|
||||
|
||||
def test_delete_own_allowed_other_user_forbidden(app_server):
|
||||
@@ -81,96 +69,3 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
|
||||
bob = _session()
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user