Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c8842ea23 | ||
|
|
ecbc353633 | ||
|
|
293d7cf443 | ||
|
|
00e59a7418 | ||
|
|
23adc04770 | ||
|
|
21f6ae0615 | ||
|
|
b777a5b9d0 | ||
|
|
cf5b7751e3 | ||
|
|
6c161401de | ||
|
|
e02919a1db | ||
|
|
3a0f682052 | ||
|
|
68c403747c | ||
|
|
f72f2edf6b | ||
|
|
4bd420f38b | ||
|
|
13f9fb5a96 | ||
|
|
0128aad7b5 | ||
|
|
0ec3e61118 | ||
|
|
a78b656ef9 | ||
|
|
7674dac628 | ||
|
|
53ddf4f233 | ||
|
|
f44e3d1db8 | ||
|
|
9d14149f62 | ||
|
|
5079f40f46 | ||
|
|
d895de1b47 | ||
|
|
571a0485c5 | ||
|
|
b185574760 | ||
|
|
3709f4fab9 | ||
|
|
1a87c392bd | ||
|
|
ff49c8342a | ||
|
|
76d73ccaea | ||
|
|
8d5d5f90be | ||
|
|
46f87a48e3 | ||
|
|
e05f97c924 | ||
|
|
2d72e0785d | ||
|
|
dbe1e2670b | ||
|
|
fda72c5afb | ||
|
|
6b3df26a52 | ||
|
|
b1a104ebb1 | ||
|
|
b5fb6436d0 | ||
|
|
3006a1b039 | ||
|
|
1f320b45ec | ||
|
|
a8ed5b690f |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python *)",
|
||||
"Bash(DEVPLACE_DISABLE_SERVICES=1 python -)",
|
||||
"Bash(command -v hawk)",
|
||||
"Bash(export DEVPLACE_DISABLE_SERVICES=1)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:///tmp/devplace_verify.db\")",
|
||||
"Bash(rm -f /tmp/devplace_verify.db)",
|
||||
"Bash(export DEVPLACE_DATABASE_URL=\"sqlite:////tmp/devplace_verify.db\")",
|
||||
"Bash",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/routers/projects/containers/instances.py)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/components/ContainerTerminal.js)",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/services/containers/store.py)",
|
||||
"Verify",
|
||||
"Edit(/home/retoor/projects/devplacepy/devplacepy/static/js/MessagesLayout.js)",
|
||||
"Write(/home/retoor/projects/devplacepy/devplacepy/static/css/messages.css)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ notification-private.pem
|
||||
notification-private.pkcs8.pem
|
||||
notification-public.pem
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.opencode
|
||||
.dpc/
|
||||
.claude/settings.local.json
|
||||
devii_*.db
|
||||
devii_*.db-shm
|
||||
devii_*.db-wal
|
||||
|
||||
-313
@@ -1,313 +0,0 @@
|
||||
## 2026-06-19 🟢
|
||||
|
||||
- Block and mute user relations with API endpoints, CLI emoji-sync command, and content filtering
|
||||
|
||||
|
||||
## 2026-06-18 🟢
|
||||
|
||||
- News service with image dedup, AI grading, featured/landing auto-rotation and admin lock
|
||||
- Server-rendered content pipeline with Telegram pairing, response timing, and admin user index
|
||||
- AI Markdown reformatting for news articles with usage metering and sidebar cleanup
|
||||
|
||||
|
||||
## 2026-06-17 🟢
|
||||
|
||||
- Backup download restricted to primary admin, admin-hidden projects invisible to other admins
|
||||
- Access token system with CLI management and wildcard file type support
|
||||
- Access token issuance with JSON/form login endpoint and token lifecycle management
|
||||
|
||||
|
||||
## 2026-06-16 🔥 Big day!
|
||||
|
||||
- Gateway admin UI with provider and model routing for OpenAI gateway
|
||||
- Backup management CLI commands and service layer with configurable data directories
|
||||
- Audit logging for admin trash restore/purge and notification clear, plus SEO noindex for private projects and sitemap docs page refactor
|
||||
- Instance lookup by name in addition to uid and slug, new terminal session service
|
||||
- Keyboard-aware input visibility with ResizeObserver fallback for mobile message layout
|
||||
- E2E comment hierarchy seed helpers for gists, news and projects
|
||||
- Optimistic message insertion disabled to prevent duplicate bubbles
|
||||
- Router tree documented in AGENTS.md with 14 new route entries
|
||||
- Add dpc binary to container image and set executable permissions
|
||||
- Remove .html and .svg from allowed upload types and MIME mappings
|
||||
|
||||
|
||||
## 2026-06-15 🟢
|
||||
|
||||
- ASGI lifespan handler with background service orchestration and lock-based worker coordination
|
||||
- Message chunking with sentence-aware splitting and configurable character limits
|
||||
- Enforce hard test-coverage standard across DevPlace workflows and agents
|
||||
- Audio file support with inline player and expanded allowed upload types
|
||||
- Gist comment form integration with card-scoped comment targeting
|
||||
- Bot account API key adoption for per-user gateway spend attribution
|
||||
|
||||
|
||||
## 2026-06-14 🔥 Massive day!
|
||||
|
||||
- Admin/internal database API with CRUD, natural-language query, and read-only SQL execution
|
||||
- DeepSearch research job queue with CLI prune/clear, Chroma vector store, and date-aware system message composition
|
||||
- DeepSearch multi-agent researcher with grounded RAG chat and per-session vector store
|
||||
- SEO Diagnostics tool with CLI management, live WebSocket progress, and static asset cache-busting
|
||||
- OpenAI-compatible embeddings endpoint with model mapping and usage tracking
|
||||
- Stealth HTTP client with curl_cffi transport adapter replacing raw httpx for outbound requests
|
||||
- Bot monitor with live age badges and zoomable screenshots
|
||||
- Author-interleaved feed ordering across all feed views and tabs
|
||||
- Author diversity via interleaving (no per-author cap) for home and feed
|
||||
- devRant API client library and example scripts in Python and JavaScript
|
||||
- TTLCache-backed cache version reads with invalidation on bump
|
||||
- Random client IP spoofing for load-testing traffic
|
||||
- Deepsearch chat component attribute naming from data-* to direct properties
|
||||
- Replace `python -m agents.validator` with `hawk` across all agent markdown files
|
||||
|
||||
|
||||
## 2026-06-13 🔥 Massive day!
|
||||
|
||||
- Three-tier test suite with unit, API, and E2E directories mirroring source and endpoint paths
|
||||
- Soft-delete audit for bookmarks, comments, follows, polls, project files, reactions, and bug create request event
|
||||
- Notification preferences with per-user per-channel toggles and admin defaults
|
||||
- Author diversity enforcement across home page and feed with personalized landing for authenticated users
|
||||
- Shared free-text search across feed, gists, and projects listings
|
||||
- Docs search with agent-powered Docii chat and admin-configurable search mode
|
||||
- Devii agent audit log query action with filterable paginated endpoint
|
||||
- Router directory-tree convention with admin audit log, AI quota, and container management endpoints
|
||||
- Initial maintenance agent fleet with per-dimension code quality enforcers
|
||||
- Unified blob sharding on uuid7 random tail across attachments, project files, and zip service
|
||||
- Consolidated runtime data directory layout with migration CLI command
|
||||
- Context-aware window control button visibility with font size boundary detection and minimize/normalize size presets
|
||||
- Overflow-managed profile tabs with a "more" dropdown for narrow screens
|
||||
- Startup jitter, randomized browser fingerprinting, and short comment styles for bot realism
|
||||
- Sidebar search form with hidden field support and configurable placeholder
|
||||
- Prevent titlebar double-click maximize when clicking buttons in FloatingWindow and DeviiTerminal
|
||||
- Pin test server to single worker and use upsert for rate-limit settings to prevent spurious 429s
|
||||
- DEVPLACE_DISABLE_RATE_LIMIT env var to bypass rate limiter in tests and middleware
|
||||
- Fallback to location.origin when DEVPLACE_DOCS.base is missing
|
||||
- Add claude-manual task-oriented guide page with cross-reference from claude.html
|
||||
- Remove PWA install button and associated installer module
|
||||
- Removed stale test files and fixed Gitea env teardown and ingress proxy test cleanup
|
||||
- Locustfile seed data expansion and route exclusion documentation
|
||||
|
||||
|
||||
## 2026-06-12 🔥 Massive day!
|
||||
|
||||
- Agent report system with codenames, timestamped output streams, and write-budget enforcement
|
||||
- Tool-scoped payload filtering for worker agents with orchestration tool isolation
|
||||
- Agent isolation and result caching in Maestro review sweep
|
||||
- Concurrent read-only fleet check mode with per-agent cost tracking and contextvar-isolated findings
|
||||
- Admin analytics and AI usage API response keys renamed, password change toggle added
|
||||
- Gitea-backed bug tracker with list/detail/comment/status and AI-enhanced filing
|
||||
- Bug detail page with admin/member role rendering and viewer_is_admin context flag
|
||||
- Changed-files fast mode for maintenance agents with write-allowlist guard
|
||||
- Partial config save with error reporting and password manager suppression
|
||||
- Admin route cache-disabling headers via Cache-Control, Pragma and Expires
|
||||
- Dirty-field tracking and server-side value sync for service config forms
|
||||
- Rename `is_admin` to `viewer_is_admin` in bug detail schema, router, and template
|
||||
- Bug tracker unavailable page with JSON and HTML 503 error responses
|
||||
- Bot comments avoid repeating sibling opinions via thread-aware distinctness prompt
|
||||
- Default Gitea repository changed from pydevplace to devplacepy
|
||||
- Remove pytest-xdist parallel test execution, switch to serial single-process test runner
|
||||
|
||||
|
||||
## 2026-06-11 🔥 Massive day!
|
||||
|
||||
- Platform-wide soft delete with deleted_at/deleted_by columns and admin trash management
|
||||
- Owner-or-admin soft-delete enforcement on all content endpoints
|
||||
- Unified image lightbox with attribute-wired opening and per-user media tab with soft delete
|
||||
- Autonomous maintenance agent fleet with CLI entry point, Makefile targets, and dependency-free validator
|
||||
- Seed-finding guided fix mode for maintenance agents with incomplete report tracking
|
||||
- Audit log tables with CLI recording hooks
|
||||
- Resolve merge conflict in pagination template and add admin-audit-log endpoint to docs API
|
||||
- Bots documentation pages and session stop/reset commands
|
||||
- Reduced nested comment indentation from 1.5rem to 0.25rem per depth level
|
||||
- Reduce comment indentation multiplier and padding for nested replies
|
||||
- Switch to dynamic viewport height and remove autofocus from message input
|
||||
- Inline message layout with responsive height and auto-scroll
|
||||
- Optional label attribute with hidden empty state for dp-upload component
|
||||
- Mandatory retoor header added to all devplacepy source files
|
||||
|
||||
|
||||
## 2026-06-10 🟢
|
||||
|
||||
- Port conflict detection and test isolation hardening across admin, avatar, bugs, landing, messages, and customization tests
|
||||
- Container proxy routing via container IP instead of host port, with fake backend network simulation
|
||||
- XDG-compliant devii tasks database path with DEVII_HOME override
|
||||
- Project editing endpoint with 125k char body limit and remote URL attachment guard
|
||||
|
||||
|
||||
## 2026-06-09 🔥 Big day!
|
||||
|
||||
- Container manager with Dockerfile CRUD, image builds, instance lifecycle, ingress proxy, and CLI commands
|
||||
- Async project fork service with job queue, CLI management, and shared container image build
|
||||
- Parallel test execution with per-worker isolated databases, data dirs, and uvicorn subprocesses via pytest-xdist
|
||||
- Per-user customization suppression toggles with profile UI and Devii tool
|
||||
- Customization toggle UI with enable/disable state management
|
||||
- Unified shared Http and Poller utilities across all frontend modules, replacing inline fetch and setInterval patterns
|
||||
- Responsive refinements for sub-360px screens, touch targets, safe-area insets, and mobile window controls
|
||||
- Click-to-open profile dropdown with keyboard and outside-click dismissal
|
||||
- Migrate hardcoded spacing values to CSS custom properties across multiple stylesheets
|
||||
- Unicode escape normalization for emoji constants across codebase
|
||||
- Consolidated upload ignore rules into a single directory-level gitignore entry
|
||||
- Removed unused imports across routers, database, and services
|
||||
- Remove project_set_private from confirmation-required actions and fix async test helpers
|
||||
|
||||
|
||||
## 2026-06-08 🟢
|
||||
|
||||
- Admin AI quota management with CLI and admin panel reset controls
|
||||
- API key management CLI with backfill command and auth support across session, API key, and HTTP Basic
|
||||
- Per-project filesystem with directory and file CRUD, upload, and inline editing
|
||||
- Async zip job framework with CLI management and zip archive download endpoints
|
||||
- Add mistune dependency to project
|
||||
|
||||
|
||||
## 2026-06-06 🟢
|
||||
|
||||
- Reactions, bookmarks, polls, extended sessions, and operational settings
|
||||
|
||||
|
||||
## 2026-06-05 🔥 Massive day!
|
||||
|
||||
- Batch attachment linking, deduplicated mention notifications, and idempotent badge milestone checks
|
||||
- Cursor-based load-more pagination across feed, gists, news and projects
|
||||
- Canonical slug redirects, cursor-based next-page links, and OG image extraction across feed, gists, news, posts, projects, and profile
|
||||
- TTLCache with LRU eviction, CLI role management, content unit helpers, database query functions, follow API with XP rewards, and news service with AI grading
|
||||
- Unified comment form component with mobile touch optimizations across all CSS
|
||||
- Inline comment previews on post cards with per-comment reply forms
|
||||
- Comment template with threaded voting, author display, and attachment support
|
||||
- Post-login redirect with `next` parameter and unauthenticated comment redirect to login
|
||||
- Login redirect for unauthenticated admin, next parameter support with external URL rejection, and inline comment reply forms
|
||||
- Seed comments created for all posts instead of only the first
|
||||
- Replace uuid4 with uuid7 via uuid_utils for push notification JWT jti claims
|
||||
- Coverage instrumentation for CI and local test runs with HTML report artifact
|
||||
- Coverage configuration with subprocess measurement support
|
||||
- Sitemap TTL configurable via environment variable and news_images schema migration
|
||||
- Kill stale server process and add startup failure detection for Locust targets
|
||||
|
||||
|
||||
## 2026-06-02 🟢
|
||||
|
||||
- Multi-worker service lock with cascading vote/comment cleanup on content deletion
|
||||
|
||||
|
||||
## 2026-05-30 🟢
|
||||
|
||||
- Leaderboard route with gamification system (XP, levels, badges, stars) and content creation refactor
|
||||
|
||||
|
||||
## 2026-05-28 🟢
|
||||
|
||||
- Cursor-based pagination for feed, notifications, and votes with thumbnail extension fallback
|
||||
- Push registration returns creation flag and only sends welcome notification on first registration
|
||||
|
||||
|
||||
## 2026-05-27 🟢
|
||||
|
||||
- AJAX vote buttons with live count updates across posts, gists, projects, and comments
|
||||
- CSS-only card-link overlay replacing JS-driven data-href navigation
|
||||
|
||||
|
||||
## 2026-05-25 🟢
|
||||
|
||||
- Unified notification click-to-navigate with comment anchor highlighting and dismiss refactor
|
||||
|
||||
|
||||
## 2026-05-23 🔥 Massive day!
|
||||
|
||||
- Web push notifications with PWA manifest and service worker registration
|
||||
- Web push notifications with PWA offline shell and install prompt
|
||||
- Unified badge, notification, and content enrichment system with star tracking helpers
|
||||
- Aggregate star counts across posts, projects and gists for profile and top-author ranking
|
||||
- Content editing and deletion with cascading cleanup, avatar image helper, HTTP form POST, text input cursor management, and toast flash utility
|
||||
- Share button with clipboard copy across detail pages, structured data schemas for gists and news articles, configurable site URL and rate limit, and production proxy headers support
|
||||
- Production deployment workflow via git merge master into production
|
||||
- Automatic production deployment on successful master push
|
||||
- Removed automatic production deployment from CI pipeline
|
||||
- Admin settings form with Pydantic validation and model-driven save
|
||||
- Pydantic form models with validation for signup, login, password reset, comments, bugs, admin actions, and posts
|
||||
- Type-safe integer settings with empty-value skip on admin save
|
||||
- Input validation tests for votes, posts, profile, and signup endpoints
|
||||
- Rate-limit environment variable and expanded Locust seed data for gists, notifications, and uploads
|
||||
- TTLCache with ETag-based HTTP caching for avatar endpoint
|
||||
- Dynamic language sidebar filtering based on existing gist language codes
|
||||
- Vendor static assets for CodeMirror, highlight.js, marked, and emoji picker
|
||||
- Test server log capture via tempfile with reduced log verbosity
|
||||
- DOMPurify XSS sanitization for client-side rendered markdown content
|
||||
- Add mobile-web-app-capable meta tag for PWA support
|
||||
- Topnav notification bell selector scoped to /notifications href
|
||||
- Fix notification bell icon locator to use explicit href selector instead of first match
|
||||
- Remove stale import of get_users_by_uids from project_detail endpoint
|
||||
|
||||
|
||||
## 2026-05-22 🟢
|
||||
|
||||
- News article HTML sanitization CLI command and database migration
|
||||
|
||||
|
||||
## 2026-05-19 🟢
|
||||
|
||||
- Avatar generation exception logging with full traceback
|
||||
- Fix multiavatar import path and add required arguments to function call
|
||||
|
||||
|
||||
## 2026-05-16 🟢
|
||||
|
||||
- Clickable post titles and content with downvote support on feed and detail pages
|
||||
- Interactive vote buttons and clickable post titles on profile page
|
||||
- Handle @-mention with preceding text in content rendering
|
||||
- Unread notification cache invalidation across comments, follows, messages, votes, and mentions
|
||||
- Compact send button, attachment upload container, and auto-scroll on message thread load
|
||||
- GistEditor lazy init with modal observer, CodeMirror Rust mode removed, emoji picker module type, source textarea required removed, projects tab spacing and settings button removed
|
||||
- Add space between icon and label in feed navigation tabs
|
||||
|
||||
|
||||
## 2026-05-15 🟢
|
||||
|
||||
- Python 3.13 base image, default port 10500, and nginx template to conf.d migration
|
||||
- Responsive mobile navigation and messages layout with hamburger menu and back button
|
||||
- Responsive breakpoint widened from 768px to 1024px for topnav, breadcrumb and page layouts
|
||||
|
||||
|
||||
## 2026-05-14 🟢
|
||||
|
||||
- Migrate from deprecated `datetime.utcnow()` to timezone-aware `datetime.now(timezone.utc)` across the entire codebase
|
||||
- Wait-for-url stabilization in noindex tests for messages and notifications pages
|
||||
- Disable parallel test execution in CI pipeline
|
||||
|
||||
|
||||
## 2026-05-13 🟢
|
||||
|
||||
- Migrate all TemplateResponse calls to pass request as first positional argument
|
||||
- Attachment linking and deletion refactored into dedicated module with batch support
|
||||
- Parallelised integration test suite with xdist worker port isolation
|
||||
- Remove deprecated imghdr dependency and fix icon spacing in bug report buttons
|
||||
- Replace hardcoded pytest.BASE_URL with conftest BASE_URL in attachment tests
|
||||
- CI trigger branch from main to master
|
||||
|
||||
|
||||
## 2026-05-12 🟢
|
||||
|
||||
- News service with admin curation, landing page articles, and comment support
|
||||
- Mention notification system across bugs, comments, messages, posts, and projects with user search API
|
||||
- Gists page with code snippet sharing, voting, and comment integration
|
||||
|
||||
|
||||
## 2026-05-11 🔥 Big day!
|
||||
|
||||
- Unified threaded comment system with polymorphic target support across posts, projects, and bugs
|
||||
- News management system with admin panel, pagination, and SEO sitemap integration
|
||||
- News background service framework with CLI management, bug reports router, and admin services monitoring
|
||||
- Admin panel with user management CLI, SEO metadata, and production deployment config
|
||||
- Multiavatar local SVG generation with WAL mode SQLite and Locust load testing
|
||||
- CI branch target renamed from main to master and test fixtures refactored for explicit login and seeded database
|
||||
- Test fixture improvements with debug logging, stderr capture, and extended startup timeout
|
||||
- Remove hawk static analysis step from CI test workflow
|
||||
|
||||
|
||||
## 2026-05-10 🚀 First commit!
|
||||
|
||||
- Initial project scaffold with FastAPI SSR app, auth, feed, posts, comments, projects, profile, messages, notifications, and voting
|
||||
- DiceBear avatar proxy with style picker on signup and profile, threaded comments
|
||||
- Image upload support for posts with daily topic display on landing and feed
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
Summary: 194 commits over 29 active days. The project launched on May 10 with the initial FastAPI scaffold, auth, feed, and core content features. The biggest pushes came on June 13 (23 commits) delivering the three-tier test suite, soft-delete audit system, notification preferences, and author diversity enforcement; June 23 (23 commits) adding web push notifications, PWA support, content editing/deletion, and production deployment workflows; and June 14 (14 commits) introducing the admin database API, DeepSearch research system, SEO diagnostics, and the stealth HTTP client.
|
||||
|
||||
@@ -65,6 +65,7 @@ devplace devii tasks prune # disable every task whose owner may not
|
||||
devplace gateway quota list # list AI gateway quota rules and current 24h spend
|
||||
devplace gateway quota set --limit-usd N [--owner-kind K] [--owner-id ID] [--app-reference APP] [--label L] [--uid UID]
|
||||
devplace gateway quota delete <uid> # delete a quota rule
|
||||
devplace gateway quota reset [--owner-kind K] [--owner-id ID] [--app-reference APP] # clear the counted 24h spend (keeps the usage history)
|
||||
devplace zips prune # delete expired zip archives + job rows
|
||||
devplace zips clear # delete every zip archive + job row
|
||||
devplace forks prune # delete expired completed fork job rows (forked projects persist)
|
||||
@@ -118,6 +119,7 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA
|
||||
| `DEVPLACE_WEB_WORKERS` / `--workers` | `nproc` (prod) | Uvicorn worker count; `make prod WEB_WORKERS=N` to override. |
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | How long after a user's last activity they still count as online. `config.PRESENCE_WRITE_SECONDS` (half of it) throttles `last_seen` writes per worker. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. |
|
||||
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes as the authority for every avatar dot. `PRESENCE_ONLINE_LIMIT` only caps how many of them the feed panel *displays*. |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. |
|
||||
| `DEVPLACE_DATA_DIR` | `<repo>/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. |
|
||||
| `DEVPLACE_OUTBOUND_PROXY_URL` | unset | Fallback for the `outbound_proxy_url` site setting (below) when the DB/settings row is unavailable (early CLI contexts). Prefer configuring the setting via `/admin/settings` - it applies live with no restart. |
|
||||
@@ -147,6 +149,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/services/bot/CLAUDE.md` | `BotsService` fleet |
|
||||
| `devplacepy/services/dbapi/CLAUDE.md` | `/dbapi` primary-admin-only read-only database API |
|
||||
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
|
||||
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
|
||||
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
|
||||
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
|
||||
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
|
||||
@@ -157,7 +160,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
|
||||
| `devplacepy/templates/CLAUDE.md` | Modal system, CDN libraries, shared template partials |
|
||||
| `tests/CLAUDE.md` | Detailed testing patterns and pitfalls |
|
||||
|
||||
`isslop/` at the repo root is a separate, standalone sibling project (own `pyproject.toml`, `Makefile`, port 18732) with its own `isslop/CLAUDE.md` - it is not a nested subsystem of the `devplacepy` package. The integrated engine that DevPlace actually runs (`devplace isslop analyze`, the `/tools/isslop` job service) is a distinct implementation documented in `devplacepy/services/jobs/CLAUDE.md`.
|
||||
The AI usage analyzer (`devplace isslop analyze`, the `/tools/isslop` job service) lives entirely inside the package at `devplacepy/services/jobs/isslop/` and is documented in `devplacepy/services/jobs/CLAUDE.md`. There is no repo-root `isslop/` project.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -248,7 +251,7 @@ Users and guests inject their own CSS and JS, scoped to a page type or globally,
|
||||
|
||||
### Container manager, Devii assistant, AI gateway, async jobs, audit log
|
||||
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 223 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
DevPlace ships a Docker-backed **Container manager** (admin-only, one shared `ppy:latest` image, security-hardened rootless workflow via a `sudo` superclone and `aptroot`), the **Devii** in-platform agentic assistant (WebSocket terminal, per-owner-channel sessions, scheduler/reminders, self-learning lessons, user-defined virtual tools, self-configured behavior, browser-automation client tools), the **AI gateway** (`/openai/v1/*`, single point of truth for every AI call, per-user cost attribution, provider/model routing overlay), **async job services** (`JobService` base pattern: zip, fork, SEO diagnostics, SEO metadata generation, DeepSearch, AI Usage Analyzer), and an admin-only append-only **audit log** (`record`/`record_system` entrypoints, `events.md` catalogue of 288 keys, never raises into the caller). These are among the largest subsystems in the codebase - read their dedicated nested `CLAUDE.md` files (see Subsystem map) before working in any of them; do not assume prior knowledge from this summary.
|
||||
|
||||
### Telegram bot, email, devRant compatibility API, issue tracker
|
||||
|
||||
@@ -346,6 +349,7 @@ Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to
|
||||
Single-host Docker Compose (`docker-compose.yml`): an **app** container (Uvicorn, 2 workers) behind an **nginx** container. Full operator reference is the admin-only `Production` docs section (`templates/docs/production*.html`); conventions that must not regress:
|
||||
|
||||
- **Shared DB and files = same as dev.** The app container bind-mounts the host project root (`.:/app`) and runs as `${DEVPLACE_UID}:${DEVPLACE_GID}` (default `1000`), so it reads/writes the same `data/devplace.db`, `data/uploads/`, `data/devii_*.db`, `data/keys/` (VAPID), and `data/locks/devplace-services.lock` as `make dev`. No `DEVPLACE_DATABASE_URL` override - `config.py` resolves an absolute path under the project's `data/` dir. WAL + the `flock` on `devplace-services.lock` make concurrent dev/prod safe and keep a single background-services owner. SQLite is local-file, so prod and dev must be the **same host**.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Code updates need no rebuild** (bind-mounted source); rebuild only on `pyproject.toml` dependency changes. Use **`make docker-reload`** (`restart app` + `up -d --wait`) to pick up new source - a bare `make docker-up` does **not** restart an unchanged container, so the running uvicorn keeps serving the code it imported at boot. `.env` is git-ignored; `.env.example` is the committed template. The app reads `DEVPLACE_DATABASE_URL`, never `DATABASE_URL`.
|
||||
- **Dockerfile layer order is load-bearing for build time.** The dependency layer (`pip install ".[bots]"` + `playwright install --with-deps chromium`, ~3GB and ~2.5 min) must depend on `pyproject.toml` **only**. `COPY devplacepy/` therefore comes *after* it, and the project itself is installed last with `pip install --no-deps --force-reinstall .`. hatchling needs the package directory to exist to build a wheel, so the dependency layer creates a placeholder `devplacepy/__init__.py` that the real `COPY` overwrites (verified: site-packages holds the full 39-entry package, not the stub). Copying source before the install inverts this and makes **every source edit** reinstall every dependency and re-download Chromium - measured 2m36s per source-only rebuild versus 7.4s with the correct order. Never move `COPY devplacepy/` above the dependency layer.
|
||||
- **nginx parity rules** (`nginx/nginx.conf.template`, rendered by `start.sh` via `envsubst` with an allow-list that preserves `$http_upgrade`): `/static/uploads/` must re-apply `nosniff` + a `Content-Disposition` via the `map $uri $upload_disposition` block (`inline` for safe image/video/audio extensions, `attachment` otherwise), mirroring `UploadStaticFiles.INLINE_MEDIA_EXTENSIONS` - an XSS control nginx would otherwise bypass, and the inline branch is what lets video play in production; `/devii/ws` needs the `map $http_upgrade $connection_upgrade` block and `Upgrade`/`Connection` headers or the Devii terminal cannot connect (every new WebSocket route needs its own nginx upgrade location - the catch-all `location /` strips upgrade headers); `client_max_body_size` comes from `NGINX_MAX_BODY_SIZE` (default `50m`) and must be `>= max_upload_size_mb` or uploads 413. nginx serves `devplacepy/static` via a read-only bind mount, so assets stay current without an image rebuild.
|
||||
- **Healthcheck start period** (`start_period: 120s` in `docker-compose.yml`, `--start-period=120s` in `Dockerfile`): full startup takes ~110s (DB init, services, uvicorn workers). The start period must stay above that. Bump both files if startup grows.
|
||||
- **Healthcheck cadence** (`docker-compose.yml` + `Dockerfile`, keep both in step): `start_period: 120s` is the grace window in which a failing probe does not count against `retries`; `start_interval: 2s` is how often the probe runs *inside* that window. Without `start_interval` the first probe only fires after the full `interval: 30s`, so a container ready in 5s still reports healthy at 30s and `depends_on: service_healthy` holds nginx back for no reason. The generous 120s start period is deliberate headroom for a cold page cache on a multi-GB database, not a measure of normal startup - normal startup is a few seconds. **Startup work is a per-worker, lock-serialized cost:** `lifespan` runs `init_db()` under an exclusive `init_lock()`, so every uvicorn worker pays it end to end, one after another, and total time-to-serving is `workers x init_db`. Never put a per-user or per-row scan in `init_db` - see the backfill convergence rule in `devplacepy/database/CLAUDE.md`.
|
||||
|
||||
+10
-6
@@ -4,6 +4,8 @@ WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates \
|
||||
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Optional: the docker CLI so the (admin-only) container manager can drive the host
|
||||
@@ -18,22 +20,24 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
|
||||
rm -rf /var/lib/apt/lists/* ; \
|
||||
fi
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
|
||||
&& pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
|
||||
COPY devplacepy/ devplacepy/
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
RUN pip install --no-cache-dir ".[bots]" \
|
||||
&& python -m playwright install --with-deps chromium \
|
||||
&& chmod -R a+rx /ms-playwright
|
||||
RUN pip install --no-cache-dir --no-deps --force-reinstall .
|
||||
|
||||
EXPOSE 10500
|
||||
|
||||
ENV DEVPLACE_WEB_WORKERS=2
|
||||
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
|
||||
CMD curl -f http://localhost:10500/ || exit 1
|
||||
|
||||
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
|
||||
|
||||
@@ -138,7 +138,7 @@ DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
|
||||
export DEVPLACE_DATA_DIR
|
||||
export DOCKER_GID
|
||||
|
||||
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
|
||||
.PHONY: docker-build docker-up docker-reload docker-down docker-logs docker-clean docker-prep ppy
|
||||
|
||||
# Build the single shared container image every instance runs. Build once;
|
||||
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
|
||||
@@ -154,6 +154,10 @@ docker-build: docker-prep
|
||||
docker-up: docker-prep
|
||||
$(COMPOSE) up -d
|
||||
|
||||
docker-reload:
|
||||
$(COMPOSE) restart app
|
||||
$(COMPOSE) up -d --wait
|
||||
|
||||
docker-down:
|
||||
$(COMPOSE) down
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ make test-headed # same tests in visible browser
|
||||
|
||||
Open `http://localhost:10500`.
|
||||
|
||||
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
@@ -40,7 +46,7 @@ devplacepy/
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
templates/ # Jinja2 HTML templates
|
||||
static/css/ # Page-specific CSS files
|
||||
@@ -221,6 +227,7 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
|
||||
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
|
||||
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes on `public.presence.roster`. That one set is the single source of truth behind every avatar presence dot on every page; `DEVPLACE_PRESENCE_ONLINE_LIMIT` only caps how many of them the feed's Online now panel displays |
|
||||
|
||||
### Runtime settings
|
||||
|
||||
@@ -543,6 +550,17 @@ disclosed only to administrators, while members and guests can see only the perc
|
||||
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
|
||||
everyone, and includes dollar figures only for administrators.
|
||||
|
||||
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
|
||||
gateway spend with quota rules scoped by any combination of caller role, individual user, and
|
||||
application reference (the `X-App-Reference` header), so a single application belonging to one
|
||||
user can be limited independently of that user's other traffic. The most specific matching rule
|
||||
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
|
||||
time, each rule has a **Reset spend** action that clears what has been counted against it
|
||||
without deleting anything from the usage history the cost analytics are built on - the
|
||||
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
|
||||
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
|
||||
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
|
||||
|
||||
Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
@@ -800,9 +818,37 @@ calling itself.
|
||||
|
||||
## Push notifications & PWA
|
||||
|
||||
Authenticated users can receive native web push notifications, and the site is an
|
||||
Authenticated users can receive native 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.
|
||||
`PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
|
||||
no third-party push wrapper.
|
||||
|
||||
### Providers
|
||||
|
||||
Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
|
||||
receives a notification through every provider they hold a live subscription for.
|
||||
|
||||
| Provider | Registration | Transport |
|
||||
|----------|--------------|-----------|
|
||||
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
|
||||
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
|
||||
|
||||
`POST /push.json` accepts a registration for any active provider; a body without a
|
||||
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
|
||||
the VAPID public key plus the providers currently accepting registrations. A provider that
|
||||
is disabled or not fully configured accepts no registrations and is skipped during
|
||||
delivery, so an unconfigured provider is inert rather than an error.
|
||||
|
||||
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
|
||||
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
|
||||
masked secret), topic and environment (production or sandbox) for `apns`. The same page
|
||||
holds the shared delivery timeout and the retention window after which dead subscriptions
|
||||
are removed. Push delivery does not depend on that service running; stopping it only stops
|
||||
the pruning sweep.
|
||||
|
||||
Adding a third provider is one file plus one registry entry: the registration route, the
|
||||
delivery loop, the admin page, the audit record and the metrics are all written against the
|
||||
provider protocol.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -825,9 +871,11 @@ in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
|
||||
|
||||
`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.
|
||||
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
|
||||
each provider's payload once, and sends over a single shared HTTP client. A subscription
|
||||
the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
|
||||
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
|
||||
kept.
|
||||
|
||||
A notification is also **marked read automatically when you open the page that shows its
|
||||
content** - viewing a post clears its comment, reply, upvote and mention notifications;
|
||||
@@ -884,7 +932,10 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
|
||||
| `devplacepy/push/store.py` | `push_registration` reads and writes |
|
||||
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
|
||||
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
|
||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
||||
@@ -984,11 +1035,13 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make docker-up # restart with new code (bind-mounted, no rebuild)
|
||||
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
|
||||
make docker-build && \
|
||||
make docker-up # only when dependencies in pyproject.toml change
|
||||
```
|
||||
|
||||
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
|
||||
|
||||
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
|
||||
|
||||
### Container Manager wiring (what the overlay does)
|
||||
|
||||
@@ -12,7 +12,7 @@ def enforce_rgba_png(file_bytes: bytes) -> bytes:
|
||||
corner = img.getpixel((0, 0))
|
||||
if len(corner) == 4 and corner[3] == 255:
|
||||
bg = corner[:3]
|
||||
data = img.getdata()
|
||||
data = img.get_flattened_data()
|
||||
cleaned = []
|
||||
for pixel in data:
|
||||
if pixel[:3] == bg:
|
||||
|
||||
@@ -70,6 +70,35 @@ def cmd_gateway_quota_delete(args):
|
||||
print(f"Deleted quota rule {args.uid}")
|
||||
|
||||
|
||||
def cmd_gateway_quota_reset(args):
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
try:
|
||||
payload = quota.QuotaResetIn(
|
||||
owner_kind=args.owner_kind,
|
||||
owner_id=args.owner_id,
|
||||
app_reference=args.app_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}")
|
||||
sys.exit(1)
|
||||
scope = quota.reset(payload, created_by="cli")
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
_audit_cli(
|
||||
"gateway.quota.reset",
|
||||
f"CLI reset the gateway 24h spend for {label}",
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
print(f"Reset the rolling 24h spend for {label}")
|
||||
|
||||
|
||||
def register_gateway(subparsers):
|
||||
gateway = subparsers.add_parser("gateway", help="AI gateway management")
|
||||
gateway_sub = gateway.add_subparsers(title="action", dest="action")
|
||||
@@ -101,3 +130,16 @@ def register_gateway(subparsers):
|
||||
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
|
||||
quota_delete.add_argument("uid", help="Quota rule uid")
|
||||
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
|
||||
|
||||
quota_reset = quota_sub.add_parser(
|
||||
"reset",
|
||||
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
|
||||
)
|
||||
quota_reset.add_argument(
|
||||
"--owner-kind",
|
||||
choices=("internal", "key", "user", "admin", "anonymous"),
|
||||
help="Role to scope by. Omit to reset every role",
|
||||
)
|
||||
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
|
||||
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
|
||||
quota_reset.set_defaults(func=cmd_gateway_quota_reset)
|
||||
|
||||
@@ -16,6 +16,7 @@ UPLOADS_DIR = DATA_DIR / "uploads"
|
||||
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
|
||||
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
|
||||
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
|
||||
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
|
||||
ZIPS_DIR = DATA_DIR / "zips"
|
||||
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
|
||||
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
|
||||
@@ -50,6 +51,7 @@ SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
|
||||
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
|
||||
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
|
||||
PRESENCE_TRACK_LIMIT = int(environ.get("DEVPLACE_PRESENCE_TRACK_LIMIT", "500"))
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS = int(
|
||||
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
|
||||
)
|
||||
@@ -106,6 +108,12 @@ INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
|
||||
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
|
||||
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
|
||||
|
||||
WORKSPACE_TUNNEL_DOMAIN = environ.get(
|
||||
"DEVPLACE_WORKSPACE_TUNNEL_DOMAIN", "tunnel.pravda.education"
|
||||
).strip()
|
||||
WORKSPACE_ACTIVITY_WRITE_SECONDS = 30
|
||||
WORKSPACE_METRICS_RING = 720
|
||||
|
||||
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
|
||||
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
|
||||
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
|
||||
@@ -117,6 +125,7 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"attachments": ATTACHMENTS_DIR,
|
||||
"project_files": PROJECT_FILES_DIR,
|
||||
"container_workspaces": CONTAINER_WORKSPACES_DIR,
|
||||
"workspace_state": WORKSPACE_STATE_DIR,
|
||||
"zips": ZIPS_DIR,
|
||||
"zip_staging": ZIP_STAGING_DIR,
|
||||
"fork_staging": FORK_STAGING_DIR,
|
||||
|
||||
@@ -13,6 +13,8 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
get_users_by_uids,
|
||||
get_vote_counts,
|
||||
get_comment_counts_by_post_uids,
|
||||
paginate,
|
||||
STAR_TARGETS,
|
||||
get_user_votes,
|
||||
get_reactions_by_targets,
|
||||
@@ -58,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_project_by_uid(project_uid: str | None) -> dict | None:
|
||||
if not project_uid:
|
||||
return None
|
||||
project = get_table("projects").find_one(uid=project_uid)
|
||||
if not project:
|
||||
return None
|
||||
slug = project.get("slug") or project["uid"]
|
||||
return {
|
||||
"uid": project["uid"],
|
||||
"name": project.get("title") or project.get("name", ""),
|
||||
"slug": slug,
|
||||
"url": f"/projects/{slug}",
|
||||
}
|
||||
|
||||
|
||||
def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||
return bool(item and user and item["user_uid"] == user["uid"])
|
||||
|
||||
@@ -119,6 +136,45 @@ def can_manage_instance(
|
||||
return is_primary_admin(user) or owns_instance(instance, project, user)
|
||||
|
||||
|
||||
def workspaces_enabled() -> bool:
|
||||
from devplacepy.database import get_setting
|
||||
|
||||
return get_setting("workspace_enabled", "0") == "1"
|
||||
|
||||
|
||||
def can_open_workspace(project: dict | None, user: dict | None) -> bool:
|
||||
if not project or not user or not user.get("uid"):
|
||||
return False
|
||||
if not workspaces_enabled():
|
||||
return False
|
||||
return is_owner(project, user) or is_admin(user)
|
||||
|
||||
|
||||
def owns_workspace(instance: dict | None, user: dict | None) -> bool:
|
||||
if not instance or not user:
|
||||
return False
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
return False
|
||||
return instance.get("workspace_owner_uid") == uid
|
||||
|
||||
|
||||
def can_manage_workspace(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
if not instance or not user:
|
||||
return False
|
||||
if owns_workspace(instance, user):
|
||||
return True
|
||||
return can_manage_instance(instance, project, user)
|
||||
|
||||
|
||||
def can_manage_tunnel(
|
||||
instance: dict | None, project: dict | None, user: dict | None
|
||||
) -> bool:
|
||||
return can_manage_workspace(instance, project, user)
|
||||
|
||||
|
||||
def canonical_redirect(
|
||||
area: str, item: dict, requested: str
|
||||
) -> RedirectResponse | None:
|
||||
@@ -360,6 +416,29 @@ def create_comment_record(
|
||||
comment_url,
|
||||
)
|
||||
|
||||
# Notify previous commenters on this post (participation)
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
if not post:
|
||||
post = posts.find_one(slug=target_uid)
|
||||
if post:
|
||||
post_owner_uid = post["user_uid"]
|
||||
previous_commenters = set()
|
||||
for c in get_table("comments").find(
|
||||
target_type="post", target_uid=target_uid, deleted_at=None
|
||||
):
|
||||
cu = c["user_uid"]
|
||||
if cu != user["uid"] and cu != post_owner_uid:
|
||||
previous_commenters.add(cu)
|
||||
for cu in previous_commenters:
|
||||
create_notification(
|
||||
cu,
|
||||
"participation",
|
||||
f"{user['username']} also commented on this post",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
schedule_correction(user, "comments", comment_uid, request)
|
||||
schedule_modification(user, "comments", comment_uid, request)
|
||||
@@ -510,6 +589,7 @@ def detail_context(
|
||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||
"bookmarked": detail.get("bookmarked", False),
|
||||
"poll": detail.get("poll"),
|
||||
"project_link": detail.get("project_link"),
|
||||
}
|
||||
if extra:
|
||||
context.update(extra)
|
||||
@@ -689,6 +769,7 @@ def load_detail(
|
||||
"reactions": reactions,
|
||||
"bookmarked": bookmarked,
|
||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
||||
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -716,5 +797,28 @@ def enrich_items(
|
||||
entry[name] = (
|
||||
source(item) if callable(source) else source.get(item["uid"], 0)
|
||||
)
|
||||
if key == "post" and item.get("project_uid"):
|
||||
entry["project_link"] = get_project_by_uid(item["project_uid"])
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
|
||||
def get_project_devlog(
|
||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
||||
) -> tuple[list, str | None]:
|
||||
posts, next_cursor = paginate(
|
||||
get_table("posts"),
|
||||
before=before,
|
||||
viewer_uid=viewer["uid"] if viewer else None,
|
||||
project_uid=project_uid,
|
||||
)
|
||||
if not posts:
|
||||
return [], None
|
||||
|
||||
authors = get_users_by_uids([post["user_uid"] for post in posts])
|
||||
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
|
||||
|
||||
enriched = enrich_items(
|
||||
posts, "post", authors, {"comment_count": counts}, user=viewer
|
||||
)
|
||||
return enriched, next_cursor
|
||||
|
||||
@@ -28,6 +28,8 @@ _index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
|
||||
|
||||
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
|
||||
|
||||
**This applies to `users` too, and an `if "users" not in db.tables: return` guard silently defeats it.** `backfill_api_keys()` is the `users` ensure-block (every non-signup column: `api_key`, `last_seen`, the AI correction/modifier settings, `avatar_seed`, `award_count`/`last_award_at`/`last_award_slug`/`last_award_uid`, ...). It used to early-return when `users` was absent, which is exactly the state on a **brand-new database**: `init_db()` runs before the first signup, so the ensure-block was skipped entirely, and the first `/auth/signup` then created `users` with only the columns of that INSERT. Every ensured-but-unwritten column was therefore missing from the long-running server's reflected metadata, so `users.find_one(...)` returned rows without them **for the whole process lifetime** - the feature reading them looked simply switched off (this is what made the awards tab, the prominent-award banner, and the avatar award badge invisible on a fresh DB, and it is invisible in production only because the columns happen to exist from an older boot). It now calls `get_table("users")` unconditionally, so the table is born with the full ensured column set. Never reintroduce a `db.tables` guard in front of a column-ensure block.
|
||||
|
||||
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
|
||||
|
||||
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
|
||||
@@ -105,6 +107,17 @@ if "comments" not in db.tables:
|
||||
|
||||
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
|
||||
|
||||
## Startup backfills must converge (hard rule)
|
||||
|
||||
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
|
||||
|
||||
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
|
||||
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
|
||||
|
||||
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
|
||||
|
||||
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
|
||||
|
||||
## Project-wide soft delete (hard rule)
|
||||
|
||||
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
|
||||
@@ -140,6 +153,7 @@ The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginat
|
||||
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
|
||||
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
|
||||
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
|
||||
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
|
||||
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
|
||||
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
|
||||
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
|
||||
|
||||
@@ -4,7 +4,7 @@ from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta,
|
||||
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
|
||||
from .atomic import conditional_update_row
|
||||
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, is_account_active, search_users_by_username
|
||||
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
|
||||
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
|
||||
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
|
||||
@@ -88,6 +88,7 @@ __all__ = [
|
||||
"set_last_seen",
|
||||
"get_online_users",
|
||||
"get_primary_admin_uid",
|
||||
"is_account_active",
|
||||
"search_users_by_username",
|
||||
"_relations_cache",
|
||||
"get_user_relations",
|
||||
@@ -254,3 +255,5 @@ __all__ = [
|
||||
"backfill_api_keys",
|
||||
"_backfill_gamification",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -185,3 +185,5 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
|
||||
def get_poll_for_post(post_uid, user=None):
|
||||
return get_polls_by_post_uids([post_uid], user).get(post_uid)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "participation", "label": "Post participation", "description": "Someone else comments on a post you also commented on"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
@@ -19,6 +20,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
||||
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
|
||||
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
|
||||
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
|
||||
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
|
||||
]
|
||||
|
||||
@@ -199,3 +201,4 @@ def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
|
||||
|
||||
clear_unread_cache(user_uid)
|
||||
return len(ids)
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ def _ranked_authors() -> list:
|
||||
cached = _authors_cache.get("ranked")
|
||||
if cached is not None:
|
||||
return cached
|
||||
tables = db.tables
|
||||
sources = [
|
||||
(target_type, table_name)
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
]
|
||||
if "votes" not in db.tables or not sources:
|
||||
if "votes" not in tables or not sources:
|
||||
_authors_cache.set("ranked", [])
|
||||
return []
|
||||
target_union = " UNION ALL ".join(
|
||||
@@ -101,12 +102,13 @@ def get_user_stars(user_uid: str) -> int:
|
||||
cached = _stars_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "votes" not in db.tables:
|
||||
tables = db.tables
|
||||
if "votes" not in tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
|
||||
for target_type, table_name in VOTABLE_TARGETS.items()
|
||||
if table_name in db.tables
|
||||
if table_name in tables
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
@@ -154,7 +156,8 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
uids = [uid for uid in (target_uids or []) if uid]
|
||||
if not uids:
|
||||
return
|
||||
if "reactions" in db.tables:
|
||||
tables = db.tables
|
||||
if "reactions" in tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
with db:
|
||||
@@ -162,7 +165,7 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if "bookmarks" in db.tables:
|
||||
if "bookmarks" in tables:
|
||||
placeholders, params = _in_clause(uids)
|
||||
params["tt"] = target_type
|
||||
with db:
|
||||
@@ -170,12 +173,12 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
if target_type == "post" and "polls" in db.tables:
|
||||
if target_type == "post" and "polls" in tables:
|
||||
for uid in uids:
|
||||
for poll in db["polls"].find(post_uid=uid):
|
||||
if "poll_votes" in db.tables:
|
||||
if "poll_votes" in tables:
|
||||
db["poll_votes"].delete(poll_uid=poll["uid"])
|
||||
if "poll_options" in db.tables:
|
||||
if "poll_options" in tables:
|
||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||
db["polls"].delete(post_uid=uid)
|
||||
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import inspect
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy_services.base.db_codec import (
|
||||
decode_value,
|
||||
encode_args,
|
||||
is_write,
|
||||
is_write_sql,
|
||||
)
|
||||
|
||||
_SERVICE_URL = os.environ.get("DEVPLACE_DB_SERVICE_URL", "http://127.0.0.1:10601").rstrip("/")
|
||||
_INTERNAL_KEY = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", "").strip()
|
||||
_CLIENT: httpx.Client | None = None
|
||||
|
||||
# Section 7.3: settings reads tolerate up to 5s staleness. patch_module()
|
||||
# generically RPCs every devplacepy.database call, bypassing the local
|
||||
# TTL cache get_setting/get_int_setting had in-process - without this,
|
||||
# every settings read (rate limiting, maintenance mode, admin dashboards)
|
||||
# pays a full HTTP round trip to the database broker.
|
||||
_SETTINGS_CACHE_TTL_SECONDS = 5
|
||||
_SETTINGS_CACHE = TTLCache(ttl=_SETTINGS_CACHE_TTL_SECONDS, max_size=512)
|
||||
_CACHED_SETTINGS_FNS = frozenset({"get_setting", "get_int_setting"})
|
||||
|
||||
|
||||
def _service_url() -> str:
|
||||
return os.environ.get("DEVPLACE_DB_SERVICE_URL", _SERVICE_URL).rstrip("/")
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
key = os.environ.get("DEVPLACE_GATEWAY_INTERNAL_KEY", _INTERNAL_KEY).strip()
|
||||
if key:
|
||||
headers["X-Internal-Key"] = key
|
||||
return headers
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
global _CLIENT
|
||||
if _CLIENT is None:
|
||||
_CLIENT = httpx.Client(timeout=30.0)
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def _post(path: str, body: dict) -> object:
|
||||
response = _client().post(
|
||||
f"{_service_url()}/{path.lstrip('/')}",
|
||||
json=body,
|
||||
headers=_headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
payload = response.json() if response.content else {}
|
||||
message = payload.get("error", "Database service request failed")
|
||||
raise RuntimeError(message)
|
||||
if not response.content:
|
||||
return None
|
||||
return decode_value(response.json())
|
||||
|
||||
|
||||
def _invoke_cached(fn_name: str, args, kwargs):
|
||||
cache_key = f"{fn_name}:{args!r}:{sorted(kwargs.items())!r}"
|
||||
cached = _SETTINGS_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
value = _invoke(fn_name, args, kwargs, write=False)
|
||||
_SETTINGS_CACHE.set(cache_key, value)
|
||||
return value
|
||||
|
||||
|
||||
def _invoke(fn_name: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
payload = {
|
||||
"fn": fn_name,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
}
|
||||
result = _post("internal/invoke", payload)
|
||||
if isinstance(result, dict) and "result" in result:
|
||||
return result["result"]
|
||||
return result
|
||||
|
||||
|
||||
class RemoteSearchClause:
|
||||
def __init__(self, term, fields, author_field=None):
|
||||
self.term = term.strip()
|
||||
self.fields = tuple(fields)
|
||||
self.author_field = author_field
|
||||
|
||||
|
||||
class RemoteUidInClause:
|
||||
def __init__(self, field, uids):
|
||||
self.field = field
|
||||
self.uids = frozenset(uids)
|
||||
|
||||
|
||||
class RemoteTable:
|
||||
def __init__(self, db: "RemoteDb", name: str) -> None:
|
||||
self._db = db
|
||||
self._name = name
|
||||
self._column_cache = None
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
def caller(*args, **kwargs):
|
||||
return self._db._table_op(self._name, name, args, kwargs)
|
||||
|
||||
return caller
|
||||
|
||||
def has_column(self, name: str) -> bool:
|
||||
cache = self._column_cache
|
||||
if cache is None:
|
||||
sample = self.find(_limit=1)
|
||||
row = next(iter(sample), None)
|
||||
cache = set(row.keys()) if row else set()
|
||||
self._column_cache = cache
|
||||
return name in cache
|
||||
|
||||
def count(self, **kwargs):
|
||||
return self._db._table_op(self._name, "count", [], kwargs)
|
||||
|
||||
@property
|
||||
def table(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self._name in self._db.tables
|
||||
|
||||
class RemoteDb:
|
||||
def __init__(self) -> None:
|
||||
self._tables_cache: list[str] | None = None
|
||||
|
||||
@property
|
||||
def tables(self) -> list[str]:
|
||||
if self._tables_cache is None:
|
||||
result = _post("internal/db-op", {"op": "tables"})
|
||||
self._tables_cache = list(result or [])
|
||||
return self._tables_cache
|
||||
|
||||
def __getitem__(self, name: str) -> RemoteTable:
|
||||
return RemoteTable(self, name)
|
||||
|
||||
def query(self, sql: str, **params):
|
||||
encoded_args, encoded_kwargs = encode_args((sql,), params)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "query",
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": is_write_sql(sql),
|
||||
},
|
||||
)
|
||||
return result or []
|
||||
|
||||
def _table_op(self, table: str, method: str, args, kwargs, *, write: bool = False):
|
||||
encoded_args, encoded_kwargs = encode_args(args, kwargs)
|
||||
result = _post(
|
||||
"internal/db-op",
|
||||
{
|
||||
"op": "table_op",
|
||||
"table": table,
|
||||
"method": method,
|
||||
"args": encoded_args,
|
||||
"kwargs": encoded_kwargs,
|
||||
"write": write,
|
||||
},
|
||||
)
|
||||
if method in {"insert", "update", "delete"}:
|
||||
self._tables_cache = None
|
||||
return result
|
||||
|
||||
@property
|
||||
def executable(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
_LOCAL_REMOTE = frozenset(
|
||||
{
|
||||
"get_table",
|
||||
"refresh_snapshot",
|
||||
"_in_clause",
|
||||
"_now_iso",
|
||||
"text_search_clause",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _remote_text_search_clause(
|
||||
table, search, fields=("title", "description"), author_field=None
|
||||
):
|
||||
term = (search or "").strip()
|
||||
if not term:
|
||||
return None
|
||||
if type(table).__name__ == "RemoteTable":
|
||||
return RemoteSearchClause(term, fields, author_field)
|
||||
from devplacepy.database.content import text_search_clause as local_clause
|
||||
|
||||
return local_clause(table, search, fields, author_field=author_field)
|
||||
|
||||
|
||||
def _remote_get_table(name: str):
|
||||
import devplacepy.database.core as core
|
||||
|
||||
return core.db[name]
|
||||
|
||||
|
||||
def _remote_refresh_snapshot() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def patch_module(module) -> None:
|
||||
import devplacepy.database as db_module
|
||||
|
||||
for name in db_module.__all__:
|
||||
if name in _LOCAL_REMOTE:
|
||||
continue
|
||||
target = getattr(module, name, None)
|
||||
if target is None or not callable(target):
|
||||
continue
|
||||
if inspect.isclass(target):
|
||||
continue
|
||||
|
||||
def make_wrapper(fn_name: str, fn_write: bool):
|
||||
if fn_name in _CACHED_SETTINGS_FNS:
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke_cached(fn_name, args, kwargs)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
return _invoke(fn_name, args, kwargs, write=fn_write)
|
||||
|
||||
wrapper.__name__ = fn_name
|
||||
return wrapper
|
||||
|
||||
setattr(module, name, make_wrapper(name, is_write(name)))
|
||||
|
||||
|
||||
def activate() -> None:
|
||||
import devplacepy.database.core as core
|
||||
|
||||
core.db = RemoteDb()
|
||||
import devplacepy.database as db_module
|
||||
|
||||
patch_module(db_module)
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
patch_module(submodule)
|
||||
for external_name in (
|
||||
"devplacepy.services.statistics.tracking",
|
||||
"devplacepy.services.base",
|
||||
"devplacepy.attachments",
|
||||
"devplacepy.project_files",
|
||||
):
|
||||
try:
|
||||
external = __import__(external_name, fromlist=[external_name.split(".")[-1]])
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(external, "db"):
|
||||
external.db = RemoteDb()
|
||||
db_module.db = core.db
|
||||
db_module.get_table = _remote_get_table
|
||||
core.get_table = _remote_get_table
|
||||
db_module.refresh_snapshot = _remote_refresh_snapshot
|
||||
core.refresh_snapshot = _remote_refresh_snapshot
|
||||
db_module.text_search_clause = _remote_text_search_clause
|
||||
import devplacepy.database.content as content_module
|
||||
|
||||
content_module.text_search_clause = _remote_text_search_clause
|
||||
for submodule_name in (
|
||||
"settings",
|
||||
"users",
|
||||
"relations",
|
||||
"pagination",
|
||||
"soft_delete",
|
||||
"engagement",
|
||||
"usage",
|
||||
"awards",
|
||||
"seo_meta",
|
||||
"activity",
|
||||
"customization",
|
||||
"email",
|
||||
"notifications",
|
||||
"forks",
|
||||
"follows",
|
||||
"deepsearch",
|
||||
"ranking",
|
||||
"comments",
|
||||
"content",
|
||||
"attachments_data",
|
||||
"stats",
|
||||
"schema",
|
||||
):
|
||||
try:
|
||||
submodule = __import__(
|
||||
f"devplacepy.database.{submodule_name}",
|
||||
fromlist=[submodule_name],
|
||||
)
|
||||
except ImportError:
|
||||
continue
|
||||
if hasattr(submodule, "db"):
|
||||
submodule.db = core.db
|
||||
@@ -42,6 +42,7 @@ def init_db():
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
_index(db, "posts", "idx_posts_slug", ["slug"])
|
||||
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
|
||||
if "posts" in tables:
|
||||
posts_table = get_table("posts")
|
||||
if not posts_table.has_column("tags"):
|
||||
@@ -133,7 +134,28 @@ def init_db():
|
||||
)
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
push_registration = get_table("push_registration")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("provider", "webpush"),
|
||||
("endpoint", ""),
|
||||
("key_auth", ""),
|
||||
("key_p256dh", ""),
|
||||
("token", ""),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
):
|
||||
if not push_registration.has_column(column):
|
||||
push_registration.create_column_by_example(column, example)
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
|
||||
if "push_registration" in db.tables:
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE push_registration SET provider = 'webpush' "
|
||||
"WHERE provider IS NULL OR provider = ''"
|
||||
)
|
||||
_index(db, "sessions", "idx_sessions_token", ["session_token"])
|
||||
projects = get_table("projects")
|
||||
for column, example in (
|
||||
@@ -146,6 +168,9 @@ def init_db():
|
||||
("is_private", 0),
|
||||
("read_only", 0),
|
||||
("updated_at", ""),
|
||||
("title", ""),
|
||||
("description", ""),
|
||||
("status", ""),
|
||||
):
|
||||
if not projects.has_column(column):
|
||||
projects.create_column_by_example(column, example)
|
||||
@@ -543,10 +568,109 @@ def init_db():
|
||||
("boot_language", "none"),
|
||||
("boot_script", ""),
|
||||
("start_on_boot", 0),
|
||||
("is_workspace", 0),
|
||||
("workspace_owner_uid", ""),
|
||||
("editor_port", 0),
|
||||
("editor_host_port", 0),
|
||||
("tunnel_name", ""),
|
||||
("last_active_at", ""),
|
||||
("idle_warned_at", ""),
|
||||
("delete_warned_at", ""),
|
||||
("disk_bytes", 0),
|
||||
("disk_sampled_at", ""),
|
||||
("egress_bytes", 0),
|
||||
("request_count", 0),
|
||||
("flagged_at", ""),
|
||||
("flag_reason", ""),
|
||||
("suspended_at", ""),
|
||||
("suspended_by", ""),
|
||||
):
|
||||
if not instances.has_column(column):
|
||||
instances.create_column_by_example(column, example)
|
||||
|
||||
tunnels = get_table("tunnels")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("instance_uid", ""),
|
||||
("project_uid", ""),
|
||||
("user_uid", ""),
|
||||
("hostname", ""),
|
||||
("label", ""),
|
||||
("container_port", 0),
|
||||
("desired_state", "present"),
|
||||
("status", "pending"),
|
||||
("cert_status", ""),
|
||||
("cert_checked_at", ""),
|
||||
("request_count", 0),
|
||||
("bytes_out", 0),
|
||||
("last_request_at", ""),
|
||||
("last_error", ""),
|
||||
("last_synced_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not tunnels.has_column(column):
|
||||
tunnels.create_column_by_example(column, example)
|
||||
|
||||
quota_rules = get_table("workspace_quota_rules")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("label", ""),
|
||||
("max_workspaces", 0),
|
||||
("max_tunnels", 0),
|
||||
("disk_quota_mb", 0),
|
||||
("egress_quota_mb", 0),
|
||||
("idle_stop_minutes", 0),
|
||||
("retention_days", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not quota_rules.has_column(column):
|
||||
quota_rules.create_column_by_example(column, example)
|
||||
|
||||
flags = get_table("workspace_flags")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("instance_uid", ""),
|
||||
("user_uid", ""),
|
||||
("kind", ""),
|
||||
("severity", "warn"),
|
||||
("detail", ""),
|
||||
("metric_value", 0.0),
|
||||
("threshold", 0.0),
|
||||
("status", "open"),
|
||||
("resolved_by", ""),
|
||||
("resolved_at", ""),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
):
|
||||
if not flags.has_column(column):
|
||||
flags.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "instances", "idx_instances_workspace", ["is_workspace", "status"])
|
||||
_index(db, "instances", "idx_instances_workspace_owner", ["workspace_owner_uid"])
|
||||
_index(db, "instances", "idx_instances_tunnel_name", ["tunnel_name"])
|
||||
_index(db, "tunnels", "idx_tunnels_hostname", ["hostname"])
|
||||
_index(db, "tunnels", "idx_tunnels_instance", ["instance_uid"])
|
||||
_index(db, "tunnels", "idx_tunnels_user", ["user_uid"])
|
||||
_index(db, "tunnels", "idx_tunnels_state", ["desired_state", "status"])
|
||||
_index(
|
||||
db,
|
||||
"workspace_quota_rules",
|
||||
"idx_workspace_quota_owner",
|
||||
["owner_kind", "owner_id"],
|
||||
)
|
||||
_index(db, "workspace_flags", "idx_workspace_flags_open", ["status", "created_at"])
|
||||
_index(
|
||||
db,
|
||||
"workspace_flags",
|
||||
"idx_workspace_flags_instance",
|
||||
["instance_uid", "kind"],
|
||||
)
|
||||
_index(db, "workspace_flags", "idx_workspace_flags_user", ["user_uid"])
|
||||
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||
_index(db, "instances", "idx_instances_name", ["name"])
|
||||
@@ -1695,9 +1819,7 @@ def migrate_ai_gateway_settings() -> None:
|
||||
|
||||
|
||||
def backfill_api_keys() -> int:
|
||||
if "users" not in db.tables:
|
||||
return 0
|
||||
users = db["users"]
|
||||
users = get_table("users")
|
||||
if not users.has_column("api_key"):
|
||||
users.create_column_by_example("api_key", "")
|
||||
if not users.has_column("created_at"):
|
||||
@@ -1760,6 +1882,29 @@ def backfill_api_keys() -> int:
|
||||
return updated
|
||||
|
||||
|
||||
MILESTONE_SOURCES = (
|
||||
("posts", "user_uid"),
|
||||
("comments", "user_uid"),
|
||||
("projects", "user_uid"),
|
||||
("gists", "user_uid"),
|
||||
("follows", "follower_uid"),
|
||||
("follows", "following_uid"),
|
||||
("user_activity", "user_uid"),
|
||||
)
|
||||
|
||||
|
||||
def _milestone_candidates() -> set:
|
||||
tables = db.tables
|
||||
candidates = set()
|
||||
for table, column in MILESTONE_SOURCES:
|
||||
if table not in tables:
|
||||
continue
|
||||
for row in db.query(f"SELECT DISTINCT {column} AS uid FROM {table}"):
|
||||
if row["uid"]:
|
||||
candidates.add(row["uid"])
|
||||
return candidates
|
||||
|
||||
|
||||
def _backfill_gamification():
|
||||
if "users" not in db.tables:
|
||||
return
|
||||
@@ -1823,6 +1968,12 @@ def _backfill_gamification():
|
||||
)
|
||||
|
||||
_authors_cache.clear()
|
||||
for user in pending:
|
||||
candidates = _milestone_candidates()
|
||||
checked = [user for user in pending if user["uid"] in candidates]
|
||||
for user in checked:
|
||||
check_milestone_badges(user["uid"])
|
||||
logger.info(f"Gamification backfill processed {len(pending)} users")
|
||||
logger.info(
|
||||
f"Gamification backfill processed {len(pending)} users, "
|
||||
f"{len(checked)} with milestone-eligible activity"
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ SOFT_DELETE_TABLES = [
|
||||
"sessions",
|
||||
"instances",
|
||||
"instance_schedules",
|
||||
"tunnels",
|
||||
"workspace_flags",
|
||||
"backup_schedules",
|
||||
"devii_conversations",
|
||||
"devii_tasks",
|
||||
|
||||
@@ -74,10 +74,15 @@ def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
|
||||
)
|
||||
|
||||
|
||||
def is_account_active(row) -> bool:
|
||||
is_active = (row or {}).get("is_active")
|
||||
return is_active is None or bool(is_active)
|
||||
|
||||
|
||||
def _can_hold_primary_admin(row, tracks_active):
|
||||
if row.get("deleted_at"):
|
||||
return False
|
||||
return not tracks_active or bool(row.get("is_active"))
|
||||
return not tracks_active or is_account_active(row)
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _activate() -> None:
|
||||
if os.environ.get("DEVPLACE_DB_SERVICE") == "1":
|
||||
return
|
||||
if os.environ.get("DEVPLACE_REMOTE_DB") == "1":
|
||||
from devplacepy.database.remote import activate
|
||||
|
||||
activate()
|
||||
|
||||
|
||||
_activate()
|
||||
|
||||
import devplacepy.database as _database
|
||||
|
||||
|
||||
def _remote_table(table) -> bool:
|
||||
return type(table).__name__ == "RemoteTable"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
return getattr(_database, name)
|
||||
|
||||
|
||||
def __dir__():
|
||||
return sorted(name for name in dir(_database) if not name.startswith("_"))
|
||||
@@ -12,6 +12,7 @@ from . import (
|
||||
uploads,
|
||||
project_files,
|
||||
containers,
|
||||
workspaces,
|
||||
tools,
|
||||
push,
|
||||
issues,
|
||||
@@ -34,6 +35,7 @@ ORDERED_GROUPS = [
|
||||
uploads.GROUP,
|
||||
project_files.GROUP,
|
||||
containers.GROUP,
|
||||
workspaces.GROUP,
|
||||
tools.GROUP,
|
||||
push.GROUP,
|
||||
issues.GROUP,
|
||||
|
||||
@@ -648,6 +648,26 @@ four ways to sign requests.
|
||||
field("label", "json", "string", False, "", "Optional admin-facing note."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-reset",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-resets",
|
||||
title="Reset the AI gateway 24h spend",
|
||||
summary=(
|
||||
"Clear the counted rolling-24h spend for a scope so a capped caller can call "
|
||||
"again, without deleting any usage history (the cost analytics stay intact). "
|
||||
"Scope it exactly like a quota rule; leaving all three dimensions blank resets "
|
||||
"every caller. Only spend recorded before the reset is cleared - new calls "
|
||||
"count again immediately against the same limit."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("owner_kind", "json", "string", False, "user", "internal, key, user, admin, or anonymous. Blank = every role."),
|
||||
field("owner_id", "json", "string", False, "", "Specific user uid. Blank = every caller."),
|
||||
field("app_reference", "json", "string", False, "typosaurus", "App label (the X-App-Reference header). Blank = every app."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-gateway-quota-rule-delete",
|
||||
method="DELETE",
|
||||
|
||||
@@ -41,9 +41,12 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
notes=[
|
||||
"Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.",
|
||||
],
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
@@ -425,7 +428,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -444,7 +447,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
"One of: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -792,4 +795,5 @@ four ways to sign requests.
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,15 @@ GROUP = {
|
||||
"intro": """
|
||||
# Web Push
|
||||
|
||||
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
|
||||
register a `PushSubscription` obtained from the browser's `PushManager`.
|
||||
Push notifications are delivered by one or more providers. `webpush` is the default and
|
||||
implements the Web Push protocol: fetch the public VAPID key, then register a
|
||||
`PushSubscription` obtained from the browser's `PushManager`. `apns` delivers to an Apple
|
||||
Push Notification service device token and is only offered when an administrator has
|
||||
configured it.
|
||||
|
||||
`GET /push.json` lists the providers that currently accept registrations. A registration
|
||||
body without a `provider` field is a `webpush` registration, so existing clients need no
|
||||
change.
|
||||
|
||||
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
||||
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
||||
@@ -26,39 +33,60 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/push.json",
|
||||
title="Get the public key",
|
||||
summary="Return the VAPID public key for subscribing.",
|
||||
summary="Return the VAPID public key and the providers that accept registrations.",
|
||||
auth="public",
|
||||
sample_response={"publicKey": "BASE64_VAPID_KEY"},
|
||||
sample_response={
|
||||
"publicKey": "BASE64_VAPID_KEY",
|
||||
"providers": {"webpush": {"publicKey": "BASE64_VAPID_KEY"}},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="push-register",
|
||||
method="POST",
|
||||
path="/push.json",
|
||||
title="Register a subscription",
|
||||
summary="Register a browser push subscription. Sends a welcome notification.",
|
||||
summary="Register a push subscription. Sends a welcome notification.",
|
||||
auth="user",
|
||||
encoding="json",
|
||||
interactive=False,
|
||||
params=[
|
||||
field(
|
||||
"provider",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"webpush",
|
||||
"Provider to register with. Omit for webpush.",
|
||||
),
|
||||
field(
|
||||
"endpoint",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
False,
|
||||
"https://fcm.googleapis.com/...",
|
||||
"Subscription endpoint URL.",
|
||||
"Subscription endpoint URL. Required for webpush.",
|
||||
),
|
||||
field(
|
||||
"keys",
|
||||
"json",
|
||||
"string",
|
||||
True,
|
||||
False,
|
||||
'{"p256dh":"...","auth":"..."}',
|
||||
"Subscription keys object.",
|
||||
"Subscription keys object. Required for webpush.",
|
||||
),
|
||||
field(
|
||||
"token",
|
||||
"json",
|
||||
"string",
|
||||
False,
|
||||
"a1b2c3...",
|
||||
"Hexadecimal device token. Required for apns.",
|
||||
),
|
||||
],
|
||||
notes=[
|
||||
'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
|
||||
'A webpush body is JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.',
|
||||
'An APNs body is JSON: `{"provider": "apns", "token": "..."}`.',
|
||||
"A provider that is unknown, disabled or unconfigured returns 400.",
|
||||
],
|
||||
sample_response={"registered": True},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "workspaces",
|
||||
"title": "Dev Workspaces",
|
||||
"intro": """
|
||||
# Dev Workspaces
|
||||
|
||||
A workspace is a browser VS Code environment attached to one of your projects. It runs your project
|
||||
files, a terminal, and preinstalled Python, Rust, Nim and Swift toolchains. `sudo` and
|
||||
`apt install` work with no extra setup; ports below 1024 cannot bind, so use a high port and publish
|
||||
it through a tunnel.
|
||||
|
||||
A **tunnel** publishes one port from inside your container on a public HTTPS hostname of the form
|
||||
`<port>-<name>.tunnel.pravda.education`. **Tunnel URLs are public and unauthenticated** - anyone with
|
||||
the link can reach whatever you are serving.
|
||||
|
||||
Workspaces are bounded: a count limit per user, a disk quota, an egress quota, and a tunnel limit.
|
||||
An idle workspace is warned about, then stopped, then warned again, then removed. Every warning
|
||||
arrives as a `workspace` notification and states exactly what happens next and when.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="workspace-get",
|
||||
method="GET",
|
||||
path="/projects/{slug}/workspace",
|
||||
title="Read workspace",
|
||||
summary=(
|
||||
"State, quota usage, idle countdown, tunnels and open moderation flags "
|
||||
"for your workspace on this project."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={
|
||||
"has_workspace": True,
|
||||
"viewer_can_workspace": True,
|
||||
"workspace_count": 1,
|
||||
"max_workspaces": 2,
|
||||
"editor_url": "/projects/my-project/containers/instances/INSTANCE_UID/code/",
|
||||
"workspace": {
|
||||
"uid": "INSTANCE_UID",
|
||||
"status": "running",
|
||||
"suspended": False,
|
||||
"tunnel_name": "brave-otter",
|
||||
"primary_url": "https://brave-otter.tunnel.pravda.education",
|
||||
"disk_bytes": 5242880,
|
||||
"disk_quota_mb": 2048,
|
||||
"disk_percent": 1,
|
||||
"egress_bytes": 10240,
|
||||
"egress_quota_mb": 10240,
|
||||
"egress_percent": 0,
|
||||
"idle_stop_minutes": 60,
|
||||
"retention_days": 14,
|
||||
"max_tunnels": 5,
|
||||
"tunnels": [],
|
||||
"flags": [],
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-open",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace",
|
||||
title="Open or resume workspace",
|
||||
summary=(
|
||||
"Create the workspace if you have none for this project, otherwise resume "
|
||||
"it. Idempotent. Refused when you are at your workspace limit, over disk "
|
||||
"quota, or suspended."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-stop",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/stop",
|
||||
title="Stop workspace",
|
||||
summary="Stop the container. Files and tunnels are kept.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-delete",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/delete",
|
||||
title="Delete workspace",
|
||||
summary="Remove the workspace and its tunnels. An administrator can restore it.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnels-list",
|
||||
method="GET",
|
||||
path="/projects/{slug}/workspace/tunnels",
|
||||
title="List tunnels",
|
||||
summary="Every public tunnel published by this workspace.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
],
|
||||
sample_response={
|
||||
"tunnels": [
|
||||
{
|
||||
"uid": "TUNNEL_UID",
|
||||
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||
"label": "web",
|
||||
"container_port": 8080,
|
||||
"status": "active",
|
||||
"cert_status": "valid",
|
||||
"request_count": 12,
|
||||
"bytes_out": 40960,
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnel-create",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/tunnels",
|
||||
title="Create tunnel",
|
||||
summary=(
|
||||
"Publish a container port on a public HTTPS hostname. The URL is public "
|
||||
"and unauthenticated. Refused past the tunnel limit."
|
||||
),
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
field("container_port", "body", "integer", True, 8080, "Port inside the container."),
|
||||
field("label", "body", "string", False, "web", "Human label."),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"data": {
|
||||
"uid": "TUNNEL_UID",
|
||||
"hostname": "8080-brave-otter.tunnel.pravda.education",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="workspace-tunnel-delete",
|
||||
method="POST",
|
||||
path="/projects/{slug}/workspace/tunnels/{uid}/delete",
|
||||
title="Delete tunnel",
|
||||
summary="Remove a tunnel. The public URL stops serving immediately.",
|
||||
auth="user",
|
||||
params=[
|
||||
field("slug", "path", "string", True, "my-project", "Project slug or uid."),
|
||||
field("uid", "path", "string", True, "TUNNEL_UID", "Tunnel uid."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/projects/my-project/workspace"},
|
||||
),
|
||||
],
|
||||
}
|
||||
+41
-1
@@ -8,7 +8,7 @@ import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -112,9 +112,11 @@ from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.containers.workspace_service import WorkspaceService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.push import PushService
|
||||
from devplacepy.services.telegram import TelegramService
|
||||
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
|
||||
|
||||
@@ -273,8 +275,10 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(WorkspaceService())
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
service_manager.register(PushService())
|
||||
service_manager.register(TelegramService())
|
||||
service_manager.register(TelegramOutboxService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
@@ -613,6 +617,42 @@ async def response_timing(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
class TunnelDispatchMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
@staticmethod
|
||||
def _host(scope) -> str:
|
||||
for key, value in scope.get("headers") or []:
|
||||
if key == b"host":
|
||||
return value.decode("latin-1")
|
||||
return ""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
from devplacepy.routers import tunnel as tunnel_router
|
||||
from devplacepy.services.containers.workspace import naming
|
||||
|
||||
try:
|
||||
if not naming.is_tunnel_host(self._host(scope)):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
except Exception:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = (scope.get("path") or "/").lstrip("/")
|
||||
if scope["type"] == "websocket":
|
||||
websocket = WebSocket(scope, receive, send)
|
||||
await tunnel_router.handle_ws(websocket, path)
|
||||
return
|
||||
request = Request(scope, receive)
|
||||
response = await tunnel_router.handle_http(request, path)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
app.add_middleware(TunnelDispatchMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)
|
||||
|
||||
|
||||
|
||||
@@ -484,9 +484,19 @@ class SeoRunForm(BaseModel):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("A URL is required")
|
||||
if "://" in text:
|
||||
scheme = text.split("://", 1)[0]
|
||||
if scheme not in ("http", "https"):
|
||||
raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
|
||||
else:
|
||||
text = f"https://{text}"
|
||||
if not SEO_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be a valid http or https source location")
|
||||
return text
|
||||
|
||||
|
||||
SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")
|
||||
|
||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
||||
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||
@@ -875,3 +885,29 @@ class QuizImportForm(BaseModel):
|
||||
except ValueError as exc:
|
||||
raise ValueError("document must be valid JSON") from exc
|
||||
return value
|
||||
|
||||
|
||||
class TunnelForm(BaseModel):
|
||||
label: str = Field(default="", max_length=64)
|
||||
container_port: int = Field(default=0, ge=0, le=65535)
|
||||
|
||||
|
||||
class WorkspaceQuotaForm(BaseModel):
|
||||
owner_id: str = Field(default="", max_length=36)
|
||||
label: str = Field(default="", max_length=64)
|
||||
max_workspaces: int = Field(default=0, ge=0, le=100)
|
||||
max_tunnels: int = Field(default=0, ge=0, le=100)
|
||||
disk_quota_mb: int = Field(default=0, ge=0)
|
||||
egress_quota_mb: int = Field(default=0, ge=0)
|
||||
idle_stop_minutes: int = Field(default=0, ge=0)
|
||||
retention_days: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class WorkspaceFlagForm(BaseModel):
|
||||
kind: str = Field(default="manual", max_length=40)
|
||||
severity: str = Field(default="warn", max_length=16)
|
||||
detail: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class WorkspaceSuspendForm(BaseModel):
|
||||
reason: str = Field(default="", max_length=500)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
This file documents `devplacepy/push/` - push notification delivery and its provider architecture. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## What this package is
|
||||
|
||||
One delivery library behind one public surface. `devplacepy.push` exports `notify_user`, `register`, `ensure_certificates`, `public_key_standard_b64` and the Web Push crypto helpers; every caller in the codebase (`main.py`, `utils/notifications.py`, `routers/push.py`) imports only those names. Everything else is internal to the package.
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `providers/base.py` | `PushProvider` protocol, the `Delivery` outcome and the three outcome constants |
|
||||
| `providers/webpush.py` | VAPID key material, `aesgcm` payload encryption, the Web Push provider |
|
||||
| `providers/apns.py` | Apple Push Notification service provider (token based, HTTP/2) |
|
||||
| `providers/__init__.py` | `PROVIDERS` registry, `get`, `active`, `is_active`, `admin_fields`, `client_config` |
|
||||
| `store.py` | Every `push_registration` read and write |
|
||||
| `delivery.py` | `notify_user`: group by provider, one shared client, one prepared body per provider |
|
||||
|
||||
The admin configuration surface lives in `devplacepy/services/push/service.py` (`PushService`), not here.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Write `providers/<name>.py` with a `PushProvider` subclass: `name`, `label`, `config_fields`, `is_configured`, `parse_registration`, `prepare`, `deliver`, optionally `client_config`.
|
||||
2. Add one entry to `PROVIDERS` in `providers/__init__.py`.
|
||||
|
||||
That is the whole change. The registration route, the delivery loop, the admin page, the audit record, the metrics and the docs are written against the protocol and need no edit. The `Enabled` toggle (`push_<name>_enabled`) comes from the base class, so a provider never declares its own.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero cost for the request.** Delivery is reached only through `utils/notifications.py` `_schedule_push`, a fire-and-forget task. Never make a route await `notify_user`, and never add a queue or a table to this path.
|
||||
- **`deliver` never raises.** Return `Delivery(REJECTED, detail)` instead. `delivery.py` guards anyway, but a raising provider costs a log line per subscription.
|
||||
- **A provider that is not configured is inert, never an error.** `is_configured()` is false, `is_active()` is false, the delivery loop skips it, and `POST /push.json` refuses a registration for it with 400. Nothing else in the platform notices.
|
||||
- **`DEAD` is the only outcome that touches the database.** It soft-deletes the registration (`deleted_at`), exactly like a `404`/`410` Web Push endpoint always did. `REJECTED` keeps the row.
|
||||
- **Every insert writes `deleted_at: None`,** and every read filters `deleted_at IS NULL`. `push_registration` deliberately stays out of `SOFT_DELETE_TABLES` (no `deleted_by`, not restorable from Trash) - a dead device token has no owner action to undo.
|
||||
- **A row without a provider is a Web Push row.** `store.provider_of` resolves `None`/`""` to `DEFAULT_PROVIDER`, so a row written by an old worker during a deploy still delivers. `init_db` backfills the column once with a single converging `UPDATE`.
|
||||
|
||||
## Storage
|
||||
|
||||
`push_registration` columns are ensured in `init_db` (`database/schema.py`) because `dataset` only creates the columns of a table's first insert, and `find(provider=...)` against a missing column matches nothing.
|
||||
|
||||
| Column | webpush | apns |
|
||||
|---|---|---|
|
||||
| `provider` | `webpush` | `apns` |
|
||||
| `endpoint`, `key_auth`, `key_p256dh` | set | `NULL` |
|
||||
| `token` | `NULL` | device token |
|
||||
|
||||
Deduplication is generic: `store.register` looks up `user_uid` + `provider` + exactly the fields the provider's `parse_registration` returned, so a provider never writes its own identity rule.
|
||||
|
||||
## APNs specifics
|
||||
|
||||
- `POST https://{host}/3/device/{token}` over HTTP/2, host from `push_apns_environment` (`api.push.apple.com` or `api.sandbox.push.apple.com`; an unrecognised value falls back to production). HTTP/2 comes from `stealth_async_client` because the origin is `https` - the cleartext downgrade in `curl_transport` does not apply.
|
||||
- Provider token: `ES256`, header `kid` = key id, claims `iss` = team id and `iat`. Cached per credential fingerprint for 45 minutes, so a worker signs at most one token per 45 minutes; Apple refuses tokens regenerated faster than every 20 minutes. Editing any credential changes the fingerprint and takes effect on the next delivery, with no restart.
|
||||
- A `.p8` that does not parse is cached as a failure for the same window, so a misconfiguration costs one error log per window rather than one parse per notification.
|
||||
- `410`, or any status carrying reason `BadDeviceToken`, `Unregistered`, `ExpiredToken`, `DeviceTokenNotForTopic` or `TopicDisallowed`, is `DEAD`. Everything else is `REJECTED`.
|
||||
- The shared payload dict (`title`, `message`, `icon`, `url`) is translated once per batch into `aps.alert` plus the custom `url`/`icon` keys, mirroring what `service-worker.js` does for Web Push. `thread-id` mirrors the service worker's notification `tag`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.push.delivery import notify_user
|
||||
from devplacepy.push.providers.webpush import (
|
||||
browser_base64,
|
||||
create_notification_authorization,
|
||||
create_notification_info_with_payload,
|
||||
ensure_certificates,
|
||||
generate_pkcs8_private_key,
|
||||
generate_private_key,
|
||||
generate_public_key,
|
||||
hkdf,
|
||||
public_key_standard_b64,
|
||||
)
|
||||
from devplacepy.push.store import register
|
||||
|
||||
__all__ = [
|
||||
"browser_base64",
|
||||
"create_notification_authorization",
|
||||
"create_notification_info_with_payload",
|
||||
"ensure_certificates",
|
||||
"generate_pkcs8_private_key",
|
||||
"generate_private_key",
|
||||
"generate_public_key",
|
||||
"hkdf",
|
||||
"notify_user",
|
||||
"public_key_standard_b64",
|
||||
"register",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.push import providers, store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_KEY = "push_delivery_timeout_seconds"
|
||||
DEFAULT_TIMEOUT_SECONDS = 10
|
||||
MIN_TIMEOUT_SECONDS = 1
|
||||
MAX_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def timeout_seconds() -> float:
|
||||
seconds = get_int_setting(TIMEOUT_KEY, DEFAULT_TIMEOUT_SECONDS)
|
||||
return float(min(max(seconds, MIN_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS))
|
||||
|
||||
|
||||
def group_by_provider(
|
||||
registrations: list[dict[str, Any]],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for registration in registrations:
|
||||
grouped.setdefault(store.provider_of(registration), []).append(registration)
|
||||
return grouped
|
||||
|
||||
|
||||
async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
registrations = store.active_for_user(user_uid)
|
||||
if not registrations:
|
||||
logger.debug("No active push subscriptions for user %s", user_uid)
|
||||
return
|
||||
|
||||
grouped = group_by_provider(registrations)
|
||||
async with stealth.stealth_async_client(timeout=timeout_seconds()) as client:
|
||||
for name, rows in grouped.items():
|
||||
provider = providers.PROVIDERS.get(name)
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"Unknown push provider %s on %s subscriptions of user %s",
|
||||
name,
|
||||
len(rows),
|
||||
user_uid,
|
||||
)
|
||||
continue
|
||||
if not providers.is_active(provider):
|
||||
logger.debug(
|
||||
"Push provider %s is not active; skipping %s subscriptions",
|
||||
name,
|
||||
len(rows),
|
||||
)
|
||||
continue
|
||||
try:
|
||||
prepared = provider.prepare(payload)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s could not build a payload: %s", name, exc)
|
||||
continue
|
||||
for registration in rows:
|
||||
await _deliver_one(provider, client, registration, prepared, user_uid)
|
||||
|
||||
|
||||
async def _deliver_one(provider, client, registration, prepared, user_uid) -> None:
|
||||
try:
|
||||
outcome = await provider.deliver(client, registration, prepared)
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s raised for %s: %s", provider.name, user_uid, exc)
|
||||
return
|
||||
if outcome.status == providers.ACCEPTED:
|
||||
logger.debug("Push delivered to %s via %s", user_uid, provider.name)
|
||||
return
|
||||
if outcome.status == providers.DEAD:
|
||||
try:
|
||||
store.mark_dead(registration["id"])
|
||||
except Exception as exc:
|
||||
logger.error("Could not soft-delete push subscription: %s", exc)
|
||||
return
|
||||
logger.warning(
|
||||
"Push rejected by %s for %s: %s", provider.name, user_uid, outcome.detail
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.push.providers.apns import ApnsProvider
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.push.providers.webpush import WebPushProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROVIDER = WebPushProvider.name
|
||||
|
||||
PROVIDERS: dict[str, PushProvider] = {
|
||||
provider.name: provider for provider in (WebPushProvider(), ApnsProvider())
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTED",
|
||||
"DEAD",
|
||||
"DEFAULT_PROVIDER",
|
||||
"Delivery",
|
||||
"PROVIDERS",
|
||||
"PushProvider",
|
||||
"REJECTED",
|
||||
"active",
|
||||
"admin_fields",
|
||||
"client_config",
|
||||
"get",
|
||||
"is_active",
|
||||
"names",
|
||||
]
|
||||
|
||||
|
||||
def get(name: str | None) -> PushProvider | None:
|
||||
if not isinstance(name, str):
|
||||
name = ""
|
||||
return PROVIDERS.get(name.strip().lower() or DEFAULT_PROVIDER)
|
||||
|
||||
|
||||
def names() -> list[str]:
|
||||
return list(PROVIDERS)
|
||||
|
||||
|
||||
def active() -> list[PushProvider]:
|
||||
return [provider for provider in PROVIDERS.values() if is_active(provider)]
|
||||
|
||||
|
||||
def admin_fields() -> list:
|
||||
return [field for provider in PROVIDERS.values() for field in provider.all_fields()]
|
||||
|
||||
|
||||
def client_config() -> dict[str, Any]:
|
||||
return {provider.name: _client_config(provider) for provider in active()}
|
||||
|
||||
|
||||
def is_active(provider: PushProvider) -> bool:
|
||||
try:
|
||||
return provider.is_active()
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed its readiness check: %s", provider.name, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _client_config(provider: PushProvider) -> dict[str, Any]:
|
||||
try:
|
||||
return provider.client_config()
|
||||
except Exception as exc:
|
||||
logger.error("Push provider %s failed to describe itself: %s", provider.name, exc)
|
||||
return {}
|
||||
@@ -0,0 +1,243 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEAM_ID_KEY = "push_apns_team_id"
|
||||
KEY_ID_KEY = "push_apns_key_id"
|
||||
AUTH_KEY_KEY = "push_apns_auth_key"
|
||||
TOPIC_KEY = "push_apns_topic"
|
||||
ENVIRONMENT_KEY = "push_apns_environment"
|
||||
|
||||
PROVIDER_LABEL = "Apple Push (APNs)"
|
||||
DEFAULT_ENVIRONMENT = "production"
|
||||
HOSTS = {
|
||||
"production": "api.push.apple.com",
|
||||
"sandbox": "api.sandbox.push.apple.com",
|
||||
}
|
||||
ENVIRONMENT_OPTIONS = [
|
||||
{"value": "production", "label": "Production"},
|
||||
{"value": "sandbox", "label": "Sandbox"},
|
||||
]
|
||||
|
||||
TOKEN_REFRESH_SECONDS = 45 * 60
|
||||
TOKEN_MIN_LENGTH = 64
|
||||
TOKEN_MAX_LENGTH = 200
|
||||
THREAD_ID = "devplace-notification"
|
||||
PUSH_TYPE = "alert"
|
||||
PRIORITY = "10"
|
||||
DEAD_REASONS = frozenset(
|
||||
{
|
||||
"BadDeviceToken",
|
||||
"DeviceTokenNotForTopic",
|
||||
"ExpiredToken",
|
||||
"Unregistered",
|
||||
"TopicDisallowed",
|
||||
}
|
||||
)
|
||||
|
||||
_token_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _setting(key: str) -> str:
|
||||
return get_setting(key, "").strip()
|
||||
|
||||
|
||||
def _environment() -> str:
|
||||
value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
|
||||
return value if value in HOSTS else DEFAULT_ENVIRONMENT
|
||||
|
||||
|
||||
def host() -> str:
|
||||
return HOSTS[_environment()]
|
||||
|
||||
|
||||
def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
|
||||
fingerprint = _fingerprint(team_id, key_id, auth_key)
|
||||
issued_at = int(time.time())
|
||||
state = _token_state.get("current")
|
||||
if (
|
||||
state
|
||||
and state["fingerprint"] == fingerprint
|
||||
and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
|
||||
):
|
||||
if state["token"] is None:
|
||||
raise ValueError(state["error"])
|
||||
return state["token"]
|
||||
try:
|
||||
token = jwt.encode(
|
||||
{"iss": team_id, "iat": issued_at},
|
||||
auth_key,
|
||||
algorithm="ES256",
|
||||
headers={"kid": key_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
message = f"APNs auth key is not usable: {exc}"
|
||||
_token_state["current"] = {
|
||||
"token": None,
|
||||
"error": message,
|
||||
"issued_at": issued_at,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
logger.error(message)
|
||||
raise ValueError(message) from exc
|
||||
_token_state["current"] = {
|
||||
"token": token,
|
||||
"error": "",
|
||||
"issued_at": issued_at,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
return token
|
||||
|
||||
|
||||
def _reason(response: httpx.Response) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return ""
|
||||
if isinstance(body, dict):
|
||||
return str(body.get("reason", ""))
|
||||
return ""
|
||||
|
||||
|
||||
class ApnsProvider(PushProvider):
|
||||
name = "apns"
|
||||
label = PROVIDER_LABEL
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
TEAM_ID_KEY,
|
||||
"Team ID",
|
||||
type="str",
|
||||
default="",
|
||||
help="Ten character Apple Developer team identifier, used as the token iss claim.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
KEY_ID_KEY,
|
||||
"Key ID",
|
||||
type="str",
|
||||
default="",
|
||||
help="Ten character identifier of the APNs auth key, sent as the token kid header.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
AUTH_KEY_KEY,
|
||||
"Auth key (.p8)",
|
||||
type="text",
|
||||
default="",
|
||||
secret=True,
|
||||
help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
TOPIC_KEY,
|
||||
"Topic",
|
||||
type="str",
|
||||
default="",
|
||||
help="Bundle identifier of the receiving app, sent as the apns-topic header.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
ConfigField(
|
||||
ENVIRONMENT_KEY,
|
||||
"Environment",
|
||||
type="select",
|
||||
default=DEFAULT_ENVIRONMENT,
|
||||
options=ENVIRONMENT_OPTIONS,
|
||||
help="Production delivers to App Store builds, sandbox to development builds.",
|
||||
group=PROVIDER_LABEL,
|
||||
),
|
||||
]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
_setting(TEAM_ID_KEY)
|
||||
and _setting(KEY_ID_KEY)
|
||||
and _setting(AUTH_KEY_KEY)
|
||||
and _setting(TOPIC_KEY)
|
||||
)
|
||||
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
token = body.get("token")
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
token = token.strip()
|
||||
if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
|
||||
return None
|
||||
if any(character not in string.hexdigits for character in token):
|
||||
return None
|
||||
return {"token": token}
|
||||
|
||||
def prepare(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title": payload.get("title") or "DevPlace",
|
||||
"body": payload.get("message") or "",
|
||||
},
|
||||
"sound": "default",
|
||||
"thread-id": THREAD_ID,
|
||||
},
|
||||
"url": payload.get("url") or DEFAULT_PUSH_URL,
|
||||
"icon": payload.get("icon") or PUSH_ICON,
|
||||
}
|
||||
)
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
|
||||
"apns-topic": _setting(TOPIC_KEY),
|
||||
"apns-push-type": PUSH_TYPE,
|
||||
"apns-priority": PRIORITY,
|
||||
"apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
|
||||
"apns-id": generate_uid(),
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery:
|
||||
token = (registration.get("token") or "").strip()
|
||||
if not token:
|
||||
return Delivery(DEAD, "missing device token")
|
||||
try:
|
||||
headers = self.headers()
|
||||
response = await client.post(
|
||||
f"https://{host()}/3/device/{token}",
|
||||
headers=headers,
|
||||
content=prepared.encode("utf-8"),
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return Delivery(REJECTED, str(exc))
|
||||
if response.status_code == 200:
|
||||
return Delivery(ACCEPTED)
|
||||
reason = _reason(response)
|
||||
detail = f"{response.status_code} {reason}".strip()
|
||||
if response.status_code == 410 or reason in DEAD_REASONS:
|
||||
return Delivery(DEAD, detail)
|
||||
return Delivery(REJECTED, detail)
|
||||
@@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.base import ConfigField
|
||||
|
||||
ACCEPTED = "accepted"
|
||||
DEAD = "dead"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Delivery:
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class PushProvider(ABC):
|
||||
name = ""
|
||||
label = ""
|
||||
config_fields: list[ConfigField] = []
|
||||
|
||||
@property
|
||||
def enabled_key(self) -> str:
|
||||
return f"push_{self.name}_enabled"
|
||||
|
||||
def enabled_field(self) -> ConfigField:
|
||||
return ConfigField(
|
||||
self.enabled_key,
|
||||
"Enabled",
|
||||
type="bool",
|
||||
default=True,
|
||||
help=f"Deliver notifications through {self.label}.",
|
||||
group=self.label,
|
||||
)
|
||||
|
||||
def all_fields(self) -> list[ConfigField]:
|
||||
return [self.enabled_field(), *self.config_fields]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return get_setting(self.enabled_key, "1") == "1"
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self.is_enabled() and self.is_configured()
|
||||
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
def is_configured(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def prepare(self, payload: dict[str, Any]) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery: ...
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -20,7 +19,6 @@ 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 import stealth
|
||||
from devplacepy.config import (
|
||||
SECONDS_PER_DAY,
|
||||
VAPID_PRIVATE_KEY_FILE,
|
||||
@@ -28,7 +26,15 @@ from devplacepy.config import (
|
||||
VAPID_PUBLIC_KEY_FILE,
|
||||
VAPID_SUB,
|
||||
)
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.push.providers.base import (
|
||||
ACCEPTED,
|
||||
DEAD,
|
||||
REJECTED,
|
||||
Delivery,
|
||||
PushProvider,
|
||||
)
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,6 +43,8 @@ JWT_LIFETIME_SECONDS = 60 * 60
|
||||
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
|
||||
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
|
||||
ACCEPTED_STATUSES = (200, 201)
|
||||
SUBJECT_KEY = "push_webpush_subject"
|
||||
PROVIDER_LABEL = "Web Push (VAPID)"
|
||||
|
||||
|
||||
def generate_private_key() -> None:
|
||||
@@ -149,13 +157,17 @@ def public_key_standard_b64() -> str:
|
||||
return base64.b64encode(point).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def subject() -> str:
|
||||
return get_setting(SUBJECT_KEY, "").strip() or VAPID_SUB
|
||||
|
||||
|
||||
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,
|
||||
"sub": subject(),
|
||||
"aud": audience,
|
||||
"exp": issued_at + JWT_LIFETIME_SECONDS,
|
||||
"nbf": issued_at,
|
||||
@@ -223,78 +235,76 @@ def create_notification_info_with_payload(
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
class WebPushProvider(PushProvider):
|
||||
name = "webpush"
|
||||
label = PROVIDER_LABEL
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
SUBJECT_KEY,
|
||||
"VAPID subject",
|
||||
type="str",
|
||||
default=VAPID_SUB,
|
||||
help="Contact sent as the JWT sub claim, a mailto: or https: URL. Blank uses the built-in default.",
|
||||
group=PROVIDER_LABEL,
|
||||
)
|
||||
]
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return True
|
||||
|
||||
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
|
||||
def client_config(self) -> dict[str, Any]:
|
||||
try:
|
||||
return {"publicKey": public_key_standard_b64()}
|
||||
except Exception as exc:
|
||||
logger.error("VAPID key material unavailable: %s", exc)
|
||||
return {}
|
||||
|
||||
body = json.dumps(payload)
|
||||
async with stealth.stealth_async_client(timeout=10.0) as client:
|
||||
for subscription in registrations:
|
||||
endpoint = subscription["endpoint"]
|
||||
try:
|
||||
notification_payload = create_notification_info_with_payload(
|
||||
endpoint,
|
||||
subscription["key_auth"],
|
||||
subscription["key_p256dh"],
|
||||
body,
|
||||
)
|
||||
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
response = await client.post(
|
||||
endpoint, headers=headers, content=notification_payload["data"]
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
|
||||
continue
|
||||
def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
keys = body.get("keys")
|
||||
if not isinstance(keys, dict):
|
||||
return None
|
||||
endpoint = body.get("endpoint")
|
||||
key_auth = keys.get("auth")
|
||||
key_p256dh = keys.get("p256dh")
|
||||
if not (
|
||||
isinstance(endpoint, str)
|
||||
and isinstance(key_auth, str)
|
||||
and isinstance(key_p256dh, str)
|
||||
and endpoint
|
||||
and key_auth
|
||||
and key_p256dh
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"endpoint": endpoint,
|
||||
"key_auth": key_auth,
|
||||
"key_p256dh": key_p256dh,
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
def prepare(self, payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
async def register(
|
||||
user_uid: str, endpoint: str, key_auth: str, key_p256dh: str
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
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, False
|
||||
|
||||
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, True
|
||||
async def deliver(
|
||||
self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
|
||||
) -> Delivery:
|
||||
endpoint = registration.get("endpoint") or ""
|
||||
if not endpoint:
|
||||
return Delivery(DEAD, "missing endpoint")
|
||||
try:
|
||||
notification_payload = create_notification_info_with_payload(
|
||||
endpoint,
|
||||
registration["key_auth"],
|
||||
registration["key_p256dh"],
|
||||
prepared,
|
||||
)
|
||||
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
response = await client.post(
|
||||
endpoint, headers=headers, content=notification_payload["data"]
|
||||
)
|
||||
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
|
||||
return Delivery(REJECTED, str(exc))
|
||||
if response.status_code in ACCEPTED_STATUSES:
|
||||
return Delivery(ACCEPTED)
|
||||
if response.status_code in DEAD_SUBSCRIPTION_STATUSES:
|
||||
return Delivery(DEAD, str(response.status_code))
|
||||
return Delivery(REJECTED, str(response.status_code))
|
||||
@@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.push.providers import DEFAULT_PROVIDER
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TABLE = "push_registration"
|
||||
|
||||
|
||||
def table():
|
||||
return get_table(TABLE)
|
||||
|
||||
|
||||
def provider_of(registration: dict[str, Any]) -> str:
|
||||
return registration.get("provider") or DEFAULT_PROVIDER
|
||||
|
||||
|
||||
def active_for_user(user_uid: str) -> list[dict[str, Any]]:
|
||||
return list(table().find(user_uid=user_uid, deleted_at=None))
|
||||
|
||||
|
||||
def register(
|
||||
user_uid: str, provider: str, fields: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
registrations = table()
|
||||
existing = registrations.find_one(
|
||||
user_uid=user_uid, provider=provider, deleted_at=None, **fields
|
||||
)
|
||||
if existing:
|
||||
logger.debug("Push subscription already registered for user %s", user_uid)
|
||||
return existing, False
|
||||
|
||||
record = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"provider": provider,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
**fields,
|
||||
}
|
||||
registrations.insert(record)
|
||||
logger.info("Registered %s push subscription for user %s", provider, user_uid)
|
||||
return record, True
|
||||
|
||||
|
||||
def mark_dead(registration_id: int) -> None:
|
||||
table().update(
|
||||
{"id": registration_id, "deleted_at": datetime.now(timezone.utc).isoformat()},
|
||||
["id"],
|
||||
)
|
||||
logger.info("Soft-deleted dead push subscription id=%s", registration_id)
|
||||
|
||||
|
||||
def prune(cutoff: str) -> int:
|
||||
if TABLE not in db.tables:
|
||||
return 0
|
||||
rows = list(table().find(deleted_at={"<": cutoff}))
|
||||
if not rows:
|
||||
return 0
|
||||
table().delete(deleted_at={"<": cutoff})
|
||||
return len(rows)
|
||||
|
||||
|
||||
def counts() -> dict[str, int]:
|
||||
if TABLE not in db.tables:
|
||||
return {}
|
||||
totals: dict[str, int] = {"dead": 0}
|
||||
for row in db.query(
|
||||
f"SELECT provider AS provider, deleted_at IS NULL AS live, COUNT(*) AS total "
|
||||
f"FROM {TABLE} GROUP BY provider, deleted_at IS NULL"
|
||||
):
|
||||
provider = row["provider"] or DEFAULT_PROVIDER
|
||||
if row["live"]:
|
||||
totals[provider] = totals.get(provider, 0) + int(row["total"])
|
||||
else:
|
||||
totals["dead"] += int(row["total"])
|
||||
return totals
|
||||
@@ -45,7 +45,7 @@ Prefixes are wired in `main.py`:
|
||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
|
||||
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
|
||||
@@ -8,6 +8,7 @@ from devplacepy.routers.admin import (
|
||||
backups,
|
||||
bots,
|
||||
containers,
|
||||
workspaces,
|
||||
devii_tasks,
|
||||
game,
|
||||
gateway_configs,
|
||||
@@ -42,3 +43,4 @@ router.include_router(devii_tasks.router)
|
||||
router.include_router(game.router)
|
||||
router.include_router(services.router, prefix="/services")
|
||||
router.include_router(containers.router, prefix="/containers")
|
||||
router.include_router(workspaces.router)
|
||||
|
||||
@@ -6,6 +6,7 @@ from devplacepy.utils import require_admin
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway import quota
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -34,14 +35,20 @@ async def admin_reset_all_ai_quota(request: Request):
|
||||
admin = require_admin(request)
|
||||
devii = service_manager.get_service("devii")
|
||||
removed = devii.reset_all_quotas() if devii is not None else 0
|
||||
gateway = quota.reset(created_by=admin["uid"])
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
|
||||
f"Admin {admin['username']} reset ALL AI quotas "
|
||||
f"({removed} Devii ledger rows, gateway watermark {gateway['reset_at']})"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.ai_quota.reset_all",
|
||||
user=admin,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset all AI quotas",
|
||||
metadata={"rows_removed": removed, "gateway_reset_at": gateway["reset_at"]},
|
||||
summary=(
|
||||
f"admin {admin['username']} reset all AI quotas "
|
||||
"(Devii assistant and AI gateway)"
|
||||
),
|
||||
)
|
||||
return action_result(request, "/admin/ai-usage")
|
||||
|
||||
|
||||
@@ -197,14 +197,7 @@ def _quota_defaults_summary() -> dict:
|
||||
|
||||
|
||||
def _rule_label(rule: dict) -> str:
|
||||
parts = []
|
||||
if rule.get("owner_kind"):
|
||||
parts.append(f"role={rule['owner_kind']}")
|
||||
if rule.get("owner_id"):
|
||||
parts.append(f"user={rule['owner_id']}")
|
||||
if rule.get("app_reference"):
|
||||
parts.append(f"app={rule['app_reference']}")
|
||||
return ", ".join(parts) or rule.get("uid", "")
|
||||
return quota.scope_label(rule, fallback=rule.get("uid", ""))
|
||||
|
||||
|
||||
@router.get("/gateway/quota-rules")
|
||||
@@ -253,6 +246,34 @@ async def save_quota_rule(request: Request):
|
||||
return JSONResponse({"ok": True, "rule": saved})
|
||||
|
||||
|
||||
@router.post("/gateway/quota-resets")
|
||||
async def reset_quota_spend(request: Request):
|
||||
admin = require_admin(request)
|
||||
body = await _payload(request)
|
||||
try:
|
||||
payload = quota.QuotaResetIn(**body)
|
||||
except ValidationError as exc:
|
||||
return _validation_error(exc)
|
||||
scope = quota.reset(payload, created_by=admin["uid"])
|
||||
label = quota.scope_label(scope, fallback="every caller")
|
||||
audit.record(
|
||||
request,
|
||||
"gateway.quota.reset",
|
||||
user=admin,
|
||||
target_type="gateway_quota",
|
||||
target_uid=scope["uid"],
|
||||
target_label=label,
|
||||
summary=f"admin {admin['username']} reset the gateway 24h spend for {label}",
|
||||
metadata={
|
||||
"owner_kind": scope["owner_kind"],
|
||||
"owner_id": scope["owner_id"],
|
||||
"app_reference": scope["app_reference"],
|
||||
"reset_at": scope["reset_at"],
|
||||
},
|
||||
)
|
||||
return JSONResponse({"ok": True, "reset": scope})
|
||||
|
||||
|
||||
@router.delete("/gateway/quota-rules/{uid}")
|
||||
async def delete_quota_rule(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
|
||||
@@ -10,6 +10,7 @@ from devplacepy.database import (
|
||||
build_pagination,
|
||||
get_post_counts_by_user_uids,
|
||||
invalidate_admins_cache,
|
||||
is_account_active,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
require_admin,
|
||||
@@ -196,7 +197,7 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
if _is_senior_admin(admin, user):
|
||||
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
||||
if user:
|
||||
new_state = not user.get("is_active", True)
|
||||
new_state = not is_account_active(user)
|
||||
users.update({"uid": uid, "is_active": new_state}, ["uid"])
|
||||
clear_user_cache(uid)
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import db, get_table, get_users_by_uids
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import (
|
||||
WorkspaceFlagForm,
|
||||
WorkspaceQuotaForm,
|
||||
WorkspaceSuspendForm,
|
||||
)
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import AdminWorkspacesOut
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import flags, provision, quota, tunnels
|
||||
from devplacepy.utils import create_notification, generate_uid, not_found, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _decorate(rows: list[dict]) -> list[dict]:
|
||||
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
|
||||
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
|
||||
projects = {}
|
||||
if "projects" in db.tables:
|
||||
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
|
||||
for uid in project_uids:
|
||||
found = get_table("projects").find_one(uid=uid)
|
||||
if found:
|
||||
projects[uid] = found
|
||||
decorated = []
|
||||
for row in rows:
|
||||
view = provision.view(row)
|
||||
owner = owners.get(row.get("workspace_owner_uid", "")) or {}
|
||||
project = projects.get(row.get("project_uid", "")) or {}
|
||||
view["owner_username"] = owner.get("username", "")
|
||||
view["project_title"] = project.get("title", "")
|
||||
view["project_slug"] = project.get("slug", "") or project.get("uid", "")
|
||||
decorated.append(view)
|
||||
return decorated
|
||||
|
||||
|
||||
def _all_workspaces() -> list[dict]:
|
||||
return list(get_table("instances").find(is_workspace=1, deleted_at=None))
|
||||
|
||||
|
||||
def _instance_or_404(uid: str) -> dict:
|
||||
instance = store.get_instance(uid)
|
||||
if not instance or not instance.get("is_workspace"):
|
||||
raise not_found("Workspace not found")
|
||||
return instance
|
||||
|
||||
|
||||
def _audit(request: Request, admin: dict, event_key: str, instance: dict, **extra):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
target_type="instance",
|
||||
target_uid=instance["uid"],
|
||||
target_label=instance.get("name"),
|
||||
summary=f"{admin['username']} {event_key} workspace {instance.get('name')}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces")
|
||||
async def admin_workspaces(request: Request):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
context = {
|
||||
"workspaces": _decorate(_all_workspaces()),
|
||||
"flags": flags.list_flags(),
|
||||
"admin_section": "workspaces",
|
||||
"user": admin,
|
||||
**base_seo_context(
|
||||
request,
|
||||
title="Workspaces - Admin",
|
||||
description="Administer dev workspaces.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Workspaces", "url": "/admin/workspaces"},
|
||||
],
|
||||
),
|
||||
}
|
||||
return respond(request, "admin_workspaces.html", context, model=AdminWorkspacesOut)
|
||||
|
||||
|
||||
@router.get("/workspaces/data")
|
||||
async def admin_workspaces_data(request: Request):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
return JSONResponse(
|
||||
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/suspend")
|
||||
async def admin_workspace_suspend(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[WorkspaceSuspendForm, Depends(json_or_form(WorkspaceSuspendForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
reason = (data.reason or "").strip()
|
||||
if not reason:
|
||||
return json_error("a reason is required and is shown to the owner", 400)
|
||||
provision.suspend(instance, admin["uid"], reason)
|
||||
_audit(request, admin, "container.workspace.suspend", instance, metadata={"reason": reason})
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} was suspended: {reason}",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/unsuspend")
|
||||
async def admin_workspace_unsuspend(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
provision.unsuspend(instance)
|
||||
_audit(request, admin, "container.workspace.unsuspend", instance)
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} is available again.",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/stop")
|
||||
async def admin_workspace_stop(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
provision.stop(instance)
|
||||
_audit(request, admin, "container.workspace.stop", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/start")
|
||||
async def admin_workspace_start(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
store.update_instance(instance["uid"], {"desired_state": "running"})
|
||||
_audit(request, admin, "container.workspace.resume", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/delete")
|
||||
async def admin_workspace_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
tunnels.soft_delete(row["uid"], admin["uid"])
|
||||
store.delete_instance(instance["uid"], admin["uid"])
|
||||
_audit(request, admin, "container.workspace.delete", instance)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/{uid}/flag")
|
||||
async def admin_workspace_flag(
|
||||
request: Request,
|
||||
uid: str,
|
||||
data: Annotated[WorkspaceFlagForm, Depends(json_or_form(WorkspaceFlagForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
instance = _instance_or_404(uid)
|
||||
row = flags.raise_flag(
|
||||
instance, data.kind or flags.KIND_MANUAL, data.severity, data.detail
|
||||
)
|
||||
_audit(request, admin, "container.workspace.flag.raise", instance,
|
||||
metadata={"kind": data.kind, "severity": data.severity})
|
||||
owner = instance.get("workspace_owner_uid", "")
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {instance.get('name', '')} was flagged: {data.detail or data.kind}",
|
||||
instance["uid"],
|
||||
"/projects",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces", data=row)
|
||||
|
||||
|
||||
@router.post("/workspaces/flags/{flag_uid}/resolve")
|
||||
async def admin_flag_resolve(request: Request, flag_uid: str, status: str = "resolved"):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
if not flags.set_status(flag_uid, status, admin["uid"]):
|
||||
raise not_found("Flag not found")
|
||||
event = (
|
||||
"container.workspace.flag.dismiss"
|
||||
if status == "dismissed"
|
||||
else "container.workspace.flag.resolve"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
event,
|
||||
user=admin,
|
||||
target_type="workspace_flag",
|
||||
target_uid=flag_uid,
|
||||
summary=f"{admin['username']} set flag {flag_uid} to {status}",
|
||||
)
|
||||
return action_result(request, "/admin/workspaces")
|
||||
|
||||
|
||||
@router.post("/workspaces/quota")
|
||||
async def admin_workspace_quota(
|
||||
request: Request,
|
||||
data: Annotated[WorkspaceQuotaForm, Depends(json_or_form(WorkspaceQuotaForm))],
|
||||
):
|
||||
admin = require_admin(request)
|
||||
if not isinstance(admin, dict):
|
||||
return admin
|
||||
if not data.owner_id:
|
||||
return json_error("owner_id is required", 400)
|
||||
table = get_table(quota.RULES_TABLE)
|
||||
existing = table.find_one(
|
||||
owner_kind="user", owner_id=data.owner_id, deleted_at=None
|
||||
)
|
||||
payload = {key: getattr(data, key) for key in quota.RULE_COLUMNS}
|
||||
if existing:
|
||||
table.update({"uid": existing["uid"], "label": data.label, **payload}, ["uid"])
|
||||
uid = existing["uid"]
|
||||
else:
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"owner_kind": "user",
|
||||
"owner_id": data.owner_id,
|
||||
"label": data.label,
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"container.workspace.settings.update",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=data.owner_id,
|
||||
summary=f"{admin['username']} updated workspace quota",
|
||||
metadata=payload,
|
||||
)
|
||||
return action_result(request, "/admin/workspaces", data={"uid": uid, **payload})
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.database import get_table, get_int_setting, is_account_active
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import (
|
||||
verify_password_async,
|
||||
@@ -59,7 +59,7 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
|
||||
|
||||
if not user or not await verify_password_async(password, user["password_hash"]):
|
||||
errors.append("Invalid email or password")
|
||||
elif not user.get("is_active", True):
|
||||
elif not is_account_active(user):
|
||||
errors.append("Account is deactivated")
|
||||
|
||||
if errors:
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_table, is_account_active
|
||||
from devplacepy.utils import verify_password_async, get_current_user
|
||||
from devplacepy.models import LoginForm
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@@ -59,7 +59,7 @@ async def token(
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
if not user.get("is_active", True):
|
||||
if not is_account_active(user):
|
||||
audit.record(
|
||||
request,
|
||||
"auth.token.failure",
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.responses import Response
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_table, get_setting
|
||||
from devplacepy.database import get_table, get_setting, is_account_active
|
||||
from devplacepy.utils import verify_password_async, register_account_async
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
@@ -42,7 +42,7 @@ async def auth_token(request: Request):
|
||||
)
|
||||
if (
|
||||
not user
|
||||
or not user.get("is_active", True)
|
||||
or not is_account_active(user)
|
||||
or not await verify_password_async(password, user["password_hash"])
|
||||
):
|
||||
audit.record(
|
||||
|
||||
@@ -87,6 +87,7 @@ async def create_rant(request: Request):
|
||||
if len(text) > 125000:
|
||||
return dr_error("Your rant is too long.")
|
||||
tags = _parse_tags(params.get("tags"))
|
||||
project_uid = params.get("project_uid") or None
|
||||
uid, slug = create_content_item(
|
||||
"posts",
|
||||
"post",
|
||||
@@ -95,7 +96,7 @@ async def create_rant(request: Request):
|
||||
"title": None,
|
||||
"content": text,
|
||||
"topic": "rant",
|
||||
"project_uid": None,
|
||||
"project_uid": project_uid,
|
||||
"image": None,
|
||||
"tags": encode_tags(tags),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ from devplacepy.models import ProfileForm
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
db,
|
||||
get_customization_prefs,
|
||||
get_notification_prefs,
|
||||
get_user_stars,
|
||||
@@ -37,6 +36,7 @@ from devplacepy.database.awards import (
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
get_badge,
|
||||
require_user,
|
||||
require_user_api,
|
||||
time_ago,
|
||||
@@ -46,6 +46,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.utils.rewards import LEVEL_XP
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
@@ -139,6 +140,12 @@ async def profile_page(
|
||||
current_user["uid"], f"/profile/{profile_user['username']}"
|
||||
)
|
||||
profile_user["stars"] = get_user_stars(profile_user["uid"])
|
||||
xp_raw = profile_user.get("xp") or 0
|
||||
level_raw = profile_user.get("level") or 1
|
||||
xp_progress_pct = xp_raw % LEVEL_XP
|
||||
xp_next_level = level_raw * LEVEL_XP
|
||||
profile_user["xp_progress_pct"] = xp_progress_pct
|
||||
profile_user["xp_next_level"] = xp_next_level
|
||||
rank = get_user_rank(profile_user["uid"])
|
||||
follow_counts = get_follow_counts(profile_user["uid"])
|
||||
|
||||
@@ -195,6 +202,10 @@ async def profile_page(
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
badge_meta = get_badge(b["badge_name"])
|
||||
b["icon"] = badge_meta.get("icon")
|
||||
b["description"] = badge_meta.get("description")
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
@@ -440,6 +451,8 @@ async def profile_page(
|
||||
"awards_count": awards_count,
|
||||
"prominent_award": prominent_award,
|
||||
"can_give_award": can_give,
|
||||
"xp_next_level": xp_next_level,
|
||||
"xp_progress_pct": xp_progress_pct,
|
||||
},
|
||||
model=ProfileOut,
|
||||
)
|
||||
@@ -499,3 +512,7 @@ async def regenerate_api_key(request: Request):
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return JSONResponse({"api_key": new_key})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from devplacepy.routers.projects.containers import instances, schedules
|
||||
from devplacepy.routers.projects.containers import instances, schedules, workspace
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(instances.router)
|
||||
router.include_router(schedules.router)
|
||||
router.include_router(workspace.router)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.content import (
|
||||
can_manage_workspace,
|
||||
can_open_workspace,
|
||||
)
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.models import TunnelForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import WorkspaceOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import activity, forward, store
|
||||
from devplacepy.services.containers.workspace import provision, quota, tunnels
|
||||
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||
from devplacepy.utils import not_found, require_user
|
||||
|
||||
from ._shared import audit_instance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _project_or_404(slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _workspace_or_404(project: dict, user: dict) -> dict:
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
if not instance:
|
||||
raise not_found("No workspace for this project")
|
||||
return instance
|
||||
|
||||
|
||||
def _guard(request: Request, project: dict, user: dict, event_key: str) -> None:
|
||||
if can_open_workspace(project, user):
|
||||
return
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
summary=f"{user['username']} denied workspace access",
|
||||
result="denied",
|
||||
)
|
||||
raise not_found("Workspaces are not available for this project")
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace")
|
||||
async def workspace_page(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
_guard(request, project, user, "container.workspace.open")
|
||||
instance = provision.find_for_project(project["uid"], user["uid"])
|
||||
limits = quota.resolve(user["uid"])
|
||||
context = {
|
||||
"project": project,
|
||||
"workspace": provision.view(instance) if instance else None,
|
||||
"has_workspace": bool(instance),
|
||||
"viewer_can_workspace": True,
|
||||
"workspace_count": provision.count_for_owner(user["uid"]),
|
||||
"max_workspaces": limits.max_workspaces,
|
||||
"editor_url": (
|
||||
f"/projects/{slug}/containers/instances/{instance['uid']}/code/"
|
||||
if instance
|
||||
else ""
|
||||
),
|
||||
"user": user,
|
||||
}
|
||||
return respond(request, "workspace.html", context, model=WorkspaceOut)
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace")
|
||||
async def workspace_open(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
_guard(request, project, user, "container.workspace.quota.block")
|
||||
try:
|
||||
instance = await provision.ensure(project, user)
|
||||
instance = provision.resume(instance)
|
||||
except WorkspaceError as error:
|
||||
audit.record(
|
||||
request,
|
||||
"container.workspace.quota.block",
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
summary=str(error),
|
||||
result="denied",
|
||||
)
|
||||
return json_error(str(error), 400)
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.workspace.create",
|
||||
instance,
|
||||
project,
|
||||
summary=f"{user['username']} opened workspace for {project.get('title')}",
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(
|
||||
request, f"/projects/{slug}/workspace", data=provision.view(instance)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/stop")
|
||||
async def workspace_stop(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error("Not allowed to manage this workspace", 403)
|
||||
provision.stop(instance)
|
||||
audit_instance(request, user, "container.workspace.stop", instance, project)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/delete")
|
||||
async def workspace_delete(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error("Not allowed to manage this workspace", 403)
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
tunnels.soft_delete(row["uid"], user["uid"])
|
||||
store.delete_instance(instance["uid"], user["uid"])
|
||||
audit_instance(request, user, "container.workspace.delete", instance, project)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
@router.get("/{slug}/workspace/tunnels")
|
||||
async def tunnel_list(request: Request, slug: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error("Not allowed to manage this workspace", 403)
|
||||
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
||||
|
||||
|
||||
@router.post("/{slug}/workspace/tunnels")
|
||||
async def tunnel_create(
|
||||
request: Request, slug: str, data: Annotated[TunnelForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error("Not allowed to manage this workspace", 403)
|
||||
if data.container_port <= 0:
|
||||
return json_error("container_port must be between 1 and 65535", 400)
|
||||
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||
if limits.max_tunnels and tunnels.count_for_instance(
|
||||
instance["uid"]
|
||||
) >= limits.max_tunnels:
|
||||
return json_error(f"tunnel limit reached ({limits.max_tunnels})", 400)
|
||||
row = tunnels.create(instance, data.label, data.container_port, user["uid"])
|
||||
if not row:
|
||||
return json_error("could not create tunnel", 400)
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.tunnel.create",
|
||||
instance,
|
||||
project,
|
||||
metadata={"hostname": row["hostname"], "port": data.container_port},
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(request, f"/projects/{slug}/workspace", data=row)
|
||||
|
||||
|
||||
@router.delete("/{slug}/workspace/tunnels/{uid}")
|
||||
@router.post("/{slug}/workspace/tunnels/{uid}/delete")
|
||||
async def tunnel_delete(request: Request, slug: str, uid: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return user
|
||||
project = _project_or_404(slug)
|
||||
instance = _workspace_or_404(project, user)
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return json_error("Not allowed to manage this workspace", 403)
|
||||
row = tunnels.get(uid)
|
||||
if not row or row.get("instance_uid") != instance["uid"]:
|
||||
raise not_found("Tunnel not found")
|
||||
tunnels.soft_delete(uid, user["uid"])
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
"container.tunnel.delete",
|
||||
instance,
|
||||
project,
|
||||
metadata={"hostname": row.get("hostname", "")},
|
||||
)
|
||||
provision.write_manifest(instance)
|
||||
return action_result(request, f"/projects/{slug}/workspace")
|
||||
|
||||
|
||||
def _editor_guard(request: Request, slug: str, uid: str):
|
||||
user = require_user(request)
|
||||
if isinstance(user, Response):
|
||||
return None, None, user
|
||||
project = _project_or_404(slug)
|
||||
instance = store.get_instance(uid)
|
||||
if not instance or not instance.get("is_workspace"):
|
||||
raise not_found("Workspace not found")
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
return None, None, json_error("Not allowed to open this workspace", 403)
|
||||
return project, instance, None
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{slug}/containers/instances/{uid}/code", methods=forward.METHODS
|
||||
)
|
||||
@router.api_route(
|
||||
"/{slug}/containers/instances/{uid}/code/{path:path}", methods=forward.METHODS
|
||||
)
|
||||
async def editor_proxy(request: Request, slug: str, uid: str, path: str = ""):
|
||||
project, instance, denial = _editor_guard(request, slug, uid)
|
||||
if denial is not None:
|
||||
return denial
|
||||
if instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return Response("this workspace is not running", status_code=409)
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
return Response("the editor has no reachable port", status_code=502)
|
||||
activity.touch(instance["uid"])
|
||||
prefix = f"/projects/{slug}/containers/instances/{uid}/code"
|
||||
return await forward.proxy_http(request, host, port, path, prefix=prefix)
|
||||
|
||||
|
||||
@router.websocket("/{slug}/containers/instances/{uid}/code")
|
||||
@router.websocket("/{slug}/containers/instances/{uid}/code/{path:path}")
|
||||
async def editor_proxy_ws(
|
||||
websocket: WebSocket, slug: str, uid: str, path: str = ""
|
||||
):
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
user = get_current_user(websocket)
|
||||
project = resolve_by_slug(get_table("projects"), slug)
|
||||
instance = store.get_instance(uid)
|
||||
if not user or not project or not instance or not instance.get("is_workspace"):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if not can_manage_workspace(instance, project, user):
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if instance.get("suspended_at") or instance.get("status") != store.ST_RUNNING:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
host, port = provision.editor_target(instance)
|
||||
if not host or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
activity.touch(instance["uid"])
|
||||
await forward.proxy_ws(websocket, host, port, path)
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import or_
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
@@ -13,6 +14,9 @@ from devplacepy.database import (
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
get_recent_comments_by_target_uids,
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -35,6 +39,7 @@ from devplacepy.content import (
|
||||
is_owner,
|
||||
can_view_project,
|
||||
can_view_project_containers,
|
||||
get_project_devlog,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
@@ -171,7 +176,7 @@ async def projects_page(
|
||||
)
|
||||
|
||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||
async def project_detail(request: Request, project_slug: str):
|
||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
||||
user = get_current_user(request)
|
||||
detail = load_detail("projects", "project", project_slug, user)
|
||||
if not detail:
|
||||
@@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
|
||||
if parent
|
||||
else None
|
||||
)
|
||||
|
||||
devlog_posts, devlog_next_cursor = get_project_devlog(
|
||||
project["uid"], before=before, viewer=user
|
||||
)
|
||||
if devlog_posts:
|
||||
post_uids = [item["post"]["uid"] for item in devlog_posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids)
|
||||
reactions_map = get_reactions_by_targets("post", post_uids, user)
|
||||
bookmark_set = (
|
||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
||||
for item in devlog_posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"project_detail.html",
|
||||
@@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
"devlog_posts": devlog_posts,
|
||||
"devlog_next_cursor": devlog_next_cursor,
|
||||
},
|
||||
),
|
||||
model=ProjectDetailOut,
|
||||
@@ -464,3 +490,5 @@ async def set_project_readonly(
|
||||
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
|
||||
):
|
||||
return _set_project_flag(request, project_slug, "read_only", data.value)
|
||||
|
||||
|
||||
|
||||
+6
-137
@@ -1,70 +1,18 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.containers import api, forward, store
|
||||
from devplacepy.utils import not_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
}
|
||||
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
def _forward_headers(request: Request, prefix: str) -> dict:
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
headers["X-Forwarded-Prefix"] = prefix
|
||||
headers["X-Script-Name"] = prefix
|
||||
headers["X-Forwarded-Host"] = request.headers.get(
|
||||
"host", request.url.hostname or ""
|
||||
)
|
||||
headers["X-Forwarded-Proto"] = request.headers.get(
|
||||
"x-forwarded-proto", request.url.scheme
|
||||
)
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
|
||||
def _inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = (
|
||||
lowered.find(b">", head)
|
||||
if head != -1
|
||||
else lowered.find(b">", lowered.find(b"<html"))
|
||||
)
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
|
||||
|
||||
def _rewrite_location(value: str, prefix: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return prefix + value
|
||||
return value
|
||||
METHODS = forward.METHODS
|
||||
|
||||
|
||||
def _resolve(slug: str):
|
||||
@@ -95,41 +43,9 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
|
||||
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
prefix = f"/p/{slug}"
|
||||
url = f"http://{host}:{port}/{path}"
|
||||
headers = _forward_headers(request, prefix)
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as client:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
params=request.query_params,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return Response(f"upstream error: {exc}", status_code=502)
|
||||
out_headers = {
|
||||
k: v
|
||||
for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
|
||||
}
|
||||
if "location" in out_headers:
|
||||
out_headers["location"] = _rewrite_location(out_headers["location"], prefix)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
content = upstream.content
|
||||
if "text/html" in content_type.lower():
|
||||
content = _inject_base(content, prefix)
|
||||
response = Response(
|
||||
content=content,
|
||||
status_code=upstream.status_code,
|
||||
headers=out_headers,
|
||||
media_type=content_type or None,
|
||||
return await forward.proxy_http(
|
||||
request, host, port, path, prefix=f"/p/{slug}", timeout=60.0
|
||||
)
|
||||
for cookie in upstream.headers.get_list("set-cookie"):
|
||||
response.headers.append("set-cookie", cookie)
|
||||
return response
|
||||
|
||||
|
||||
@router.websocket("/{slug}")
|
||||
@@ -139,9 +55,6 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||
if instance is None or not host or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
upstream_url = f"ws://{host}:{port}/{path}"
|
||||
if websocket.url.query:
|
||||
upstream_url += f"?{websocket.url.query}"
|
||||
await websocket.accept()
|
||||
audit.record(
|
||||
websocket,
|
||||
@@ -154,48 +67,4 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url, open_timeout=10, max_size=None
|
||||
) as upstream:
|
||||
await _pump(websocket, upstream)
|
||||
except Exception as exc:
|
||||
logger.debug("ws proxy %s failed: %s", slug, exc)
|
||||
try:
|
||||
await websocket.close(code=1011)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _pump(client_ws: WebSocket, upstream) -> None:
|
||||
async def client_to_upstream():
|
||||
try:
|
||||
while True:
|
||||
message = await client_ws.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message.get("text") is not None:
|
||||
await upstream.send(message["text"])
|
||||
elif message.get("bytes") is not None:
|
||||
await upstream.send(message["bytes"])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
async def upstream_to_client():
|
||||
try:
|
||||
async for message in upstream:
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
await client_ws.send_bytes(bytes(message))
|
||||
else:
|
||||
await client_ws.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await client_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||
await forward.proxy_ws(websocket, host, port, path, accepted=True)
|
||||
|
||||
+23
-15
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from devplacepy import push
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.push import providers
|
||||
from devplacepy.utils import require_user_api
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
@@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
|
||||
|
||||
@router.get("/push.json")
|
||||
async def push_public_key() -> JSONResponse:
|
||||
return JSONResponse({"publicKey": push.public_key_standard_b64()})
|
||||
configs = providers.client_config()
|
||||
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
|
||||
return JSONResponse(
|
||||
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push.json")
|
||||
@@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
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")
|
||||
):
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
_, created = await push.register(
|
||||
user_uid=user["uid"],
|
||||
endpoint=body["endpoint"],
|
||||
key_auth=keys["auth"],
|
||||
key_p256dh=keys["p256dh"],
|
||||
)
|
||||
provider = providers.get(body.get("provider"))
|
||||
if provider is None or not providers.is_active(provider):
|
||||
return JSONResponse({"error": "Unknown provider"}, status_code=400)
|
||||
|
||||
fields = provider.parse_registration(body)
|
||||
if fields is None:
|
||||
return JSONResponse({"error": "Invalid request"}, status_code=400)
|
||||
|
||||
_, created = push.register(user["uid"], provider.name, fields)
|
||||
|
||||
if created:
|
||||
try:
|
||||
@@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user.get("username"),
|
||||
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
|
||||
metadata={
|
||||
"provider": provider.name,
|
||||
"endpoint_host": urlparse(fields["endpoint"]).hostname
|
||||
if fields.get("endpoint")
|
||||
else None,
|
||||
"created": created,
|
||||
},
|
||||
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from devplacepy.services.containers import activity, api, forward, store
|
||||
from devplacepy.services.containers.workspace import naming, tunnels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
METHODS = forward.METHODS
|
||||
|
||||
|
||||
def resolve(host: str):
|
||||
if not naming.is_tunnel_host(host):
|
||||
return None, None, None, None
|
||||
row = tunnels.by_hostname(host)
|
||||
if not row or row.get("status") not in tunnels.SERVING_STATUSES:
|
||||
return None, None, None, None
|
||||
instance = store.get_instance(row.get("instance_uid", ""))
|
||||
if not instance or instance.get("deleted_at"):
|
||||
return None, None, None, None
|
||||
if instance.get("suspended_at"):
|
||||
return row, instance, None, None
|
||||
if instance.get("status") != store.ST_RUNNING:
|
||||
return row, instance, None, None
|
||||
gateway, _ = api.proxy_target(instance)
|
||||
host_port = _published_host_port(instance, int(row.get("container_port") or 0))
|
||||
return row, instance, gateway, host_port
|
||||
|
||||
|
||||
def _published_host_port(instance: dict, container_port: int) -> int:
|
||||
import json
|
||||
|
||||
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||
if int(mapping.get("container") or 0) == container_port:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
|
||||
|
||||
async def handle_http(request: Request, path: str) -> Response:
|
||||
host = request.headers.get("host", "")
|
||||
row, instance, gateway, port = resolve(host)
|
||||
if row is None:
|
||||
return Response("no tunnel is published at this address", status_code=404)
|
||||
if instance is not None and instance.get("suspended_at"):
|
||||
return Response("this workspace is suspended", status_code=403)
|
||||
if not gateway or not port:
|
||||
return Response("the tunnel has no reachable port", status_code=502)
|
||||
response = await forward.proxy_http(request, gateway, port, path)
|
||||
size = len(response.body) if hasattr(response, "body") and response.body else 0
|
||||
activity.touch(instance["uid"], egress_bytes=size)
|
||||
tunnels.record_hit(row["uid"], size)
|
||||
return response
|
||||
|
||||
|
||||
async def handle_ws(websocket: WebSocket, path: str) -> None:
|
||||
host = websocket.headers.get("host", "")
|
||||
row, instance, gateway, port = resolve(host)
|
||||
if row is None or instance is None or not gateway or not port:
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
activity.touch(instance["uid"])
|
||||
await forward.proxy_ws(websocket, gateway, port, path)
|
||||
@@ -71,10 +71,15 @@ from devplacepy.schemas.containers import (
|
||||
AdminContainerEditOut,
|
||||
AdminContainerInstanceOut,
|
||||
AdminContainersOut,
|
||||
AdminWorkspacesOut,
|
||||
BotFrameOut,
|
||||
ContainersOut,
|
||||
InstanceOut,
|
||||
ScheduleOut,
|
||||
TunnelOut,
|
||||
WorkspaceFlagOut,
|
||||
WorkspaceOut,
|
||||
WorkspaceViewOut,
|
||||
)
|
||||
from devplacepy.schemas.jobs import (
|
||||
DbQueryJobOut,
|
||||
|
||||
@@ -99,3 +99,75 @@ class AdminBotsOut(_Out):
|
||||
service_status: str = ""
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class TunnelOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
project_uid: str = ""
|
||||
user_uid: str = ""
|
||||
hostname: str = ""
|
||||
label: str = ""
|
||||
container_port: int = 0
|
||||
desired_state: str = ""
|
||||
status: str = ""
|
||||
cert_status: str = ""
|
||||
request_count: int = 0
|
||||
bytes_out: int = 0
|
||||
last_request_at: str = ""
|
||||
last_error: str = ""
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class WorkspaceFlagOut(_Out):
|
||||
uid: str = ""
|
||||
instance_uid: str = ""
|
||||
user_uid: str = ""
|
||||
kind: str = ""
|
||||
severity: str = ""
|
||||
detail: str = ""
|
||||
metric_value: float = 0.0
|
||||
threshold: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
class WorkspaceViewOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
status: str = ""
|
||||
desired_state: str = ""
|
||||
suspended: bool = False
|
||||
flag_reason: str = ""
|
||||
tunnel_name: str = ""
|
||||
primary_url: str = ""
|
||||
last_active_at: str = ""
|
||||
disk_bytes: int = 0
|
||||
disk_quota_mb: int = 0
|
||||
disk_percent: int = 0
|
||||
egress_bytes: int = 0
|
||||
egress_quota_mb: int = 0
|
||||
egress_percent: int = 0
|
||||
idle_stop_minutes: int = 0
|
||||
retention_days: int = 0
|
||||
max_tunnels: int = 0
|
||||
tunnels: list[TunnelOut] = []
|
||||
flags: list[WorkspaceFlagOut] = []
|
||||
|
||||
|
||||
class WorkspaceOut(_Out):
|
||||
project: Optional[Any] = None
|
||||
workspace: Optional[WorkspaceViewOut] = None
|
||||
has_workspace: bool = False
|
||||
viewer_can_workspace: bool = False
|
||||
workspace_count: int = 0
|
||||
max_workspaces: int = 0
|
||||
editor_url: str = ""
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminWorkspacesOut(_Out):
|
||||
workspaces: list[WorkspaceViewOut] = []
|
||||
flags: list[WorkspaceFlagOut] = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from devplacepy.schemas.base import _Out
|
||||
|
||||
|
||||
@@ -17,6 +19,8 @@ class UserOut(_Out):
|
||||
website: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
xp: Optional[int] = None
|
||||
xp_progress_pct: Optional[int] = None
|
||||
xp_next_level: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
@@ -62,8 +66,18 @@ class PollOut(_Out):
|
||||
|
||||
|
||||
class BadgeOut(_Out):
|
||||
name: Optional[str] = None
|
||||
name: Optional[str] = Field(None, alias="badge_name")
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class ProjectLinkOut(_Out):
|
||||
uid: str = ""
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
class PostOut(_Out):
|
||||
@@ -75,6 +89,7 @@ class PostOut(_Out):
|
||||
topic: Optional[str] = None
|
||||
stars: Optional[int] = None
|
||||
image: Optional[str] = None
|
||||
project_uid: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
@@ -202,3 +217,4 @@ class MessageOut(_Out):
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
|
||||
NotificationOut,
|
||||
PollOut,
|
||||
PostOut,
|
||||
ProjectLinkOut,
|
||||
ProjectOut,
|
||||
ReactionsOut,
|
||||
UserOut,
|
||||
@@ -31,6 +32,7 @@ class FeedItemOut(_Out):
|
||||
reactions: ReactionsOut = ReactionsOut()
|
||||
bookmarked: bool = False
|
||||
poll: Optional[PollOut] = None
|
||||
project_link: Optional[ProjectLinkOut] = None
|
||||
|
||||
|
||||
class GistItemOut(_Out):
|
||||
@@ -132,6 +134,7 @@ class PostDetailOut(_Out):
|
||||
comment_count: Optional[int] = None
|
||||
related_posts: list[FeedItemOut] = []
|
||||
topics: list[str] = []
|
||||
project_link: Optional[ProjectLinkOut] = None
|
||||
|
||||
|
||||
class ProjectsOut(_Out):
|
||||
@@ -163,6 +166,8 @@ class ProjectDetailOut(_Out):
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
devlog_posts: list[FeedItemOut] = []
|
||||
devlog_next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
class GistsOut(_Out):
|
||||
@@ -232,3 +237,4 @@ class LeaderboardOut(_Out):
|
||||
class SavedOut(_Out):
|
||||
items: list[SavedItemOut] = []
|
||||
next_cursor: Optional[str] = None
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ class ProfileOut(_Out):
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
xp_next_level: int = 0
|
||||
xp_progress_pct: int = 0
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
notification_prefs: list[Any] = []
|
||||
@@ -88,3 +90,4 @@ class TelegramPairOut(_Out):
|
||||
code: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
ttl_minutes: Optional[int] = None
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
|
||||
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
||||
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`).
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`). `PushService` (`services/push/`) is the thinnest example of the pattern: it owns no loop work beyond pruning dead subscriptions, and exists mainly so every push provider's configuration is edited through the same `ConfigField` surface as every other subsystem - its `config_fields` are assembled from `devplacepy.push.providers.admin_fields()`, so a new provider appears at `/admin/services/push` with no edit to the service. Push delivery does NOT depend on that service running (see `devplacepy/push/CLAUDE.md`).
|
||||
|
||||
### DB-backed state (correct across workers)
|
||||
|
||||
@@ -179,12 +179,12 @@ Online status is a single **`users.last_seen`** UTC-ISO column (ensured in `data
|
||||
|
||||
**Read path (any worker):** `presence.is_online(user_row)` = `now - last_seen < PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60). Profile (`routers/profile/index.py` -> `profile_online`) and messages (`routers/messages.py` seed) read `last_seen` off the user row they already loaded - no extra query. Exposed as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. This is the **only** cross-worker-correct approach here because pub/sub is in-process.
|
||||
|
||||
**Live path (lock owner only), change-only + hysteresis:** `PresenceRelayService` (`services/presence_relay.py`, `BaseService`, default-enabled, 2s tick, registered in `main.py`) is a sibling of `NotificationRelayService`/`LiveViewRelayService`. Each tick it recomputes ONE global online set `self._online` (dot-subscribed uids batch-read via `get_users_by_uids` + roster candidates) and drives BOTH the per-user dots and the feed roster from that one set, so they can never disagree. The set uses **hysteresis** via `presence.stays_online(elapsed, was_online)`: a user becomes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after `+ PRESENCE_ONLINE_MARGIN_SECONDS` (env `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - **quick to go online, slow (grace margin) to go offline** - which kills boundary flicker for a user hovering near the timeout. It publishes `{online, last_seen}` to `public.presence.{uid}` **only when a topic's `online` bool changed OR the topic is newly subscribed (first-seen)** - never on a fixed interval, so a steady page emits nothing after the initial frame (`self._published[topic] -> bool`, pruned to active topics). `public.presence.{uid}` is subscribable by any logged-in user (`pubsub/policy.py` allows `public.*`); guests fall back to the server-rendered initial state. The one-directional grace also means dots and roster stay consistent across viewers (the shared `self._online` is the single authority). To keep it lightweight the relay reads all due users in **one batched `get_users_by_uids`** per tick.
|
||||
**Live path (lock owner only), ONE set on ONE topic, change-only + hysteresis:** `PresenceRelayService` (`services/presence_relay.py`, `BaseService`, default-enabled, 2s tick, registered in `main.py`) is a sibling of `NotificationRelayService`/`LiveViewRelayService` and is **the single source of truth for live online status**. Each tick, only while the roster topic has subscribers, it reads the online population in ONE indexed query (`presence.online_candidates()`, capped at `config.PRESENCE_TRACK_LIMIT`, env `DEVPLACE_PRESENCE_TRACK_LIMIT`, default 500) and recomputes ONE set `self._online` with **hysteresis** via `presence.stays_online(elapsed, was_online)`: a user becomes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after `+ PRESENCE_ONLINE_MARGIN_SECONDS` (env `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - **quick to go online, slow (grace margin) to go offline** - which kills boundary flicker. It publishes that set on the ONE shared topic `public.presence.roster` (`roster_payload`: `{count, online: [uid...], users: [display rows]}`) **only when the set of online uids changes** (a `frozenset` compare, so reordering never republishes), never on a fixed interval, so an idle site emits nothing. `online` is the authority for EVERY avatar dot; `users` is the same set trimmed to `PRESENCE_ONLINE_LIMIT` for the feed's avatar panel, and `count` matches it. **There are no per-user `public.presence.{uid}` topics** - they were removed precisely because their candidate population differed from the roster's, so dots and roster could disagree (the /messages-vs-feed bug). One set, one topic, one frame. `public.presence.roster` is subscribable by any logged-in user (`pubsub/policy.py` allows `public.*`); guests keep the server-rendered initial state.
|
||||
|
||||
**Frontend:** `static/js/PresenceManager.js` (`app.presence`, constructed with `this.pubsub` in `Application.js`, mirroring `LocalTime`/`CounterManager`) scans `[data-presence-uid]` elements, subscribes each uid to `public.presence.{uid}` (deduped per uid, so a repeated author is one subscription), and treats each frame's `online` flag as **authoritative** (`entry.online`), toggling the `online` class + (for `data-presence-label` elements) the "online / last seen X / offline" text. Because the relay is change-only and authoritative, a live-subscribed dot is **not** expired by the client clock (no false-offline flicker for an active user whose `last_seen` the client cannot see advancing); the 20s `last_seen` staleness timer only applies to entries that never received a frame (guests / degraded). The window is read from `<body data-presence-timeout>`. Both the profile `.profile-presence` dot and the messages `#messages-presence` span carry `data-presence-uid`/`data-presence-last-seen`.
|
||||
**Frontend:** `static/js/PresenceManager.js` (`app.presence`, constructed with `this.pubsub` in `Application.js`, mirroring `LocalTime`/`CounterManager`) makes ONE subscription to `public.presence.roster` and keeps the pushed `online` uid set. It scans `[data-presence-uid]` elements (on load and via a `MutationObserver`, so dynamically inserted markup is covered) and renders each one purely as membership of that set - toggling the `online` class and, for `data-presence-label` elements, the "online / last seen X / offline" text, where the relative time is a `<time data-dt data-dt-mode="ago">` formatted by the shared `LocalTime` (presence never formats a date itself). **There is NO client-side clock, no `data-presence-timeout`, and no expiry timer** - the old `isOnline(lastSeen)` fallback was a second decider that silently drifted a dot to grey after the timeout whenever a frame was missed, which is exactly how /messages diverged from the feed. Before the first frame arrives the server-rendered state simply stands. The constructor takes an optional `root` (`new PresenceManager(pubsub, root)`) so a detached widget can scope it; `dp-chat mode="embed"` uses that only when no page-global `app.presence` exists. Both the profile `.profile-presence` dot and the messages `#messages-presence` span carry `data-presence-uid`/`data-presence-last-seen`.
|
||||
|
||||
**Online-now roster (feed):** the same relay maintains ONE shared topic `public.presence.roster`, republished **only when the SET of online uids changes** (a `frozenset` compare, so pure reordering never republishes). `services/presence.py` `online_users(limit)` (strict, feed initial render) and `online_candidates(limit)` (grace window, relay hysteresis) both go through `database.get_online_users(cutoff_iso, limit)`, which reads users with `last_seen >= cutoff` via the `idx_users_last_seen` index (the one place presence is queried by `last_seen`; `config.PRESENCE_ONLINE_LIMIT`, env `DEVPLACE_PRESENCE_ONLINE_LIMIT`, default 30). **The list is ordered ALPHABETICALLY by username** (`get_online_users` `order_by=["username"]` + case-insensitive `presence.sort_by_username`), NOT by recency, so avatars keep a stable position and do not needlessly reshuffle as people's `last_seen` ticks. `routers/feed.py` puts `online_users` on the context (`FeedOut.online_users`) and `feed.html` renders the initial **Online now** panel as a `.sidebar-section` at the bottom of the left feed sidebar (`aside.sidebar-card`); `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to `public.presence.roster` and re-renders the avatar list + count live. Roster avatars use a plain green `.presence-dot` with NO `data-presence-uid` (list membership IS the presence, so no per-user subscription - the relay drops a user from the roster when they go offline).
|
||||
**Online-now roster (feed):** the feed panel is just the display face of the same frame. `services/presence.py` `online_users(limit=PRESENCE_ONLINE_LIMIT)` (strict cutoff, feed initial render) and `online_candidates(limit=PRESENCE_TRACK_LIMIT)` (grace window, the relay's authority population) both go through `database.get_online_users(cutoff_iso, limit)`, which reads users with `last_seen >= cutoff` via the `idx_users_last_seen` index (the one place presence is queried by `last_seen`). **The list is ordered ALPHABETICALLY by username** (`get_online_users` `order_by=["username"]` + case-insensitive `presence.sort_by_username`), NOT by recency, so avatars keep a stable position. `routers/feed.py` puts `online_users` on the context (`FeedOut.online_users`) and `feed.html` renders the initial **Online now** panel as a `.sidebar-section` at the bottom of the left feed sidebar; `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to `public.presence.roster` and re-renders the avatar list + count from `users`/`count`. **A roster avatar is NOT a special case** - it renders the same `_presence_dot.html` (server) / `Avatar.badgeElement` (client) as every other avatar, subscribed like every other dot, so it cannot disagree with the rest of the page.
|
||||
|
||||
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. Reuse `_presence_dot.html` + the `.avatar-badge` wrapper for any new avatar surface - never hand-roll a presence dot.
|
||||
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. In JavaScript the matching builder is `Avatar.badgeElement(user)` (`static/js/Avatar.js`), which emits the `.avatar-badge` + `.presence-dot` + award-badge trio and is used by `OnlineUsers` and `AppChat._buildConversationItem`, so dot markup lives in exactly two places: the partial and that helper. Reuse `_presence_dot.html` (server) or `Avatar.badgeElement` (client) for any new avatar surface - never hand-roll a presence dot.
|
||||
|
||||
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`.
|
||||
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.roster` topic, and `PresenceManager`. `dp-chat` likewise no longer carries its own presence renderer or `PubSubClient`: that private duplicate only ever showed online/offline (never `last seen`) and was a third source of truth.
|
||||
|
||||
@@ -4,7 +4,7 @@ import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.database import get_table, get_int_setting, is_account_active
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
@@ -77,7 +77,7 @@ def resolve_token(token: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
user = get_table("users").find_one(uid=row.get("user_uid"))
|
||||
if not user or not user.get("is_active", True):
|
||||
if not user or not is_account_active(user):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ This file documents the audit log subsystem. Claude Code auto-loads it when a fi
|
||||
|
||||
## Audit log (`services/audit/`)
|
||||
|
||||
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 223 keys across 38 domains.
|
||||
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 288 keys across 42 domains.
|
||||
|
||||
### Package layout
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ The security hotpatch that used to run per build is now baked into `ppy.Dockerfi
|
||||
- `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed).
|
||||
- `COPY`s the **`aptroot` fakeroot wrapper** (`files/aptroot`, symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` so pravda installs system packages without root).
|
||||
- `COPY`s **`pagent`** (`files/pagent`, the stdlib AI agent; reads `DEVPLACE_OPENAI_URL`+`DEVPLACE_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py`, plus `files/.vimrc` to `/home/pravda/.vimrc` (whose AI helper - `AiEditSelection` - targets the same gateway as pagent via `DEVPLACE_OPENAI_URL`/`DEVPLACE_API_KEY`, with a public fallback, never `api.openai.com`).
|
||||
- `COPY`s **`dpc`** (`files/dpc`, DevPlace Code, the Claude-Code-class coding agent) to `/usr/bin/dpc`, and **`bot.py`** to `/usr/bin/botje.py`. **`dpc` is the one prebuilt binary in this repository**: an ELF 64-bit x86-64 position-independent executable, 10850272 bytes, sha256 `0c0f980717deebed285dcde7968c249f77638a65d76af8ae5deeda3ac2b0f042`. Its source is NOT in this repository and there is no build recipe here, so unlike every other file in `files/` it cannot be reviewed before it is installed root-owned onto `PATH` in every user container. Record a new size and checksum here whenever it is replaced - this record is the only integrity check that exists on it, so a stale entry is worse than none. (The previous entry, 3578664 bytes / sha256 `24f7fbb0...`, described a build that is no longer the file on disk.)
|
||||
- Evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**.
|
||||
- Hands pravda ownership of the toolchain AND the OS package trees (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`, plus `/usr/lib`, `/usr/bin`, `/usr/sbin`, `/usr/share`, `/usr/include`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv` so `apt`/`dpkg` can write; `~/.local/bin` on `PATH`).
|
||||
- Ends on `USER pravda`.
|
||||
@@ -198,3 +199,71 @@ The container runtime is also the basis of "vibe coding": the public prose page
|
||||
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
|
||||
|
||||
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
|
||||
|
||||
## Dev Workspaces (`workspace/`, `workspace_service.py`, `activity.py`, `forward.py`)
|
||||
|
||||
A **workspace** is a member-facing container running `code-server`, layered on this same runtime. The
|
||||
admin container manager's authorization is unchanged; workspaces add their own narrower predicates.
|
||||
|
||||
**Two planes, deliberately distinct.**
|
||||
|
||||
| | Plane A: editor | Plane B: user's servers |
|
||||
|---|---|---|
|
||||
| Entry | `/projects/{slug}/containers/instances/{uid}/code/...` | `{port}-{name}.tunnel.pravda.education` |
|
||||
| Auth | session + `can_manage_workspace` | none, public by design |
|
||||
| Backend | code-server, `--auth none`, bound in-container | whatever the user runs |
|
||||
|
||||
Plane B routing is **one static molohttp site** `*.tunnel.pravda.education -> 127.0.0.1:10500`;
|
||||
DevPlace resolves the instance from the `Host` header. There is no molohttp object per tunnel.
|
||||
`TunnelDispatchMiddleware` (`main.py`) is ASGI-level and pre-empts the router for both HTTP and
|
||||
WebSocket, so a tunnel host can never render the application. molohttp's `HostIndex` glob matches
|
||||
**exactly one label**, which is why the pattern is `{port}-{name}`, never `{port}.{name}`.
|
||||
|
||||
**One forwarding core.** `forward.py` owns header filtering, prefix stripping, `Location` rewriting,
|
||||
`<base>` injection and the bidirectional WS pump. `/p/{slug}`, the editor route and the tunnel route
|
||||
all call it. Never write a second proxy.
|
||||
|
||||
**Editor persistence.** code-server's user-data and extensions live in
|
||||
`config.WORKSPACE_STATE_DIR/<instance uid>`, bind-mounted at `WORKSPACE_STATE_MOUNT`, so extensions
|
||||
survive container recreation. `api.editor_command` builds the argv; `run_spec_for` prefers it over
|
||||
the boot-script/boot-command chain when `is_workspace` and `editor_port` are set.
|
||||
|
||||
**Activity and egress are the presence pattern.** `activity.py` keeps a per-worker monotonic dict and
|
||||
writes at most once per `WORKSPACE_ACTIVITY_WRITE_SECONDS`, accumulating egress and request counts
|
||||
into one atomic `COALESCE` UPDATE (`store.record_activity`). Both proxy planes call `touch`; because
|
||||
plane B traverses DevPlace, public traffic is observed directly rather than inferred.
|
||||
|
||||
**`workspace/` package.** `quota.py` resolves limits instance -> user rule -> setting -> default
|
||||
through ONE resolver (never read a workspace setting at a call site). `flags.py` is the abuse ledger
|
||||
and is **idempotent per `(instance_uid, kind)` while a flag is open**, so a sustained condition is one
|
||||
row, not one per tick. `naming.py` generates faker labels with collision retry and owns the hostname
|
||||
patterns plus `is_tunnel_host`. `tunnels.py` is CRUD with revive-not-duplicate. `provision.py` is the
|
||||
create/resume/stop/suspend/view surface and writes `/app/.devplace/tunnels.json`.
|
||||
|
||||
**`WorkspaceService`** is the only new service: lock-owner, `default_enabled=False`, four wrapped
|
||||
phases (disk sample on its own slow cadence, flag evaluation, lifecycle, purge sweep). It is a
|
||||
reconciler, not a `JobService`.
|
||||
|
||||
**Two contracts that bite:**
|
||||
- A `ConfigField` with `type="select"` needs `options=[{"value": ..., "label": ...}]`. Plain strings
|
||||
crash `docs_api.build_services_group`, which `docs_search` indexes, so the whole docs search page
|
||||
500s. No other service used `select` before this one.
|
||||
- A route taking `Annotated[Form, Form()]` validates **before** the handler's auth guard, so a
|
||||
required field makes an anonymous request 422 instead of 401 and `tests/api/auth/matrix.py` fails.
|
||||
Give the field a default and validate it inside the handler after `require_user`.
|
||||
|
||||
**Admin console** is `/admin/workspaces` (`routers/admin/workspaces.py`, `admin_workspaces.html`):
|
||||
list, start/stop, suspend/unsuspend with a required reason, raise/resolve/dismiss flags, per-user
|
||||
quota rules. `base_seo_context` takes `breadcrumbs`/`schemas`, not `canonical`/`schema`.
|
||||
|
||||
**Devii** has 15 tools under `handler="workspace"`; five are in `CONFIRM_REQUIRED` and every one of
|
||||
them declares a `confirm` param (schemas are `additionalProperties: false`, so a gated tool without it
|
||||
loops forever).
|
||||
|
||||
**Toolchains in `ppy`.** Rust (rustup), Nim (choosenim), Swift (swiftly, then the toolchain is moved
|
||||
to a fixed `/opt/swift/toolchain` because swiftly's proxy resolves against `$HOME` and breaks for
|
||||
`pravda` at runtime). The choosenim installer **exits 1 even on success**, so its `RUN` ends with
|
||||
`|| true` plus a real version check. The build smoke test must not pipe (`cmd | head -1` returns
|
||||
`head`'s status and masks a broken toolchain - this hid a non-working Swift through a full build).
|
||||
`/etc/profile.d/devplace-toolchains.sh` re-exports the PATH because a login shell resets it, which is
|
||||
what the container terminal uses.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.config import WORKSPACE_ACTIVITY_WRITE_SECONDS
|
||||
from devplacepy.services.containers import store
|
||||
|
||||
_last_write: dict[str, float] = {}
|
||||
_pending_egress: dict[str, int] = {}
|
||||
_pending_requests: dict[str, int] = {}
|
||||
|
||||
|
||||
def touch(instance_uid: str, egress_bytes: int = 0) -> None:
|
||||
if not instance_uid:
|
||||
return
|
||||
if egress_bytes > 0:
|
||||
_pending_egress[instance_uid] = _pending_egress.get(instance_uid, 0) + egress_bytes
|
||||
_pending_requests[instance_uid] = _pending_requests.get(instance_uid, 0) + 1
|
||||
now = time.monotonic()
|
||||
if now - _last_write.get(instance_uid, 0.0) < WORKSPACE_ACTIVITY_WRITE_SECONDS:
|
||||
return
|
||||
_last_write[instance_uid] = now
|
||||
flush(instance_uid)
|
||||
|
||||
|
||||
def flush(instance_uid: str) -> None:
|
||||
egress = _pending_egress.pop(instance_uid, 0)
|
||||
requests = _pending_requests.pop(instance_uid, 0)
|
||||
store.record_activity(
|
||||
instance_uid,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
egress,
|
||||
requests,
|
||||
)
|
||||
|
||||
|
||||
def flush_all() -> None:
|
||||
for instance_uid in list(_pending_requests) + list(_pending_egress):
|
||||
if instance_uid in _pending_requests or instance_uid in _pending_egress:
|
||||
flush(instance_uid)
|
||||
|
||||
|
||||
def pending(instance_uid: str) -> tuple[int, int]:
|
||||
return (
|
||||
_pending_egress.get(instance_uid, 0),
|
||||
_pending_requests.get(instance_uid, 0),
|
||||
)
|
||||
|
||||
|
||||
def forget(instance_uid: str) -> None:
|
||||
_last_write.pop(instance_uid, None)
|
||||
_pending_egress.pop(instance_uid, None)
|
||||
_pending_requests.pop(instance_uid, None)
|
||||
@@ -10,6 +10,7 @@ from devplacepy import config, project_files, stealth
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
WORKSPACE_STATE_MOUNT,
|
||||
Mount,
|
||||
PortMapping,
|
||||
RunSpec,
|
||||
@@ -414,7 +415,7 @@ def pravda_env(instance: dict) -> dict:
|
||||
break
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
env = {
|
||||
"DEVPLACE_BASE_URL": base_url,
|
||||
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"DEVPLACE_API_KEY": api_key,
|
||||
@@ -423,6 +424,109 @@ def pravda_env(instance: dict) -> dict:
|
||||
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
|
||||
"DEVPLACE_INGRESS_URL": ingress_url,
|
||||
}
|
||||
env.update(workspace_env(instance, base_url))
|
||||
return env
|
||||
|
||||
|
||||
def workspace_env(instance: dict, base_url: str) -> dict:
|
||||
from devplacepy import database
|
||||
from devplacepy.database import get_setting
|
||||
from devplacepy.services.containers.workspace import naming, quota
|
||||
|
||||
if not instance.get("is_workspace"):
|
||||
return {"DEVPLACE_WORKSPACE": ""}
|
||||
|
||||
project_slug = ""
|
||||
project_title = ""
|
||||
project_uid = instance.get("project_uid") or ""
|
||||
if project_uid:
|
||||
project = database.get_table("projects").find_one(uid=project_uid)
|
||||
if project:
|
||||
project_slug = project.get("slug") or project_uid
|
||||
project_title = project.get("title") or ""
|
||||
|
||||
owner_uid = instance.get("workspace_owner_uid") or ""
|
||||
owner_name = ""
|
||||
if owner_uid:
|
||||
owner = database.get_users_by_uids([owner_uid]).get(owner_uid)
|
||||
if owner:
|
||||
owner_name = owner.get("username") or ""
|
||||
|
||||
name = instance.get("tunnel_name") or ""
|
||||
domain = naming.domain()
|
||||
primary = naming.hostname_for(name) if name else ""
|
||||
workspace_url = (
|
||||
f"{base_url}/projects/{project_slug}/workspace"
|
||||
if base_url and project_slug
|
||||
else (f"/projects/{project_slug}/workspace" if project_slug else "")
|
||||
)
|
||||
limits = quota.resolve(owner_uid, instance)
|
||||
gallery = get_setting("workspace_extensions_gallery", "").strip()
|
||||
editor_port = int(instance.get("editor_port") or 0)
|
||||
|
||||
env = {
|
||||
"DEVPLACE_WORKSPACE": "1",
|
||||
"DEVPLACE_WORKSPACE_UID": instance.get("uid") or "",
|
||||
"DEVPLACE_WORKSPACE_URL": workspace_url,
|
||||
"DEVPLACE_WORKSPACE_OWNER": owner_name,
|
||||
"DEVPLACE_WORKSPACE_OWNER_UID": owner_uid,
|
||||
"DEVPLACE_PROJECT_SLUG": project_slug,
|
||||
"DEVPLACE_PROJECT_TITLE": project_title,
|
||||
"DEVPLACE_PROJECT_URL": (
|
||||
f"{base_url}/projects/{project_slug}"
|
||||
if base_url and project_slug
|
||||
else (f"/projects/{project_slug}" if project_slug else "")
|
||||
),
|
||||
"DEVPLACE_WORKSPACE_DIR": WORKSPACE_MOUNT,
|
||||
"DEVPLACE_WORKSPACE_STATE_DIR": WORKSPACE_STATE_MOUNT,
|
||||
"DEVPLACE_TUNNEL_NAME": name,
|
||||
"DEVPLACE_TUNNEL_DOMAIN": domain,
|
||||
"DEVPLACE_TUNNEL_URL": f"https://{primary}" if primary else "",
|
||||
"DEVPLACE_TUNNEL_PATTERN": naming.host_pattern(),
|
||||
"DEVPLACE_TUNNEL_PORT_PATTERN": naming.port_pattern(),
|
||||
"DEVPLACE_TUNNEL_MANIFEST": f"{WORKSPACE_MOUNT}/.devplace/tunnels.json",
|
||||
"DEVPLACE_TUNNEL_MAX": str(limits.max_tunnels),
|
||||
"VSCODE_PROXY_URI": naming.proxy_uri_template(name),
|
||||
"DEVPLACE_EDITOR": "code-server",
|
||||
"DEVPLACE_EDITOR_PORT": str(editor_port),
|
||||
"DEVPLACE_EDITOR_URL": (
|
||||
f"{workspace_url}" if workspace_url else ""
|
||||
),
|
||||
"VSCODE_CLI_DATA_DIR": f"{WORKSPACE_STATE_MOUNT}/cli",
|
||||
"DEVPLACE_QUOTA_DISK_MB": str(limits.disk_quota_mb),
|
||||
"DEVPLACE_QUOTA_DISK_USED_MB": str(
|
||||
int(instance.get("disk_bytes") or 0) // (1024 * 1024)
|
||||
),
|
||||
"DEVPLACE_QUOTA_EGRESS_MB": str(limits.egress_quota_mb),
|
||||
"DEVPLACE_IDLE_STOP_MINUTES": str(limits.idle_stop_minutes),
|
||||
"DEVPLACE_RETENTION_DAYS": str(limits.retention_days),
|
||||
"DEVPLACE_CPU_LIMIT": str(instance.get("cpu_limit") or ""),
|
||||
"DEVPLACE_MEM_LIMIT": str(instance.get("mem_limit") or ""),
|
||||
}
|
||||
if gallery:
|
||||
env["EXTENSIONS_GALLERY"] = gallery
|
||||
return {key: ("" if value is None else str(value)) for key, value in env.items()}
|
||||
|
||||
|
||||
EDITOR_DEFAULT_PORT = 8443
|
||||
|
||||
|
||||
def editor_command(instance: dict) -> list[str]:
|
||||
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
|
||||
return [
|
||||
"code-server",
|
||||
"--bind-addr",
|
||||
f"0.0.0.0:{port}",
|
||||
"--auth",
|
||||
"none",
|
||||
"--disable-telemetry",
|
||||
"--disable-update-check",
|
||||
"--user-data-dir",
|
||||
f"{WORKSPACE_STATE_MOUNT}/data",
|
||||
"--extensions-dir",
|
||||
f"{WORKSPACE_STATE_MOUNT}/extensions",
|
||||
WORKSPACE_MOUNT,
|
||||
]
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
@@ -432,6 +536,10 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
for p in json.loads(instance.get("ports_json") or "[]")
|
||||
]
|
||||
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
||||
if instance.get("is_workspace"):
|
||||
state_dir = config.WORKSPACE_STATE_DIR / instance["uid"]
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
mounts.append(Mount(str(state_dir), WORKSPACE_STATE_MOUNT, "rw"))
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(
|
||||
@@ -439,7 +547,9 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
)
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
||||
if instance.get("is_workspace") and int(instance.get("editor_port") or 0):
|
||||
command = editor_command(instance)
|
||||
elif language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
||||
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
||||
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
||||
elif boot:
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Awaitable, Callable, Optional
|
||||
LogCallback = Callable[[str], Awaitable[None]]
|
||||
|
||||
WORKSPACE_MOUNT = "/app"
|
||||
WORKSPACE_STATE_MOUNT = "/home/pravda/.workspace-state"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
}
|
||||
|
||||
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
DEFAULT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def forward_headers(request: Request, prefix: str = "") -> dict:
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
|
||||
if prefix:
|
||||
headers["X-Forwarded-Prefix"] = prefix
|
||||
headers["X-Script-Name"] = prefix
|
||||
headers["X-Forwarded-Host"] = request.headers.get(
|
||||
"host", request.url.hostname or ""
|
||||
)
|
||||
headers["X-Forwarded-Proto"] = request.headers.get(
|
||||
"x-forwarded-proto", request.url.scheme
|
||||
)
|
||||
headers["Accept-Encoding"] = "identity"
|
||||
return headers
|
||||
|
||||
|
||||
def inject_base(body: bytes, prefix: str) -> bytes:
|
||||
lowered = body.lower()
|
||||
if b"<base" in lowered:
|
||||
return body
|
||||
tag = f'<base href="{prefix}/">'.encode()
|
||||
head = lowered.find(b"<head")
|
||||
anchor = (
|
||||
lowered.find(b">", head)
|
||||
if head != -1
|
||||
else lowered.find(b">", lowered.find(b"<html"))
|
||||
)
|
||||
if anchor == -1:
|
||||
return tag + body
|
||||
return body[: anchor + 1] + tag + body[anchor + 1 :]
|
||||
|
||||
|
||||
def rewrite_location(value: str, prefix: str) -> str:
|
||||
if not prefix:
|
||||
return value
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return prefix + value
|
||||
return value
|
||||
|
||||
|
||||
async def proxy_http(
|
||||
request: Request,
|
||||
host: str,
|
||||
port: int,
|
||||
path: str,
|
||||
*,
|
||||
prefix: str = "",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
rewrite_html: bool = True,
|
||||
) -> Response:
|
||||
url = f"http://{host}:{port}/{path}"
|
||||
headers = forward_headers(request, prefix)
|
||||
body = await request.body()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout, follow_redirects=False
|
||||
) as client:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
url,
|
||||
params=request.query_params,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
except httpx.HTTPError as error:
|
||||
return Response(f"upstream error: {error}", status_code=502)
|
||||
out_headers = {
|
||||
k: v
|
||||
for k, v in upstream.headers.items()
|
||||
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
|
||||
}
|
||||
if "location" in out_headers:
|
||||
out_headers["location"] = rewrite_location(out_headers["location"], prefix)
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
content = upstream.content
|
||||
if prefix and rewrite_html and "text/html" in content_type.lower():
|
||||
content = inject_base(content, prefix)
|
||||
response = Response(
|
||||
content=content,
|
||||
status_code=upstream.status_code,
|
||||
headers=out_headers,
|
||||
media_type=content_type or None,
|
||||
)
|
||||
for cookie in upstream.headers.get_list("set-cookie"):
|
||||
response.headers.append("set-cookie", cookie)
|
||||
return response
|
||||
|
||||
|
||||
async def proxy_ws(
|
||||
websocket: WebSocket, host: str, port: int, path: str, *, accepted: bool = False
|
||||
) -> None:
|
||||
upstream_url = f"ws://{host}:{port}/{path}"
|
||||
if websocket.url.query:
|
||||
upstream_url += f"?{websocket.url.query}"
|
||||
if not accepted:
|
||||
await websocket.accept()
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url, open_timeout=10, max_size=None
|
||||
) as upstream:
|
||||
await pump(websocket, upstream)
|
||||
except Exception as error:
|
||||
logger.debug("ws proxy to %s:%s failed: %s", host, port, error)
|
||||
try:
|
||||
await websocket.close(code=1011)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def pump(client_ws: WebSocket, upstream) -> None:
|
||||
async def client_to_upstream():
|
||||
try:
|
||||
while True:
|
||||
message = await client_ws.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message.get("text") is not None:
|
||||
await upstream.send(message["text"])
|
||||
elif message.get("bytes") is not None:
|
||||
await upstream.send(message["bytes"])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await upstream.close()
|
||||
|
||||
async def upstream_to_client():
|
||||
try:
|
||||
async for message in upstream:
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
await client_ws.send_bytes(bytes(message))
|
||||
else:
|
||||
await client_ws.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await client_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
||||
@@ -109,6 +109,29 @@ def update_instance(uid: str, changes: dict) -> None:
|
||||
get_table("instances").update({"uid": uid, "updated_at": now(), **changes}, ["uid"])
|
||||
|
||||
|
||||
def record_activity(
|
||||
uid: str, seen_at: str, egress_bytes: int = 0, requests: int = 0
|
||||
) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
if not _exists("instances"):
|
||||
return
|
||||
sql = (
|
||||
"UPDATE instances SET last_active_at = :seen_at, "
|
||||
"egress_bytes = COALESCE(egress_bytes, 0) + :egress, "
|
||||
"request_count = COALESCE(request_count, 0) + :requests "
|
||||
"WHERE uid = :uid AND deleted_at IS NULL"
|
||||
)
|
||||
params = {
|
||||
"seen_at": seen_at,
|
||||
"egress": max(0, egress_bytes),
|
||||
"requests": max(0, requests),
|
||||
"uid": uid,
|
||||
}
|
||||
with db:
|
||||
db.executable.execute(text(sql), params)
|
||||
|
||||
|
||||
def delete_instance(uid: str, deleted_by: str = "system") -> None:
|
||||
get_table("instances").update(
|
||||
{"uid": uid, "deleted_at": now(), "deleted_by": deleted_by}, ["uid"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import flags, naming, provision, quota, tunnels
|
||||
|
||||
__all__ = ["flags", "naming", "provision", "quota", "tunnels"]
|
||||
@@ -0,0 +1,134 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
FLAGS_TABLE = "workspace_flags"
|
||||
|
||||
SEVERITIES = ("info", "warn", "critical")
|
||||
STATUSES = ("open", "acknowledged", "resolved", "dismissed")
|
||||
|
||||
KIND_CPU = "cpu_sustained"
|
||||
KIND_EGRESS = "egress_spike"
|
||||
KIND_REQUESTS = "request_rate"
|
||||
KIND_ERRORS = "error_ratio"
|
||||
KIND_DISK = "disk_growth"
|
||||
KIND_TUNNEL_CHURN = "tunnel_churn"
|
||||
KIND_MANUAL = "manual"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def open_flag(instance_uid: str, kind: str) -> dict | None:
|
||||
return get_table(FLAGS_TABLE).find_one(
|
||||
instance_uid=instance_uid, kind=kind, status="open", deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
def raise_flag(
|
||||
instance: dict,
|
||||
kind: str,
|
||||
severity: str = "warn",
|
||||
detail: str = "",
|
||||
metric_value: float = 0.0,
|
||||
threshold: float = 0.0,
|
||||
) -> dict | None:
|
||||
if severity not in SEVERITIES:
|
||||
severity = "warn"
|
||||
instance_uid = instance.get("uid", "")
|
||||
if not instance_uid or not kind:
|
||||
return None
|
||||
table = get_table(FLAGS_TABLE)
|
||||
existing = open_flag(instance_uid, kind)
|
||||
if existing:
|
||||
table.update(
|
||||
{
|
||||
"uid": existing["uid"],
|
||||
"severity": severity,
|
||||
"detail": detail,
|
||||
"metric_value": float(metric_value),
|
||||
"threshold": float(threshold),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
return table.find_one(uid=existing["uid"])
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"instance_uid": instance_uid,
|
||||
"user_uid": instance.get("workspace_owner_uid", ""),
|
||||
"kind": kind,
|
||||
"severity": severity,
|
||||
"detail": detail,
|
||||
"metric_value": float(metric_value),
|
||||
"threshold": float(threshold),
|
||||
"status": "open",
|
||||
"resolved_by": "",
|
||||
"resolved_at": "",
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return table.find_one(uid=uid)
|
||||
|
||||
|
||||
def clear_flag(instance_uid: str, kind: str, resolved_by: str = "system") -> bool:
|
||||
existing = open_flag(instance_uid, kind)
|
||||
if not existing:
|
||||
return False
|
||||
get_table(FLAGS_TABLE).update(
|
||||
{
|
||||
"uid": existing["uid"],
|
||||
"status": "resolved",
|
||||
"resolved_by": resolved_by,
|
||||
"resolved_at": _now(),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def set_status(uid: str, status: str, actor_uid: str) -> bool:
|
||||
if status not in STATUSES:
|
||||
return False
|
||||
table = get_table(FLAGS_TABLE)
|
||||
row = table.find_one(uid=uid, deleted_at=None)
|
||||
if not row:
|
||||
return False
|
||||
changes = {"uid": uid, "status": status, "updated_at": _now()}
|
||||
if status in ("resolved", "dismissed"):
|
||||
changes["resolved_by"] = actor_uid
|
||||
changes["resolved_at"] = _now()
|
||||
table.update(changes, ["uid"])
|
||||
return True
|
||||
|
||||
|
||||
def list_flags(
|
||||
instance_uid: str = "", user_uid: str = "", status: str = "open"
|
||||
) -> list[dict]:
|
||||
filters: dict[str, object] = {"deleted_at": None}
|
||||
if instance_uid:
|
||||
filters["instance_uid"] = instance_uid
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
if status:
|
||||
filters["status"] = status
|
||||
return list(get_table(FLAGS_TABLE).find(order_by=["-created_at"], **filters))
|
||||
|
||||
|
||||
def has_critical(instance_uid: str) -> bool:
|
||||
for row in list_flags(instance_uid=instance_uid):
|
||||
if row.get("severity") == "critical":
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from faker import Faker
|
||||
|
||||
from devplacepy.config import WORKSPACE_TUNNEL_DOMAIN
|
||||
from devplacepy.database import get_setting, get_table
|
||||
|
||||
LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$")
|
||||
MAX_ATTEMPTS = 12
|
||||
|
||||
_faker = Faker()
|
||||
|
||||
|
||||
def domain() -> str:
|
||||
return get_setting("workspace_tunnel_domain", WORKSPACE_TUNNEL_DOMAIN).strip(".")
|
||||
|
||||
|
||||
def host_pattern() -> str:
|
||||
return get_setting("workspace_hostname_pattern", "{name}.{domain}")
|
||||
|
||||
|
||||
def port_pattern() -> str:
|
||||
return get_setting("workspace_port_hostname_pattern", "{port}-{name}.{domain}")
|
||||
|
||||
|
||||
def is_valid_label(value: str) -> bool:
|
||||
return bool(value) and bool(LABEL.match(value))
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return cleaned[:49].strip("-")
|
||||
|
||||
|
||||
def candidate(index: int) -> str:
|
||||
words = [_slugify(_faker.word()) for _ in range(2)]
|
||||
words = [word for word in words if word]
|
||||
if not words:
|
||||
words = ["workspace"]
|
||||
name = "-".join(words)
|
||||
if index:
|
||||
name = f"{name}-{index}"
|
||||
return name if is_valid_label(name) else "workspace"
|
||||
|
||||
|
||||
def taken(name: str) -> bool:
|
||||
return bool(get_table("instances").find_one(tunnel_name=name, deleted_at=None))
|
||||
|
||||
|
||||
def generate(fallback_uid: str = "") -> str:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
name = candidate(attempt)
|
||||
if not taken(name):
|
||||
return name
|
||||
tail = (fallback_uid or "").replace("-", "")[-8:]
|
||||
return f"workspace-{tail}" if tail else "workspace"
|
||||
|
||||
|
||||
def hostname_for(name: str, port: int = 0) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
pattern = port_pattern() if port else host_pattern()
|
||||
return pattern.format(name=name, domain=domain(), port=port)
|
||||
|
||||
|
||||
def proxy_uri_template(name: str) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return "https://" + port_pattern().format(
|
||||
name=name, domain=domain(), port="{{port}}"
|
||||
)
|
||||
|
||||
|
||||
def is_tunnel_host(host: str) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
bare = host.split(":", 1)[0].lower().rstrip(".")
|
||||
suffix = "." + domain().lower()
|
||||
return bare.endswith(suffix)
|
||||
@@ -0,0 +1,188 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.containers import api, store
|
||||
|
||||
from . import flags, naming, quota, tunnels
|
||||
|
||||
MANIFEST_DIRECTORY = ".devplace"
|
||||
MANIFEST_NAME = "tunnels.json"
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_for_project(project_uid: str, owner_uid: str) -> dict | None:
|
||||
return get_table("instances").find_one(
|
||||
project_uid=project_uid,
|
||||
workspace_owner_uid=owner_uid,
|
||||
is_workspace=1,
|
||||
deleted_at=None,
|
||||
)
|
||||
|
||||
|
||||
def count_for_owner(owner_uid: str) -> int:
|
||||
return get_table("instances").count(
|
||||
workspace_owner_uid=owner_uid, is_workspace=1, deleted_at=None
|
||||
)
|
||||
|
||||
|
||||
async def ensure(project: dict, user: dict) -> dict:
|
||||
owner_uid = user["uid"]
|
||||
existing = find_for_project(project["uid"], owner_uid)
|
||||
if existing:
|
||||
return existing
|
||||
limits = quota.resolve(owner_uid)
|
||||
if limits.max_workspaces and count_for_owner(owner_uid) >= limits.max_workspaces:
|
||||
raise WorkspaceError(
|
||||
f"workspace limit reached ({limits.max_workspaces}); "
|
||||
"delete one before creating another"
|
||||
)
|
||||
instance = await api.create_instance(
|
||||
project,
|
||||
name=f"ws-{project.get('slug') or project['uid']}"[:64],
|
||||
actor=("user", owner_uid),
|
||||
ports=[f"{api.EDITOR_DEFAULT_PORT}"],
|
||||
)
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{
|
||||
"is_workspace": 1,
|
||||
"workspace_owner_uid": owner_uid,
|
||||
"editor_port": api.EDITOR_DEFAULT_PORT,
|
||||
"tunnel_name": naming.generate(instance["uid"]),
|
||||
"desired_state": "running",
|
||||
},
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def resume(instance: dict) -> dict:
|
||||
if instance.get("suspended_at"):
|
||||
raise WorkspaceError("this workspace is suspended; contact an administrator")
|
||||
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||
disk_quota = limits.disk_quota_bytes()
|
||||
if disk_quota and int(instance.get("disk_bytes") or 0) >= disk_quota:
|
||||
raise WorkspaceError(
|
||||
"disk quota reached; free space before starting this workspace again"
|
||||
)
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": "running", "idle_warned_at": "", "delete_warned_at": ""},
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def stop(instance: dict) -> dict:
|
||||
store.update_instance(instance["uid"], {"desired_state": "stopped"})
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def suspend(instance: dict, actor_uid: str, reason: str) -> dict:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{
|
||||
"desired_state": "stopped",
|
||||
"suspended_at": datetime.now(timezone.utc).isoformat(),
|
||||
"suspended_by": actor_uid,
|
||||
"flag_reason": reason,
|
||||
},
|
||||
)
|
||||
tunnels.suspend_for_instance(instance["uid"])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def unsuspend(instance: dict) -> dict:
|
||||
store.update_instance(
|
||||
instance["uid"], {"suspended_at": "", "suspended_by": "", "flag_reason": ""}
|
||||
)
|
||||
tunnels.resume_for_instance(instance["uid"])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def editor_target(instance: dict) -> tuple[str, int]:
|
||||
host, port = api.proxy_target(instance)
|
||||
return host, port
|
||||
|
||||
|
||||
def manifest_payload(instance: dict) -> dict:
|
||||
rows = tunnels.list_for_instance(instance["uid"])
|
||||
return {
|
||||
"workspace": {
|
||||
"uid": instance.get("uid", ""),
|
||||
"name": instance.get("name", ""),
|
||||
"tunnel_name": instance.get("tunnel_name", ""),
|
||||
"domain": naming.domain(),
|
||||
"project_uid": instance.get("project_uid", ""),
|
||||
},
|
||||
"tunnels": [
|
||||
{
|
||||
"label": row.get("label", ""),
|
||||
"hostname": row.get("hostname", ""),
|
||||
"url": f"https://{row.get('hostname', '')}",
|
||||
"container_port": int(row.get("container_port") or 0),
|
||||
"status": row.get("status", ""),
|
||||
"cert_status": row.get("cert_status", ""),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(instance: dict) -> None:
|
||||
workspace_dir = instance.get("workspace_dir")
|
||||
if not workspace_dir:
|
||||
return
|
||||
directory = Path(workspace_dir) / MANIFEST_DIRECTORY
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest_payload(instance), indent=2)
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def state_dir(instance: dict) -> Path:
|
||||
return config.WORKSPACE_STATE_DIR / instance["uid"]
|
||||
|
||||
|
||||
def view(instance: dict, viewer_is_admin: bool = False) -> dict:
|
||||
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||
disk_used = int(instance.get("disk_bytes") or 0)
|
||||
egress_used = int(instance.get("egress_bytes") or 0)
|
||||
return {
|
||||
"uid": instance.get("uid", ""),
|
||||
"name": instance.get("name", ""),
|
||||
"status": instance.get("status", ""),
|
||||
"desired_state": instance.get("desired_state", ""),
|
||||
"suspended": bool(instance.get("suspended_at")),
|
||||
"flag_reason": instance.get("flag_reason", ""),
|
||||
"tunnel_name": instance.get("tunnel_name", ""),
|
||||
"primary_url": (
|
||||
f"https://{naming.hostname_for(instance.get('tunnel_name', ''))}"
|
||||
if instance.get("tunnel_name")
|
||||
else ""
|
||||
),
|
||||
"last_active_at": instance.get("last_active_at", ""),
|
||||
"disk_bytes": disk_used,
|
||||
"disk_quota_mb": limits.disk_quota_mb,
|
||||
"disk_percent": quota.percent_used(disk_used, limits.disk_quota_bytes()),
|
||||
"egress_bytes": egress_used,
|
||||
"egress_quota_mb": limits.egress_quota_mb,
|
||||
"egress_percent": quota.percent_used(egress_used, limits.egress_quota_bytes()),
|
||||
"idle_stop_minutes": limits.idle_stop_minutes,
|
||||
"retention_days": limits.retention_days,
|
||||
"max_tunnels": limits.max_tunnels,
|
||||
"tunnels": tunnels.list_for_instance(instance["uid"]),
|
||||
"flags": flags.list_flags(instance_uid=instance["uid"]),
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.database import get_int_setting, get_table
|
||||
|
||||
RULES_TABLE = "workspace_quota_rules"
|
||||
|
||||
DEFAULTS: dict[str, int] = {
|
||||
"max_workspaces": 2,
|
||||
"max_tunnels": 5,
|
||||
"disk_quota_mb": 2048,
|
||||
"egress_quota_mb": 10240,
|
||||
"idle_warn_minutes": 45,
|
||||
"idle_stop_minutes": 60,
|
||||
"retention_days": 14,
|
||||
"purge_after_days": 7,
|
||||
"disk_warn_percent": 80,
|
||||
}
|
||||
|
||||
SETTING_KEYS: dict[str, str] = {
|
||||
"max_workspaces": "workspace_max_per_user",
|
||||
"max_tunnels": "workspace_max_tunnels",
|
||||
"disk_quota_mb": "workspace_disk_quota_mb",
|
||||
"egress_quota_mb": "workspace_egress_quota_mb",
|
||||
"idle_warn_minutes": "workspace_idle_warn_minutes",
|
||||
"idle_stop_minutes": "workspace_idle_stop_minutes",
|
||||
"retention_days": "workspace_retention_days",
|
||||
"purge_after_days": "workspace_purge_after_days",
|
||||
"disk_warn_percent": "workspace_disk_warn_percent",
|
||||
}
|
||||
|
||||
RULE_COLUMNS = (
|
||||
"max_workspaces",
|
||||
"max_tunnels",
|
||||
"disk_quota_mb",
|
||||
"egress_quota_mb",
|
||||
"idle_stop_minutes",
|
||||
"retention_days",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
max_workspaces: int
|
||||
max_tunnels: int
|
||||
disk_quota_mb: int
|
||||
egress_quota_mb: int
|
||||
idle_warn_minutes: int
|
||||
idle_stop_minutes: int
|
||||
retention_days: int
|
||||
purge_after_days: int
|
||||
disk_warn_percent: int
|
||||
|
||||
def disk_quota_bytes(self) -> int:
|
||||
return self.disk_quota_mb * 1024 * 1024
|
||||
|
||||
def egress_quota_bytes(self) -> int:
|
||||
return self.egress_quota_mb * 1024 * 1024
|
||||
|
||||
|
||||
def _global_value(key: str) -> int:
|
||||
return get_int_setting(SETTING_KEYS[key], DEFAULTS[key])
|
||||
|
||||
|
||||
def _rule_for(owner_kind: str, owner_id: str) -> dict | None:
|
||||
if not owner_id:
|
||||
return None
|
||||
table = get_table(RULES_TABLE)
|
||||
return table.find_one(owner_kind=owner_kind, owner_id=owner_id, deleted_at=None)
|
||||
|
||||
|
||||
def resolve(user_uid: str = "", instance: dict | None = None) -> Limits:
|
||||
rule = _rule_for("user", user_uid) if user_uid else None
|
||||
values: dict[str, int] = {}
|
||||
for key in DEFAULTS:
|
||||
value = _global_value(key)
|
||||
if rule and key in RULE_COLUMNS:
|
||||
override = rule.get(key)
|
||||
if override:
|
||||
value = int(override)
|
||||
if instance:
|
||||
instance_override = instance.get(f"workspace_{key}")
|
||||
if instance_override:
|
||||
value = int(instance_override)
|
||||
values[key] = max(0, value)
|
||||
if values["idle_warn_minutes"] >= values["idle_stop_minutes"]:
|
||||
values["idle_warn_minutes"] = max(1, values["idle_stop_minutes"] - 1)
|
||||
return Limits(**values)
|
||||
|
||||
|
||||
def percent_used(used: int, quota: int) -> int:
|
||||
if quota <= 0:
|
||||
return 0
|
||||
return min(100, int(used * 100 / quota))
|
||||
@@ -0,0 +1,154 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
from . import naming
|
||||
|
||||
TUNNELS_TABLE = "tunnels"
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_PROVISIONING = "provisioning"
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_SUSPENDED = "suspended"
|
||||
|
||||
SERVING_STATUSES = (STATUS_PROVISIONING, STATUS_ACTIVE)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _table():
|
||||
return get_table(TUNNELS_TABLE)
|
||||
|
||||
|
||||
def list_for_instance(instance_uid: str) -> list[dict]:
|
||||
return list(
|
||||
_table().find(
|
||||
instance_uid=instance_uid, deleted_at=None, order_by=["created_at"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_for_user(user_uid: str) -> list[dict]:
|
||||
return list(
|
||||
_table().find(user_uid=user_uid, deleted_at=None, order_by=["-created_at"])
|
||||
)
|
||||
|
||||
|
||||
def get(uid: str) -> dict | None:
|
||||
return _table().find_one(uid=uid, deleted_at=None)
|
||||
|
||||
|
||||
def by_hostname(hostname: str) -> dict | None:
|
||||
if not hostname:
|
||||
return None
|
||||
bare = hostname.split(":", 1)[0].lower().rstrip(".")
|
||||
return _table().find_one(hostname=bare, deleted_at=None)
|
||||
|
||||
|
||||
def count_for_instance(instance_uid: str) -> int:
|
||||
return _table().count(instance_uid=instance_uid, deleted_at=None)
|
||||
|
||||
|
||||
def create(
|
||||
instance: dict, label: str, container_port: int, user_uid: str
|
||||
) -> dict | None:
|
||||
name = instance.get("tunnel_name", "")
|
||||
if not name or container_port <= 0:
|
||||
return None
|
||||
hostname = naming.hostname_for(name, container_port)
|
||||
table = _table()
|
||||
revived = table.find_one(hostname=hostname)
|
||||
stamp = _now()
|
||||
if revived:
|
||||
table.update(
|
||||
{
|
||||
"uid": revived["uid"],
|
||||
"instance_uid": instance["uid"],
|
||||
"project_uid": instance.get("project_uid", ""),
|
||||
"user_uid": user_uid,
|
||||
"label": label or f"port {container_port}",
|
||||
"container_port": container_port,
|
||||
"desired_state": "present",
|
||||
"status": STATUS_PENDING,
|
||||
"last_error": "",
|
||||
"updated_at": stamp,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
return table.find_one(uid=revived["uid"])
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"instance_uid": instance["uid"],
|
||||
"project_uid": instance.get("project_uid", ""),
|
||||
"user_uid": user_uid,
|
||||
"hostname": hostname,
|
||||
"label": label or f"port {container_port}",
|
||||
"container_port": container_port,
|
||||
"desired_state": "present",
|
||||
"status": STATUS_PENDING,
|
||||
"cert_status": "",
|
||||
"cert_checked_at": "",
|
||||
"request_count": 0,
|
||||
"bytes_out": 0,
|
||||
"last_request_at": "",
|
||||
"last_error": "",
|
||||
"last_synced_at": "",
|
||||
"created_at": stamp,
|
||||
"updated_at": stamp,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return table.find_one(uid=uid)
|
||||
|
||||
|
||||
def update(uid: str, changes: dict) -> None:
|
||||
_table().update({"uid": uid, "updated_at": _now(), **changes}, ["uid"])
|
||||
|
||||
|
||||
def mark_absent(uid: str, actor_uid: str = "system") -> None:
|
||||
update(uid, {"desired_state": "absent"})
|
||||
|
||||
|
||||
def soft_delete(uid: str, actor_uid: str = "system") -> None:
|
||||
_table().update(
|
||||
{"uid": uid, "deleted_at": _now(), "deleted_by": actor_uid}, ["uid"]
|
||||
)
|
||||
|
||||
|
||||
def suspend_for_instance(instance_uid: str) -> None:
|
||||
for row in list_for_instance(instance_uid):
|
||||
update(row["uid"], {"status": STATUS_SUSPENDED})
|
||||
|
||||
|
||||
def resume_for_instance(instance_uid: str) -> None:
|
||||
for row in list_for_instance(instance_uid):
|
||||
if row.get("status") == STATUS_SUSPENDED:
|
||||
update(row["uid"], {"status": STATUS_PENDING})
|
||||
|
||||
|
||||
def record_hit(uid: str, byte_count: int) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
sql = (
|
||||
"UPDATE tunnels SET request_count = COALESCE(request_count, 0) + 1, "
|
||||
"bytes_out = COALESCE(bytes_out, 0) + :bytes, last_request_at = :seen "
|
||||
"WHERE uid = :uid AND deleted_at IS NULL"
|
||||
)
|
||||
with db:
|
||||
db.executable.execute(
|
||||
text(sql),
|
||||
{"bytes": max(0, byte_count), "seen": _now(), "uid": uid},
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import flags, quota, tunnels
|
||||
|
||||
DISK_SAMPLE_DEFAULT_MINUTES = 10
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _minutes_since(value: str | None) -> float | None:
|
||||
parsed = _parse(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
return (_now() - parsed).total_seconds() / 60.0
|
||||
|
||||
|
||||
def _directory_size(path: Path) -> int:
|
||||
total = 0
|
||||
stack = [path]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
try:
|
||||
with os.scandir(current) as entries:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
stack.append(Path(entry.path))
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
total += entry.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
class WorkspaceService(BaseService):
|
||||
title = "Dev Workspaces"
|
||||
description = (
|
||||
"Reconciles browser IDE workspaces: tunnel certificates, disk and egress "
|
||||
"metering, abuse flags, idle and retention lifecycle, and purge sweeps."
|
||||
)
|
||||
default_enabled = False
|
||||
min_interval = 5
|
||||
METRICS_SECONDS = 30
|
||||
|
||||
config_fields = [
|
||||
ConfigField("workspace_enabled", "Enabled", type="bool", default="0",
|
||||
group="General", help="Master switch for the workspace feature."),
|
||||
ConfigField("workspace_editor_version", "Editor version", default="",
|
||||
group="General",
|
||||
help="code-server release. Empty means the image default."),
|
||||
ConfigField("workspace_extensions_gallery", "Extensions gallery", type="text",
|
||||
default="", group="General",
|
||||
help="EXTENSIONS_GALLERY JSON. Empty means the default."),
|
||||
ConfigField("workspace_molohttp_base_url", "molohttp base URL", type="url",
|
||||
default="https://pravda.education/molohttp", group="molohttp"),
|
||||
ConfigField("workspace_molohttp_api_key", "molohttp API key", type="password",
|
||||
default="", group="molohttp", secret=True,
|
||||
help="Preferred credential. Sent as the x-api-key header."),
|
||||
ConfigField("workspace_molohttp_username", "molohttp username", default="",
|
||||
group="molohttp"),
|
||||
ConfigField("workspace_molohttp_password", "molohttp password",
|
||||
type="password", default="", group="molohttp", secret=True),
|
||||
ConfigField("workspace_tunnel_domain", "Tunnel domain",
|
||||
default=config.WORKSPACE_TUNNEL_DOMAIN, group="Tunnels"),
|
||||
ConfigField("workspace_hostname_pattern", "Hostname pattern",
|
||||
default="{name}.{domain}", group="Tunnels"),
|
||||
ConfigField("workspace_port_hostname_pattern", "Port hostname pattern",
|
||||
default="{port}-{name}.{domain}", group="Tunnels"),
|
||||
ConfigField("workspace_cert_mode", "Certificate mode", type="select",
|
||||
default="per_host", options=[{"value": "per_host", "label": "Per host"}, {"value": "wildcard", "label": "Wildcard"}],
|
||||
group="Tunnels"),
|
||||
ConfigField("workspace_acme_email", "ACME email", default="", group="Tunnels"),
|
||||
ConfigField("workspace_max_per_user", "Max workspaces per user", type="int",
|
||||
default="2", minimum=0, group="Quotas"),
|
||||
ConfigField("workspace_max_tunnels", "Max tunnels per workspace", type="int",
|
||||
default="5", minimum=0, group="Quotas"),
|
||||
ConfigField("workspace_disk_quota_mb", "Disk quota (MB)", type="int",
|
||||
default="2048", minimum=0, group="Quotas"),
|
||||
ConfigField("workspace_egress_quota_mb", "Egress quota (MB)", type="int",
|
||||
default="10240", minimum=0, group="Quotas"),
|
||||
ConfigField("workspace_disk_warn_percent", "Disk warn percent", type="int",
|
||||
default="80", minimum=1, maximum=100, group="Quotas"),
|
||||
ConfigField("workspace_idle_warn_minutes", "Idle warn (minutes)", type="int",
|
||||
default="45", minimum=1, group="Lifecycle"),
|
||||
ConfigField("workspace_idle_stop_minutes", "Idle stop (minutes)", type="int",
|
||||
default="60", minimum=1, group="Lifecycle"),
|
||||
ConfigField("workspace_retention_days", "Retention (days)", type="int",
|
||||
default="14", minimum=1, group="Lifecycle"),
|
||||
ConfigField("workspace_purge_after_days", "Purge after (days)", type="int",
|
||||
default="7", minimum=1, group="Lifecycle"),
|
||||
ConfigField("workspace_auto_stop", "Auto stop", type="select", default="auto",
|
||||
options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}], group="Automation"),
|
||||
ConfigField("workspace_auto_delete", "Auto delete", type="select",
|
||||
default="auto", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
|
||||
group="Automation"),
|
||||
ConfigField("workspace_auto_suspend", "Auto suspend", type="select",
|
||||
default="notify", options=[{"value": "off", "label": "Off"}, {"value": "notify", "label": "Notify"}, {"value": "auto", "label": "Auto"}],
|
||||
group="Automation"),
|
||||
ConfigField("workspace_auto_flag", "Auto flag", type="select", default="auto",
|
||||
options=[{"value": "off", "label": "Off"}, {"value": "flag", "label": "Flag"}, {"value": "auto", "label": "Auto"}], group="Automation"),
|
||||
ConfigField("workspace_flag_cpu_percent", "Flag CPU percent", type="int",
|
||||
default="95", minimum=1, maximum=100, group="Abuse"),
|
||||
ConfigField("workspace_flag_cpu_minutes", "Flag CPU minutes", type="int",
|
||||
default="120", minimum=1, group="Abuse"),
|
||||
ConfigField("workspace_flag_egress_mb_per_hour", "Flag egress MB/hour",
|
||||
type="int", default="2048", minimum=1, group="Abuse"),
|
||||
ConfigField("workspace_flag_request_rate", "Flag request rate", type="int",
|
||||
default="6000", minimum=1, group="Abuse"),
|
||||
ConfigField("workspace_disk_sample_minutes", "Disk sample (minutes)",
|
||||
type="int", default=str(DISK_SAMPLE_DEFAULT_MINUTES), minimum=1,
|
||||
group="Advanced"),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("workspace", interval_seconds=30)
|
||||
self._last_disk_sample = 0.0
|
||||
|
||||
def _workspaces(self) -> list[dict]:
|
||||
table = get_table("instances")
|
||||
return list(table.find(is_workspace=1, deleted_at=None))
|
||||
|
||||
async def run_once(self) -> None:
|
||||
cfg = self.get_config()
|
||||
if cfg.get("workspace_enabled") not in (True, "1", 1):
|
||||
return
|
||||
rows = self._workspaces()
|
||||
for phase in (
|
||||
self._sample_disk,
|
||||
self._evaluate_flags,
|
||||
self._advance_lifecycle,
|
||||
self._sweep_purge,
|
||||
):
|
||||
try:
|
||||
phase(rows, cfg)
|
||||
except Exception as error:
|
||||
self.log(f"{phase.__name__} failed: {error}")
|
||||
|
||||
def _sample_disk(self, rows: list[dict], cfg: dict) -> None:
|
||||
interval = int(cfg.get("workspace_disk_sample_minutes") or
|
||||
DISK_SAMPLE_DEFAULT_MINUTES) * 60
|
||||
if time.monotonic() - self._last_disk_sample < interval:
|
||||
return
|
||||
self._last_disk_sample = time.monotonic()
|
||||
for row in rows:
|
||||
workspace = config.CONTAINER_WORKSPACES_DIR / row.get("project_uid", "")
|
||||
state = config.WORKSPACE_STATE_DIR / row["uid"]
|
||||
total = _directory_size(workspace) + _directory_size(state)
|
||||
store.update_instance(
|
||||
row["uid"],
|
||||
{"disk_bytes": total, "disk_sampled_at": _now().isoformat()},
|
||||
)
|
||||
row["disk_bytes"] = total
|
||||
|
||||
def _evaluate_flags(self, rows: list[dict], cfg: dict) -> None:
|
||||
mode = cfg.get("workspace_auto_flag") or "auto"
|
||||
if mode == "off":
|
||||
return
|
||||
egress_ceiling = int(cfg.get("workspace_flag_egress_mb_per_hour") or 2048)
|
||||
egress_bytes = egress_ceiling * 1024 * 1024
|
||||
requests_ceiling = int(cfg.get("workspace_flag_request_rate") or 6000)
|
||||
for row in rows:
|
||||
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
|
||||
used = int(row.get("egress_bytes") or 0)
|
||||
if used > egress_bytes:
|
||||
flags.raise_flag(
|
||||
row,
|
||||
flags.KIND_EGRESS,
|
||||
"warn",
|
||||
f"egress {used} bytes exceeds hourly ceiling {egress_bytes}",
|
||||
float(used),
|
||||
float(egress_bytes),
|
||||
)
|
||||
if int(row.get("request_count") or 0) > requests_ceiling:
|
||||
flags.raise_flag(
|
||||
row,
|
||||
flags.KIND_REQUESTS,
|
||||
"warn",
|
||||
"request rate above configured ceiling",
|
||||
float(row.get("request_count") or 0),
|
||||
float(requests_ceiling),
|
||||
)
|
||||
disk_quota = limits.disk_quota_bytes()
|
||||
if disk_quota and int(row.get("disk_bytes") or 0) > disk_quota:
|
||||
flags.raise_flag(
|
||||
row,
|
||||
flags.KIND_DISK,
|
||||
"warn",
|
||||
"disk usage above quota",
|
||||
float(row.get("disk_bytes") or 0),
|
||||
float(disk_quota),
|
||||
)
|
||||
|
||||
def _advance_lifecycle(self, rows: list[dict], cfg: dict) -> None:
|
||||
from devplacepy.utils import create_notification
|
||||
|
||||
stop_mode = cfg.get("workspace_auto_stop") or "auto"
|
||||
delete_mode = cfg.get("workspace_auto_delete") or "auto"
|
||||
for row in rows:
|
||||
if row.get("suspended_at"):
|
||||
continue
|
||||
limits = quota.resolve(row.get("workspace_owner_uid", ""), row)
|
||||
owner = row.get("workspace_owner_uid", "")
|
||||
idle = _minutes_since(row.get("last_active_at"))
|
||||
if idle is None:
|
||||
continue
|
||||
running = row.get("status") == "running"
|
||||
if running and stop_mode != "off":
|
||||
if idle >= limits.idle_stop_minutes:
|
||||
if stop_mode == "auto":
|
||||
store.update_instance(
|
||||
row["uid"], {"desired_state": "stopped"}
|
||||
)
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {row.get('name', '')} stopped after "
|
||||
f"{limits.idle_stop_minutes} minutes idle.",
|
||||
row["uid"],
|
||||
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||
)
|
||||
store.update_instance(row["uid"], {"idle_warned_at": ""})
|
||||
elif idle >= limits.idle_warn_minutes and not row.get("idle_warned_at"):
|
||||
store.update_instance(
|
||||
row["uid"], {"idle_warned_at": _now().isoformat()}
|
||||
)
|
||||
if owner:
|
||||
remaining = int(limits.idle_stop_minutes - idle)
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {row.get('name', '')} stops in about "
|
||||
f"{remaining} minutes unless you use it.",
|
||||
row["uid"],
|
||||
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||
)
|
||||
if delete_mode == "off" or running:
|
||||
continue
|
||||
idle_days = idle / (60 * 24)
|
||||
warn_at = max(1, limits.retention_days - 3)
|
||||
if idle_days >= limits.retention_days:
|
||||
if delete_mode == "auto":
|
||||
store.delete_instance(row["uid"], "system")
|
||||
tunnels.suspend_for_instance(row["uid"])
|
||||
if owner:
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {row.get('name', '')} was removed after "
|
||||
f"{limits.retention_days} days idle. An administrator can "
|
||||
"restore it from Trash.",
|
||||
row["uid"],
|
||||
"/projects",
|
||||
)
|
||||
elif idle_days >= warn_at and not row.get("delete_warned_at"):
|
||||
store.update_instance(
|
||||
row["uid"], {"delete_warned_at": _now().isoformat()}
|
||||
)
|
||||
if owner:
|
||||
due = _now() + timedelta(days=limits.retention_days - idle_days)
|
||||
create_notification(
|
||||
owner,
|
||||
"workspace",
|
||||
f"Workspace {row.get('name', '')} is scheduled for deletion "
|
||||
f"on {due.strftime('%d/%m/%Y')} unless you use it.",
|
||||
row["uid"],
|
||||
f"/projects/{row.get('project_uid', '')}/workspace",
|
||||
)
|
||||
|
||||
def _sweep_purge(self, rows: list[dict], cfg: dict) -> None:
|
||||
limits = quota.resolve()
|
||||
cutoff = _now() - timedelta(days=limits.purge_after_days)
|
||||
table = get_table("instances")
|
||||
for row in table.find(is_workspace=1):
|
||||
deleted = _parse(row.get("deleted_at"))
|
||||
if not deleted or deleted > cutoff:
|
||||
continue
|
||||
for tunnel in tunnels.list_for_instance(row["uid"]):
|
||||
tunnels.soft_delete(tunnel["uid"], "system")
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
rows = self._workspaces()
|
||||
running = [r for r in rows if r.get("status") == "running"]
|
||||
suspended = [r for r in rows if r.get("suspended_at")]
|
||||
disk = sum(int(r.get("disk_bytes") or 0) for r in rows)
|
||||
egress = sum(int(r.get("egress_bytes") or 0) for r in rows)
|
||||
open_flags = flags.list_flags()
|
||||
tunnel_rows = list(get_table("tunnels").find(deleted_at=None))
|
||||
by_status: dict[str, int] = {}
|
||||
for row in tunnel_rows:
|
||||
key = row.get("status") or "unknown"
|
||||
by_status[key] = by_status.get(key, 0) + 1
|
||||
return {
|
||||
"stats": [
|
||||
{"label": "Workspaces", "value": len(rows)},
|
||||
{"label": "Running", "value": len(running)},
|
||||
{"label": "Suspended", "value": len(suspended)},
|
||||
{"label": "Disk MB", "value": disk // (1024 * 1024)},
|
||||
{"label": "Egress MB", "value": egress // (1024 * 1024)},
|
||||
{"label": "Open flags", "value": len(open_flags)},
|
||||
{"label": "Tunnels", "value": len(tunnel_rows)},
|
||||
],
|
||||
"table": {
|
||||
"columns": ["Tunnel status", "Count"],
|
||||
"rows": [[key, value] for key, value in sorted(by_status.items())],
|
||||
},
|
||||
}
|
||||
@@ -161,6 +161,30 @@ GATEWAY_ACTIONS: tuple[Action, ...] = (
|
||||
body("label", "Optional admin-facing note describing what this rule is for."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="gateway_quota_reset",
|
||||
method="POST",
|
||||
path="/admin/gateway/quota-resets",
|
||||
summary="Reset the rolling-24h AI gateway spend so a capped caller can call again (admin only)",
|
||||
description=(
|
||||
"Clears the counted spend for a scope without deleting any usage history, so the "
|
||||
"cost analytics on /admin/ai-usage stay intact. Scope it exactly like a quota rule: "
|
||||
"owner_kind (internal/key/user/admin/anonymous), a specific owner_id (user uid), and "
|
||||
"app_reference (the X-App-Reference header). Leaving all three blank resets every "
|
||||
"caller. A reset only clears spend recorded BEFORE it - new calls start counting "
|
||||
"again immediately against the same limit. Use this when an app is stuck on "
|
||||
"'AI gateway daily quota exceeded' and you want it running again without raising "
|
||||
"its cap."
|
||||
),
|
||||
handler="http",
|
||||
requires_admin=True,
|
||||
params=(
|
||||
body("owner_kind", "Role to scope by: internal, key, user, admin, or anonymous. Blank = every role."),
|
||||
body("owner_id", "Specific user uid to scope by. Blank = every caller."),
|
||||
body("app_reference", "App label to scope by (the X-App-Reference header). Blank = every app."),
|
||||
confirm(),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="gateway_quota_rule_delete",
|
||||
method="DELETE",
|
||||
|
||||
@@ -32,6 +32,11 @@ from .spec import Action, Catalog
|
||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||
|
||||
CONFIRM_REQUIRED = {
|
||||
"workspace_stop",
|
||||
"workspace_delete",
|
||||
"tunnel_delete",
|
||||
"workspace_flag_resolve",
|
||||
"workspace_suspend",
|
||||
"project_set_private",
|
||||
"project_set_readonly",
|
||||
"customize_set_css",
|
||||
@@ -59,6 +64,7 @@ CONFIRM_REQUIRED = {
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
"gateway_quota_rule_delete",
|
||||
"gateway_quota_reset",
|
||||
"email_account_delete",
|
||||
"email_delete_message",
|
||||
"game_prestige",
|
||||
@@ -299,8 +305,10 @@ class Dispatcher:
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||
from ..container import ContainerController
|
||||
from ..workspace import WorkspaceController
|
||||
|
||||
self._container = ContainerController(client, owner_id=owner_id)
|
||||
self._workspace = WorkspaceController(owner_kind, owner_id, admin=is_admin)
|
||||
from ..customization import CustomizationController
|
||||
|
||||
self._customization = CustomizationController(owner_kind, owner_id)
|
||||
@@ -492,6 +500,9 @@ class Dispatcher:
|
||||
if action.handler == "container":
|
||||
return await self._container.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "workspace":
|
||||
return await self._workspace.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "customization":
|
||||
return await self._customization.dispatch(action.name, arguments)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class Action:
|
||||
"chunks",
|
||||
"rsearch",
|
||||
"container",
|
||||
"workspace",
|
||||
"customization",
|
||||
"notification",
|
||||
"behavior",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
SLUG = arg("project_slug", "Project slug or uid that owns the workspace.", required=True)
|
||||
CONFIRM = arg(
|
||||
"confirm",
|
||||
"Set true only after showing the exact target to the user and getting agreement.",
|
||||
kind="boolean",
|
||||
)
|
||||
|
||||
WORKSPACE_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="workspace_open",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
summary=(
|
||||
"Open or resume the browser VS Code workspace for a project. "
|
||||
"Creates one if the user has none, otherwise resumes the existing one."
|
||||
),
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="workspace_status",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
summary=(
|
||||
"Read a workspace's state, disk and egress usage against quota, idle "
|
||||
"countdown, tunnels and any open moderation flags."
|
||||
),
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="workspace_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
summary="List the caller's workspaces. Administrators see every workspace.",
|
||||
params=(),
|
||||
),
|
||||
Action(
|
||||
name="workspace_stop",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
summary="Stop a running workspace. The files and tunnels are kept.",
|
||||
params=(SLUG, CONFIRM),
|
||||
),
|
||||
Action(
|
||||
name="workspace_delete",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
summary="Delete a workspace. Removable from admin Trash afterwards.",
|
||||
params=(SLUG, CONFIRM),
|
||||
),
|
||||
Action(
|
||||
name="tunnel_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
summary="List the public HTTPS tunnels published by a workspace.",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="tunnel_create",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
summary=(
|
||||
"Publish a container port on a public HTTPS hostname. The resulting URL "
|
||||
"is PUBLIC and unauthenticated - warn the user before creating one."
|
||||
),
|
||||
params=(
|
||||
SLUG,
|
||||
arg("container_port", "Port inside the container.", required=True, kind="integer"),
|
||||
arg("label", "Human label for the tunnel."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="tunnel_delete",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
summary="Remove a public tunnel. The URL stops serving immediately.",
|
||||
params=(SLUG, arg("tunnel_uid", "Tunnel uid.", required=True), CONFIRM),
|
||||
),
|
||||
Action(
|
||||
name="workspace_quota_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
summary=(
|
||||
"Read the effective workspace limits for the caller, or for any user "
|
||||
"when the caller is an administrator."
|
||||
),
|
||||
params=(arg("username", "Administrators only: whose quota to read."),),
|
||||
),
|
||||
Action(
|
||||
name="workspace_quota_set",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
summary="Set a per-user workspace quota rule. Zero means inherit the global value.",
|
||||
params=(
|
||||
arg("username", "User to apply the rule to.", required=True),
|
||||
arg("max_workspaces", "Workspace count limit.", kind="integer"),
|
||||
arg("max_tunnels", "Tunnel count limit.", kind="integer"),
|
||||
arg("disk_quota_mb", "Disk quota in MB.", kind="integer"),
|
||||
arg("egress_quota_mb", "Egress quota in MB.", kind="integer"),
|
||||
arg("idle_stop_minutes", "Idle stop window in minutes.", kind="integer"),
|
||||
arg("retention_days", "Retention in days.", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="workspace_flag_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
summary=(
|
||||
"List moderation flags. A member sees only flags on their own "
|
||||
"workspaces; an administrator sees every flag."
|
||||
),
|
||||
params=(arg("status", "Filter by status: open, resolved, dismissed."),),
|
||||
),
|
||||
Action(
|
||||
name="workspace_flag_raise",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
summary="Raise a moderation flag against a workspace.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("username", "Owner of the workspace.", required=True),
|
||||
arg("severity", "info, warn or critical."),
|
||||
arg("detail", "Why the flag was raised."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="workspace_flag_resolve",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
summary="Resolve or dismiss a moderation flag.",
|
||||
params=(
|
||||
arg("flag_uid", "Flag uid.", required=True),
|
||||
arg("status", "resolved or dismissed."),
|
||||
CONFIRM,
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="workspace_suspend",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
summary="Suspend a workspace. It stops serving; no data is destroyed.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("username", "Owner of the workspace.", required=True),
|
||||
arg("reason", "Reason shown to the owner.", required=True),
|
||||
CONFIRM,
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="workspace_unsuspend",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="workspace",
|
||||
requires_auth=True,
|
||||
requires_admin=True,
|
||||
summary="Lift a suspension and let the workspace serve again.",
|
||||
params=(SLUG, arg("username", "Owner of the workspace.", required=True)),
|
||||
),
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from .actions.notification_actions import NOTIFICATION_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .actions.telegram_actions import TELEGRAM_ACTIONS
|
||||
from .actions.workspace_actions import WORKSPACE_ACTIONS
|
||||
from .interaction.actions import INTERACTION_ACTIONS
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
@@ -36,6 +37,7 @@ CATALOG = Catalog(
|
||||
+ CHUNK_ACTIONS
|
||||
+ RSEARCH_ACTIONS
|
||||
+ CONTAINER_ACTIONS
|
||||
+ WORKSPACE_ACTIONS
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ BEHAVIOR_ACTIONS
|
||||
+ NOTIFICATION_ACTIONS
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import WorkspaceController
|
||||
|
||||
__all__ = ["WorkspaceController"]
|
||||
@@ -0,0 +1,243 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.workspace import (
|
||||
flags,
|
||||
provision,
|
||||
quota,
|
||||
tunnels,
|
||||
)
|
||||
from devplacepy.services.containers.workspace.provision import WorkspaceError
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
class WorkspaceController:
|
||||
def __init__(self, owner_kind: str, owner_id: str, admin: bool = False) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
self.owner_id = owner_id
|
||||
self.admin = admin
|
||||
|
||||
def _user(self) -> dict | None:
|
||||
if self.owner_kind != "user" or not self.owner_id:
|
||||
return None
|
||||
return get_table("users").find_one(uid=self.owner_id)
|
||||
|
||||
def _user_by_name(self, username: str) -> dict | None:
|
||||
if not username:
|
||||
return None
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
def _project(self, slug: str) -> dict | None:
|
||||
return resolve_by_slug(get_table("projects"), slug)
|
||||
|
||||
def _resolve(self, slug: str, owner_uid: str = "") -> dict:
|
||||
project = self._project(slug)
|
||||
if not project:
|
||||
raise WorkspaceError(f"project not found: {slug}")
|
||||
target = owner_uid or self.owner_id
|
||||
instance = provision.find_for_project(project["uid"], target)
|
||||
if not instance:
|
||||
raise WorkspaceError("no workspace exists for this project")
|
||||
return instance
|
||||
|
||||
async def dispatch(self, name: str, args: dict[str, Any]) -> Any:
|
||||
handler = getattr(self, f"_{name}", None)
|
||||
if handler is None:
|
||||
return {"error": f"unknown workspace action: {name}"}
|
||||
try:
|
||||
result = handler(args)
|
||||
if hasattr(result, "__await__"):
|
||||
return await result
|
||||
return result
|
||||
except WorkspaceError as error:
|
||||
return {"error": str(error)}
|
||||
|
||||
async def _workspace_open(self, args: dict) -> Any:
|
||||
user = self._user()
|
||||
if not user:
|
||||
raise WorkspaceError("sign in to use workspaces")
|
||||
project = self._project(args.get("project_slug", ""))
|
||||
if not project:
|
||||
raise WorkspaceError("project not found")
|
||||
instance = await provision.ensure(project, user)
|
||||
instance = provision.resume(instance)
|
||||
provision.write_manifest(instance)
|
||||
view = provision.view(instance)
|
||||
view["editor_url"] = (
|
||||
f"/projects/{project.get('slug') or project['uid']}/workspace"
|
||||
)
|
||||
return view
|
||||
|
||||
def _workspace_status(self, args: dict) -> Any:
|
||||
return provision.view(self._resolve(args.get("project_slug", "")))
|
||||
|
||||
def _workspace_list(self, args: dict) -> Any:
|
||||
table = get_table("instances")
|
||||
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
|
||||
if not self.admin:
|
||||
filters["workspace_owner_uid"] = self.owner_id
|
||||
return {
|
||||
"workspaces": [provision.view(row) for row in table.find(**filters)]
|
||||
}
|
||||
|
||||
def _workspace_stop(self, args: dict) -> Any:
|
||||
instance = self._resolve(args.get("project_slug", ""))
|
||||
provision.stop(instance)
|
||||
return {"ok": True, "status": "stopping", "uid": instance["uid"]}
|
||||
|
||||
def _workspace_delete(self, args: dict) -> Any:
|
||||
instance = self._resolve(args.get("project_slug", ""))
|
||||
for row in tunnels.list_for_instance(instance["uid"]):
|
||||
tunnels.soft_delete(row["uid"], self.owner_id)
|
||||
store.delete_instance(instance["uid"], self.owner_id)
|
||||
return {"ok": True, "deleted": instance["uid"]}
|
||||
|
||||
def _tunnel_list(self, args: dict) -> Any:
|
||||
instance = self._resolve(args.get("project_slug", ""))
|
||||
return {"tunnels": tunnels.list_for_instance(instance["uid"])}
|
||||
|
||||
def _tunnel_create(self, args: dict) -> Any:
|
||||
instance = self._resolve(args.get("project_slug", ""))
|
||||
port = int(args.get("container_port") or 0)
|
||||
if port <= 0:
|
||||
raise WorkspaceError("container_port is required")
|
||||
limits = quota.resolve(instance.get("workspace_owner_uid", ""), instance)
|
||||
if limits.max_tunnels and tunnels.count_for_instance(
|
||||
instance["uid"]
|
||||
) >= limits.max_tunnels:
|
||||
raise WorkspaceError(f"tunnel limit reached ({limits.max_tunnels})")
|
||||
row = tunnels.create(
|
||||
instance, args.get("label", ""), port, self.owner_id
|
||||
)
|
||||
if not row:
|
||||
raise WorkspaceError("could not create tunnel")
|
||||
provision.write_manifest(instance)
|
||||
return {
|
||||
"ok": True,
|
||||
"tunnel": row,
|
||||
"url": f"https://{row['hostname']}",
|
||||
"public": True,
|
||||
}
|
||||
|
||||
def _tunnel_delete(self, args: dict) -> Any:
|
||||
instance = self._resolve(args.get("project_slug", ""))
|
||||
uid = args.get("tunnel_uid", "")
|
||||
row = tunnels.get(uid)
|
||||
if not row or row.get("instance_uid") != instance["uid"]:
|
||||
raise WorkspaceError("tunnel not found on this workspace")
|
||||
tunnels.soft_delete(uid, self.owner_id)
|
||||
provision.write_manifest(instance)
|
||||
return {"ok": True, "deleted": uid}
|
||||
|
||||
def _workspace_quota_get(self, args: dict) -> Any:
|
||||
target = self.owner_id
|
||||
username = args.get("username", "")
|
||||
if username:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("only administrators may read another user's quota")
|
||||
other = self._user_by_name(username)
|
||||
if not other:
|
||||
raise WorkspaceError(f"user not found: {username}")
|
||||
target = other["uid"]
|
||||
limits = quota.resolve(target)
|
||||
return {
|
||||
"max_workspaces": limits.max_workspaces,
|
||||
"max_tunnels": limits.max_tunnels,
|
||||
"disk_quota_mb": limits.disk_quota_mb,
|
||||
"egress_quota_mb": limits.egress_quota_mb,
|
||||
"idle_stop_minutes": limits.idle_stop_minutes,
|
||||
"retention_days": limits.retention_days,
|
||||
"used_workspaces": provision.count_for_owner(target),
|
||||
}
|
||||
|
||||
def _workspace_quota_set(self, args: dict) -> Any:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("administrators only")
|
||||
other = self._user_by_name(args.get("username", ""))
|
||||
if not other:
|
||||
raise WorkspaceError("user not found")
|
||||
table = get_table(quota.RULES_TABLE)
|
||||
existing = table.find_one(
|
||||
owner_kind="user", owner_id=other["uid"], deleted_at=None
|
||||
)
|
||||
payload = {
|
||||
key: int(args.get(key) or 0)
|
||||
for key in quota.RULE_COLUMNS
|
||||
if args.get(key) is not None
|
||||
}
|
||||
if existing:
|
||||
table.update({"uid": existing["uid"], **payload}, ["uid"])
|
||||
uid = existing["uid"]
|
||||
else:
|
||||
uid = generate_uid()
|
||||
table.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"owner_kind": "user",
|
||||
"owner_id": other["uid"],
|
||||
"label": args.get("label", "") or other.get("username", ""),
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "rule_uid": uid, "applied": payload}
|
||||
|
||||
def _workspace_flag_list(self, args: dict) -> Any:
|
||||
status = args.get("status", "open")
|
||||
if self.admin:
|
||||
return {"flags": flags.list_flags(status=status)}
|
||||
return {"flags": flags.list_flags(user_uid=self.owner_id, status=status)}
|
||||
|
||||
def _workspace_flag_raise(self, args: dict) -> Any:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("administrators only")
|
||||
owner = self._user_by_name(args.get("username", ""))
|
||||
if not owner:
|
||||
raise WorkspaceError("user not found")
|
||||
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||
row = flags.raise_flag(
|
||||
instance,
|
||||
flags.KIND_MANUAL,
|
||||
args.get("severity", "warn"),
|
||||
args.get("detail", ""),
|
||||
)
|
||||
return {"ok": True, "flag": row}
|
||||
|
||||
def _workspace_flag_resolve(self, args: dict) -> Any:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("administrators only")
|
||||
status = args.get("status", "resolved")
|
||||
if not flags.set_status(args.get("flag_uid", ""), status, self.owner_id):
|
||||
raise WorkspaceError("flag not found or invalid status")
|
||||
return {"ok": True, "status": status}
|
||||
|
||||
def _workspace_suspend(self, args: dict) -> Any:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("administrators only")
|
||||
owner = self._user_by_name(args.get("username", ""))
|
||||
if not owner:
|
||||
raise WorkspaceError("user not found")
|
||||
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||
reason = args.get("reason", "")
|
||||
if not reason:
|
||||
raise WorkspaceError("a reason is required and is shown to the owner")
|
||||
provision.suspend(instance, self.owner_id, reason)
|
||||
return {"ok": True, "suspended": instance["uid"], "reason": reason}
|
||||
|
||||
def _workspace_unsuspend(self, args: dict) -> Any:
|
||||
if not self.admin:
|
||||
raise WorkspaceError("administrators only")
|
||||
owner = self._user_by_name(args.get("username", ""))
|
||||
if not owner:
|
||||
raise WorkspaceError("user not found")
|
||||
instance = self._resolve(args.get("project_slug", ""), owner["uid"])
|
||||
provision.unsuspend(instance)
|
||||
return {"ok": True, "resumed": instance["uid"]}
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.avatar import avatar_seed
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.devrant.avatar import avatar_payload
|
||||
from devplacepy.services.devrant.ids import to_unix
|
||||
|
||||
@@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
|
||||
return json.dumps(cleaned)
|
||||
|
||||
|
||||
def _rant_project(post: dict) -> dict | None:
|
||||
project_uid = post.get("project_uid")
|
||||
if not project_uid:
|
||||
return None
|
||||
project = get_table("projects").find_one(uid=project_uid)
|
||||
if not project:
|
||||
return None
|
||||
slug = project.get("slug") or project["uid"]
|
||||
return {
|
||||
"uid": project["uid"],
|
||||
"name": project.get("title") or project.get("name", ""),
|
||||
"slug": slug,
|
||||
"url": f"/projects/{slug}",
|
||||
}
|
||||
|
||||
|
||||
def rant_text(post: dict) -> str:
|
||||
title = (post.get("title") or "").strip()
|
||||
content = post.get("content") or ""
|
||||
@@ -56,6 +73,7 @@ def serialize_rant(
|
||||
author = authors.get(post["user_uid"]) or {}
|
||||
uid = post["uid"]
|
||||
username = author.get("username") or ""
|
||||
project = _rant_project(post)
|
||||
return {
|
||||
"id": int(post["id"]),
|
||||
"text": rant_text(post),
|
||||
@@ -75,6 +93,8 @@ def serialize_rant(
|
||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||
"project_uid": post.get("project_uid"),
|
||||
"project": project,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.database import get_table, get_int_setting, is_account_active
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.devrant.ids import as_int, now_unix
|
||||
|
||||
@@ -53,7 +53,7 @@ def resolve_user(params: dict) -> Optional[dict]:
|
||||
if as_int(token.get("expire_time")) < now_unix():
|
||||
return None
|
||||
user = get_table("users").find_one(uid=token.get("user_uid"))
|
||||
if not user or not user.get("is_active", True):
|
||||
if not user or not is_account_active(user):
|
||||
return None
|
||||
return user
|
||||
|
||||
@@ -73,7 +73,7 @@ def resolve_user_by_key(key: str) -> Optional[dict]:
|
||||
if expire and expire < now_unix():
|
||||
return None
|
||||
user = get_table("users").find_one(uid=token.get("user_uid"))
|
||||
if not user or not user.get("is_active", True):
|
||||
if not user or not is_account_active(user):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ def _screenshot_hue_buckets(screenshot_bytes: bytes) -> dict[int, int]:
|
||||
image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
|
||||
image = image.resize((64, 64))
|
||||
buckets: dict[int, int] = {}
|
||||
for r, g, b in image.getdata():
|
||||
for r, g, b in image.get_flattened_data():
|
||||
hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
|
||||
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
|
||||
continue
|
||||
|
||||
@@ -57,7 +57,7 @@ Client -> server: `{type:"send", receiver_uid, content, attachment_uids?, client
|
||||
|
||||
## Presence is NOT part of the messaging WS
|
||||
|
||||
Presence is handled entirely by the generic, cross-worker-correct mechanism documented in `devplacepy/services/CLAUDE.md` ("Online presence"): a single `users.last_seen` column, written by the `track_presence` HTTP middleware, read server-side at page load via `presence.is_online(user)`, and kept live client-side by `PresenceManager` (`static/js/PresenceManager.js`) subscribing any `[data-presence-uid]` element to the pub/sub topic `public.presence.{uid}`. The messages page's `#messages-presence` span carries `data-presence-uid`/`data-presence-last-seen` like every other avatar-presence site in the app - it is not messaging-specific code. An earlier design had WS-connect-based presence living in `message_hub` (`is_online`/`last_seen`, an `_announce_presence` helper, and a WS `presence` frame); that design was per-worker only and broke with more than one uvicorn worker, so it was removed outright. `message_hub` today tracks ONLY socket connections for message delivery - it has no presence state, and there is no `presence` frame anywhere in the current WS protocol (client or server). **Never re-implement WS-connect presence on this socket** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`, exactly as every other presence surface in the app does.
|
||||
Presence is handled entirely by the generic, cross-worker-correct mechanism documented in `devplacepy/services/CLAUDE.md` ("Online presence"): a single `users.last_seen` column, written by the `track_presence` HTTP middleware, read server-side at page load via `presence.is_online(user)`, and kept live client-side by `PresenceManager` (`static/js/PresenceManager.js`) subscribing any `[data-presence-uid]` element to the single pub/sub topic `public.presence.roster`. The messages page's `#messages-presence` span carries `data-presence-uid`/`data-presence-last-seen` like every other avatar-presence site in the app - it is not messaging-specific code. An earlier design had WS-connect-based presence living in `message_hub` (`is_online`/`last_seen`, an `_announce_presence` helper, and a WS `presence` frame); that design was per-worker only and broke with more than one uvicorn worker, so it was removed outright. `message_hub` today tracks ONLY socket connections for message delivery - it has no presence state, and there is no `presence` frame anywhere in the current WS protocol (client or server). **Never re-implement WS-connect presence on this socket** - reuse `presence.is_online`, the `public.presence.roster` topic, and `PresenceManager`, exactly as every other presence surface in the app does. `dp-chat` also no longer owns a private presence renderer or `PubSubClient` - that duplicate disagreed with the feed roster and was removed.
|
||||
|
||||
## Renderer integration (XSS control)
|
||||
|
||||
|
||||
@@ -73,7 +73,11 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su
|
||||
|
||||
**429 body never carries a dollar figure**, admin or not (`{"detail": "AI gateway daily quota exceeded"}`) - mirrors Devii's own over-limit WS message, which likewise never states a number. The admin-only services log line and the `ai.quota.exceeded` audit row (`GatewayService._audit_quota_exceeded`, reusing `usage.audit_actor_for`) do carry the spend/limit/matched-rule-uid, since those are admin-only surfaces.
|
||||
|
||||
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. CLI: `devplace gateway quota list|set|delete`.
|
||||
**Resetting the counted spend (`gateway_quota_resets`).** A cap is only lifted by *time* otherwise, so there is a reset that clears what has been counted **without deleting any ledger row** - `gateway_usage_ledger` is the cost-analytics source for `/admin/ai-usage`, so a reset must never truncate it. `quota.reset(QuotaResetIn, created_by=)` upserts one watermark row into `gateway_quota_resets` (same `ensure_tables()`/`"gateway_quota"` cache-version/hard-CRUD shape as the rules table, same two indexes) scoped by the SAME three nullable dimensions as a rule, and `quota.spent_24h` sums from `max(24h cutoff, reset_watermark(scope))`. A reset row applies to a queried scope when each of its non-null dimensions equals that scope's - so an all-null reset clears everyone, while a reset scoped to one app deliberately does NOT clear a broader per-user-all-apps scope (clearing a narrower window can only over-credit). Spend recorded after the reset counts again immediately against the same limit. `QuotaScopeIn` is the shared base holding the three dimensions and their validators; `QuotaRuleIn` and `QuotaResetIn` both extend it, so scope parsing exists once.
|
||||
|
||||
**The two AI quotas are separate systems and the reset surfaces must say so.** `/admin/ai-usage`'s *Reset all quotas* clears the Devii `devii_usage_ledger` AND now also stamps a global gateway watermark, because a caller hitting `429 AI gateway daily quota exceeded` had no reset at all before and the button looked global. *Reset guest quotas* stays Devii-only (guest gateway calls ride the shared internal key, so there is no per-guest gateway scope to clear).
|
||||
|
||||
**CRUD.** Admin JSON at `/admin/gateway/quota-rules` (`routers/admin/gateway_configs.py`, list returns each rule's live `spent_24h_usd` plus the Layer A defaults for context), audited `gateway.quota_rule.update`/`gateway.quota_rule.delete` (category `ai`, both already in `events.md`), rendered in the **Quota rules** section of `/admin/gateway` (`GatewayAdmin.js`, mirrors the providers/models CRUD tables). Devii tools `gateway_quota_rules`/`gateway_quota_rule_set`/`gateway_quota_rule_delete` (`requires_admin=True`, delete is `CONFIRM_REQUIRED`) proxy the same endpoints via `handler="http"`, same as the provider/model tools. Reset is `POST /admin/gateway/quota-resets` (same file, `_payload`/`ValidationError` shape as the rule CRUD), audited `gateway.quota.reset` (category `ai`), surfaced as a per-rule **Reset spend** button in the Quota rules table (`GatewayAdmin.js`), and exposed as the Devii tool `gateway_quota_reset` (`requires_admin=True`, in `CONFIRM_REQUIRED` with a declared `confirm` param, like the other quota-lifting admin resets). CLI: `devplace gateway quota list|set|delete|reset`.
|
||||
|
||||
## Image generation
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from devplacepy.database import bump_cache_version, db, get_table, sync_local_ca
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RULES_TABLE = "gateway_quota_rules"
|
||||
RESETS_TABLE = "gateway_quota_resets"
|
||||
CACHE_NAME = "gateway_quota"
|
||||
APP_REFERENCE_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,30}$")
|
||||
OWNER_KINDS = ("internal", "key", "user", "admin", "anonymous")
|
||||
@@ -62,22 +63,38 @@ def ensure_tables() -> None:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("gateway quota rule index creation failed: %s", exc)
|
||||
db.query(
|
||||
"CREATE TABLE IF NOT EXISTS "
|
||||
+ RESETS_TABLE
|
||||
+ " (id INTEGER PRIMARY KEY, uid TEXT, owner_kind TEXT, owner_id TEXT, "
|
||||
"app_reference TEXT, reset_at TEXT, created_by TEXT)"
|
||||
)
|
||||
try:
|
||||
db.query(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_quota_resets_uid ON "
|
||||
+ RESETS_TABLE
|
||||
+ " (uid)"
|
||||
)
|
||||
db.query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_gateway_quota_resets_lookup ON "
|
||||
+ RESETS_TABLE
|
||||
+ " (owner_kind, owner_id, app_reference)"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("gateway quota reset index creation failed: %s", exc)
|
||||
|
||||
|
||||
class QuotaRuleIn(BaseModel):
|
||||
class QuotaScopeIn(BaseModel):
|
||||
owner_kind: Optional[str] = None
|
||||
owner_id: Optional[str] = Field(default=None, max_length=64)
|
||||
app_reference: Optional[str] = Field(default=None, max_length=30)
|
||||
limit_usd: float = Field(default=0.0, ge=0)
|
||||
is_active: bool = True
|
||||
label: str = Field(default="", max_length=200)
|
||||
|
||||
@field_validator("owner_kind")
|
||||
@classmethod
|
||||
def _clean_owner_kind(cls, value: Optional[str]) -> Optional[str]:
|
||||
value = (value or "").strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip().lower()
|
||||
if value not in OWNER_KINDS:
|
||||
raise ValueError(f"owner_kind must be one of {', '.join(OWNER_KINDS)}")
|
||||
return value
|
||||
@@ -85,20 +102,24 @@ class QuotaRuleIn(BaseModel):
|
||||
@field_validator("owner_id")
|
||||
@classmethod
|
||||
def _clean_owner_id(cls, value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
return value.strip()
|
||||
return (value or "").strip() or None
|
||||
|
||||
@field_validator("app_reference")
|
||||
@classmethod
|
||||
def _clean_app_reference(cls, value: Optional[str]) -> Optional[str]:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not APP_REFERENCE_PATTERN.match(value):
|
||||
raise ValueError("app_reference must match ^[a-zA-Z0-9_.-]{1,30}$")
|
||||
return value
|
||||
|
||||
|
||||
class QuotaRuleIn(QuotaScopeIn):
|
||||
limit_usd: float = Field(default=0.0, ge=0)
|
||||
is_active: bool = True
|
||||
label: str = Field(default="", max_length=200)
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def _clean_label(cls, value: str) -> str:
|
||||
@@ -114,6 +135,10 @@ class QuotaRuleIn(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class QuotaResetIn(QuotaScopeIn):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuotaRule:
|
||||
uid: str
|
||||
@@ -250,6 +275,83 @@ class QuotaRuleStore:
|
||||
quota_rule_store = QuotaRuleStore()
|
||||
|
||||
|
||||
def _load_resets() -> list[dict]:
|
||||
sync_local_cache(CACHE_NAME, _QUOTA_CACHE)
|
||||
if "resets" not in _QUOTA_CACHE:
|
||||
resets: list[dict] = []
|
||||
try:
|
||||
if RESETS_TABLE in db.tables:
|
||||
for row in get_table(RESETS_TABLE).all():
|
||||
if row.get("reset_at"):
|
||||
resets.append(
|
||||
{
|
||||
"owner_kind": row.get("owner_kind") or None,
|
||||
"owner_id": row.get("owner_id") or None,
|
||||
"app_reference": row.get("app_reference") or None,
|
||||
"reset_at": str(row["reset_at"]),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("gateway quota reset load failed: %s", exc)
|
||||
_QUOTA_CACHE["resets"] = resets
|
||||
return _QUOTA_CACHE["resets"]
|
||||
|
||||
|
||||
def _reset_applies(reset: dict, scope: dict) -> bool:
|
||||
for field in ("owner_kind", "owner_id", "app_reference"):
|
||||
wanted = reset.get(field)
|
||||
if wanted is None:
|
||||
continue
|
||||
if scope.get(field) is None or scope[field] != wanted:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def reset_watermark(
|
||||
owner_kind: Optional[str], owner_id: Optional[str], app_reference: Optional[str]
|
||||
) -> str:
|
||||
scope = {
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"app_reference": app_reference,
|
||||
}
|
||||
stamps = [r["reset_at"] for r in _load_resets() if _reset_applies(r, scope)]
|
||||
return max(stamps) if stamps else ""
|
||||
|
||||
|
||||
def reset(payload: Optional[QuotaResetIn] = None, *, created_by: str = "") -> dict:
|
||||
ensure_tables()
|
||||
payload = payload or QuotaResetIn()
|
||||
table = get_table(RESETS_TABLE)
|
||||
scope = {
|
||||
"owner_kind": payload.owner_kind,
|
||||
"owner_id": payload.owner_id,
|
||||
"app_reference": payload.app_reference,
|
||||
}
|
||||
stamp = _now()
|
||||
existing = table.find_one(**scope)
|
||||
if existing:
|
||||
table.update({"id": existing["id"], "reset_at": stamp, "created_by": created_by}, ["id"])
|
||||
uid = existing.get("uid") or uuid.uuid4().hex
|
||||
else:
|
||||
uid = uuid.uuid4().hex
|
||||
table.insert({**scope, "uid": uid, "reset_at": stamp, "created_by": created_by})
|
||||
bump_cache_version(CACHE_NAME)
|
||||
_QUOTA_CACHE.clear()
|
||||
return {**scope, "uid": uid, "reset_at": stamp}
|
||||
|
||||
|
||||
def scope_label(scope: dict, fallback: str = "") -> str:
|
||||
parts = []
|
||||
if scope.get("owner_kind"):
|
||||
parts.append(f"role={scope['owner_kind']}")
|
||||
if scope.get("owner_id"):
|
||||
parts.append(f"user={scope['owner_id']}")
|
||||
if scope.get("app_reference"):
|
||||
parts.append(f"app={scope['app_reference']}")
|
||||
return ", ".join(parts) or fallback
|
||||
|
||||
|
||||
def default_limit(owner_kind: str, cfg: dict) -> float:
|
||||
field = _DEFAULT_FIELD_BY_KIND.get(owner_kind, FIELD_DEFAULT_USER)
|
||||
return float(cfg.get(field, 0.0) or 0.0)
|
||||
@@ -294,10 +396,10 @@ def spent_24h(
|
||||
|
||||
if GATEWAY_LEDGER not in db.tables:
|
||||
return 0.0
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||
watermark = reset_watermark(owner_kind, owner_id, app_reference)
|
||||
clauses = ["created_at >= :cutoff"]
|
||||
params: dict = {
|
||||
"cutoff": (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||
}
|
||||
params: dict = {"cutoff": max(cutoff, watermark) if watermark else cutoff}
|
||||
if owner_kind is not None:
|
||||
clauses.append("owner_kind = :owner_kind")
|
||||
params["owner_kind"] = owner_kind
|
||||
|
||||
@@ -10,6 +10,7 @@ from devplacepy.config import (
|
||||
PRESENCE_ONLINE_LIMIT,
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS,
|
||||
PRESENCE_TIMEOUT_SECONDS,
|
||||
PRESENCE_TRACK_LIMIT,
|
||||
PRESENCE_WRITE_SECONDS,
|
||||
)
|
||||
from devplacepy.database import get_online_users, set_last_seen
|
||||
@@ -39,13 +40,6 @@ def seconds_since(last_seen: Optional[str]) -> Optional[float]:
|
||||
return (datetime.now(timezone.utc) - seen).total_seconds()
|
||||
|
||||
|
||||
def is_online(user: Optional[dict]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
elapsed = seconds_since(user.get("last_seen"))
|
||||
return elapsed is not None and elapsed < PRESENCE_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def stays_online(elapsed: Optional[float], was_online: bool) -> bool:
|
||||
if elapsed is None:
|
||||
return False
|
||||
@@ -53,6 +47,12 @@ def stays_online(elapsed: Optional[float], was_online: bool) -> bool:
|
||||
return elapsed < (grace if was_online else PRESENCE_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def is_online(user: Optional[dict]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
return stays_online(seconds_since(user.get("last_seen")), was_online=False)
|
||||
|
||||
|
||||
def _cutoff_iso(seconds: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
|
||||
|
||||
@@ -69,7 +69,7 @@ def online_users(limit: int = PRESENCE_ONLINE_LIMIT) -> list:
|
||||
return sort_by_username(get_online_users(online_cutoff_iso(), limit))
|
||||
|
||||
|
||||
def online_candidates(limit: int = PRESENCE_ONLINE_LIMIT) -> list:
|
||||
def online_candidates(limit: int = PRESENCE_TRACK_LIMIT) -> list:
|
||||
return sort_by_username(
|
||||
get_online_users(
|
||||
_cutoff_iso(PRESENCE_TIMEOUT_SECONDS + PRESENCE_ONLINE_MARGIN_SECONDS), limit
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.config import PRESENCE_ONLINE_LIMIT
|
||||
from devplacepy.database import db, get_users_by_uids
|
||||
from devplacepy.database import db
|
||||
from devplacepy.database.awards import award_is_prominent
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.base import BaseService
|
||||
@@ -14,108 +13,73 @@ from devplacepy.services.pubsub import publish as pubsub_publish
|
||||
from devplacepy.services.pubsub.hub import pubsub
|
||||
|
||||
ROSTER_TOPIC = "public.presence.roster"
|
||||
TOPIC_PATTERN = re.compile(r"^public\.presence\.(?P<uid>[A-Za-z0-9_-]{1,128})$")
|
||||
|
||||
|
||||
def roster_payload(rows: list, online: set) -> dict:
|
||||
users = [row for row in rows if row["uid"] in online]
|
||||
listed = users[:PRESENCE_ONLINE_LIMIT]
|
||||
return {
|
||||
"count": len(listed),
|
||||
"online": [row["uid"] for row in users],
|
||||
"users": [
|
||||
{
|
||||
"uid": row["uid"],
|
||||
"username": row["username"],
|
||||
"avatar_seed": row.get("avatar_seed") or row["username"],
|
||||
"last_award_slug": row.get("last_award_slug") or "",
|
||||
"award_prominent": award_is_prominent(row),
|
||||
}
|
||||
for row in listed
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class PresenceRelayService(BaseService):
|
||||
title = "Presence relay"
|
||||
description = (
|
||||
"Single source of truth for LIVE online status. Each tick it recomputes one online "
|
||||
"Single source of truth for LIVE online status. Each tick it recomputes ONE online "
|
||||
"set with hysteresis - a user is online at the timeout window but only drops after an "
|
||||
"extra grace margin - and drives BOTH the per-user avatar dots (public.presence.{uid}) "
|
||||
"and the shared feed roster (public.presence.roster) from that one set, so they never "
|
||||
"disagree. It publishes ONLY on change (a real online<->offline transition or a first-seen "
|
||||
"subscriber), reads due users in one batched query per tick, and does nothing when idle. "
|
||||
"Runs on the service lock owner where every subscriber converges."
|
||||
"extra grace margin - and publishes that whole set on the single shared topic "
|
||||
"public.presence.roster, which drives BOTH the feed's Online now avatars and every "
|
||||
"avatar presence dot on every page. One set, one topic, one frame, so no two "
|
||||
"indicators can ever disagree. It publishes ONLY when the set of online users changes, "
|
||||
"reads the online population in one indexed query per tick, and does nothing when no "
|
||||
"one is subscribed. Runs on the service lock owner where every subscriber converges."
|
||||
)
|
||||
default_enabled = True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="presence_relay", interval_seconds=2)
|
||||
self._online: set[str] = set()
|
||||
self._published: dict[str, bool] = {}
|
||||
self._roster_uids: Optional[frozenset] = None
|
||||
self._published: Optional[frozenset] = None
|
||||
|
||||
def _subscribed(self) -> bool:
|
||||
return any(
|
||||
entry["subscribers"] and entry["topic"] == ROSTER_TOPIC
|
||||
for entry in pubsub.topics()
|
||||
)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if "users" not in db.tables:
|
||||
if "users" not in db.tables or not self._subscribed():
|
||||
self._published = None
|
||||
return
|
||||
dot_pairs: list[tuple[str, str]] = []
|
||||
roster_subscribed = False
|
||||
for entry in pubsub.topics():
|
||||
topic = entry["topic"]
|
||||
if not entry["subscribers"] or "*" in topic:
|
||||
continue
|
||||
if topic == ROSTER_TOPIC:
|
||||
roster_subscribed = True
|
||||
continue
|
||||
match = TOPIC_PATTERN.match(topic)
|
||||
if match is not None:
|
||||
dot_pairs.append((topic, match.group("uid")))
|
||||
|
||||
roster_rows = presence.online_candidates() if roster_subscribed else []
|
||||
rows: dict[str, dict] = {}
|
||||
dot_uids = {uid for _, uid in dot_pairs}
|
||||
if dot_uids:
|
||||
rows.update(get_users_by_uids(list(dot_uids)))
|
||||
for row in roster_rows:
|
||||
rows[row["uid"]] = row
|
||||
|
||||
rows = presence.online_candidates()
|
||||
prev = self._online
|
||||
online: set[str] = set()
|
||||
for uid, row in rows.items():
|
||||
elapsed = presence.seconds_since(row.get("last_seen"))
|
||||
if presence.stays_online(elapsed, uid in prev):
|
||||
online.add(uid)
|
||||
self._online = online
|
||||
|
||||
await self._publish_dots(dot_pairs, online, rows)
|
||||
await self._publish_roster(roster_subscribed, roster_rows, online)
|
||||
|
||||
async def _publish_dots(self, dot_pairs, online, rows) -> None:
|
||||
active = {topic for topic, _ in dot_pairs}
|
||||
self._published = {t: v for t, v in self._published.items() if t in active}
|
||||
published = 0
|
||||
for topic, uid in dot_pairs:
|
||||
is_on = uid in online
|
||||
if self._published.get(topic) == is_on:
|
||||
continue
|
||||
row = rows.get(uid) or {}
|
||||
published += await pubsub_publish(
|
||||
topic, {"online": is_on, "last_seen": row.get("last_seen")}
|
||||
self._online = {
|
||||
row["uid"]
|
||||
for row in rows
|
||||
if presence.stays_online(
|
||||
presence.seconds_since(row.get("last_seen")), row["uid"] in prev
|
||||
)
|
||||
self._published[topic] = is_on
|
||||
if published:
|
||||
self.log(f"pushed {published} presence change(s)")
|
||||
}
|
||||
|
||||
async def _publish_roster(self, subscribed, roster_rows, online) -> None:
|
||||
if not subscribed:
|
||||
self._roster_uids = None
|
||||
uids = frozenset(self._online)
|
||||
if uids == self._published:
|
||||
return
|
||||
users = [row for row in roster_rows if row["uid"] in online][:PRESENCE_ONLINE_LIMIT]
|
||||
uids = frozenset(row["uid"] for row in users)
|
||||
if uids == self._roster_uids:
|
||||
return
|
||||
self._roster_uids = uids
|
||||
await pubsub_publish(
|
||||
ROSTER_TOPIC,
|
||||
{
|
||||
"count": len(users),
|
||||
"users": [
|
||||
{
|
||||
"uid": row["uid"],
|
||||
"username": row["username"],
|
||||
"avatar_seed": row.get("avatar_seed") or row["username"],
|
||||
"last_award_slug": row.get("last_award_slug") or "",
|
||||
"award_prominent": award_is_prominent(row),
|
||||
}
|
||||
for row in users
|
||||
],
|
||||
},
|
||||
)
|
||||
self.log(f"roster changed: {len(users)} online")
|
||||
self._published = uids
|
||||
await pubsub_publish(ROSTER_TOPIC, roster_payload(rows, self._online))
|
||||
self.log(f"roster changed: {len(uids)} online")
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
return {
|
||||
"online": len(self._online),
|
||||
"tracked_dots": len(self._published),
|
||||
}
|
||||
return {"online": len(self._online)}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.push.service import (
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
RETENTION_KEY,
|
||||
PushService,
|
||||
)
|
||||
|
||||
__all__ = ["DEFAULT_RETENTION_DAYS", "PushService", "RETENTION_KEY"]
|
||||
@@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.push import providers, store
|
||||
from devplacepy.push.delivery import (
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_TIMEOUT_SECONDS,
|
||||
MIN_TIMEOUT_SECONDS,
|
||||
TIMEOUT_KEY,
|
||||
)
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RETENTION_KEY = "push_dead_retention_days"
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
class PushService(BaseService):
|
||||
title = "Push notifications"
|
||||
description = (
|
||||
"Configures the push providers and prunes dead subscriptions. Delivery is "
|
||||
"independent of this service and continues while it is stopped."
|
||||
)
|
||||
details = (
|
||||
"Notifications reach a user through every provider they have a live subscription "
|
||||
"for. A provider delivers only while its own Enabled toggle is on and its "
|
||||
"configuration is complete, so an unconfigured provider is inert."
|
||||
)
|
||||
default_enabled = True
|
||||
min_interval = 3600
|
||||
METRICS_SECONDS = 300
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
RETENTION_KEY,
|
||||
"Dead subscription retention (days)",
|
||||
type="int",
|
||||
default=DEFAULT_RETENTION_DAYS,
|
||||
minimum=0,
|
||||
help="Subscriptions the push services rejected as gone are removed after this many days. 0 disables pruning.",
|
||||
group="General",
|
||||
),
|
||||
ConfigField(
|
||||
TIMEOUT_KEY,
|
||||
"Delivery timeout (seconds)",
|
||||
type="int",
|
||||
default=DEFAULT_TIMEOUT_SECONDS,
|
||||
minimum=MIN_TIMEOUT_SECONDS,
|
||||
maximum=MAX_TIMEOUT_SECONDS,
|
||||
help="Per request timeout used for every push provider.",
|
||||
group="General",
|
||||
),
|
||||
*providers.admin_fields(),
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("push", interval_seconds=86400)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
days = get_int_setting(RETENTION_KEY, DEFAULT_RETENTION_DAYS)
|
||||
if days <= 0:
|
||||
self.log("Retention disabled (0 days); nothing pruned")
|
||||
return
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
removed = store.prune(cutoff)
|
||||
self.log(f"Pruned {removed} dead push subscriptions older than {days}d")
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
totals = store.counts()
|
||||
metrics = {"dead": totals.get("dead", 0)}
|
||||
for provider in providers.PROVIDERS.values():
|
||||
metrics[f"{provider.name}_active"] = totals.get(provider.name, 0)
|
||||
metrics[f"{provider.name}_ready"] = (
|
||||
1 if providers.is_active(provider) else 0
|
||||
)
|
||||
return metrics
|
||||
@@ -221,7 +221,7 @@ for `target_type == "quiz"`, soft-deleting questions, options, attempts and answ
|
||||
| SEO | `seo.quiz_schema`, `/quizzes` + the newest published quizzes in the sitemap; hub and detail `index,follow`, builder/player/results `noindex,follow`, a draft detail `noindex,nofollow` |
|
||||
| Notifications | `quiz_attempt`, fired once on the finish transition to the author, never per answer |
|
||||
| Gamification | `XP_QUIZ`/`XP_QUIZ_PUBLISH`/`XP_QUIZ_COMPLETE`, achievements `quiz_publish`/`quiz_complete`/`quiz_perfect`, the **Quizzes** badge group |
|
||||
| Audit | prefix `quiz` -> category `content`; eleven keys in `events.md` |
|
||||
| Audit | prefix `quiz` -> category `content`; thirteen keys in `events.md` |
|
||||
| Devii | `actions/catalog/quizzes.py`; `publish_quiz`, `delete_quiz` and `delete_quiz_question` are in `dispatcher.CONFIRM_REQUIRED` and each declares a `confirm` param |
|
||||
| CLI | `devplace quiz prune` - hard-deletes abandoned and expired attempts older than `QUIZ_ATTEMPT_RETENTION_DAYS`. Completed attempts are never pruned; they are the player's record |
|
||||
| Frontend | `dp-quiz-player`/`dp-quiz-builder` (light DOM, adopt the server-rendered markup), `static/css/quiz.css` (adds only what is new - the layout and card chrome come from `feed.css`/`sidebar.css`) |
|
||||
|
||||
@@ -42,7 +42,7 @@ def admin_shell_manager() -> ServiceManager:
|
||||
|
||||
Never supervised (no set_lock_owner/supervise call) - used only for
|
||||
describe()/config metadata/key derivation, all of which read or write
|
||||
through db_client to the shared service_state / site_settings tables.
|
||||
through the database layer to the shared service_state / site_settings tables.
|
||||
The owning Tier 3 process is the only one that ever ticks these services.
|
||||
"""
|
||||
global _SHELL_MANAGER
|
||||
|
||||
@@ -245,16 +245,24 @@
|
||||
min-width: 2.2em;
|
||||
}
|
||||
|
||||
.game-lb-name {
|
||||
.game-lb-name-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-lb-name {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.game-lb-name:hover {
|
||||
@@ -268,10 +276,14 @@
|
||||
}
|
||||
|
||||
.game-lb-title {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.game-lb-score {
|
||||
|
||||
@@ -55,6 +55,22 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -305,6 +305,10 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.project-devlog {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.project-section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
@@ -313,3 +317,4 @@
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.workspace-page {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.workspace-state {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.workspace-meters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.workspace-meter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-tunnel-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workspace-tunnel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workspace-badge {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.workspace-badge-active {
|
||||
background: var(--success);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-failed {
|
||||
background: var(--danger);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-badge-provisioning,
|
||||
.workspace-badge-pending {
|
||||
background: var(--warning);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.workspace-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workspace-tunnel-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workspace-suspended {
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.workspace-flag-critical {
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.workspace-flag-warn {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.workspace-flag-info {
|
||||
border-left: 3px solid var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workspace-meters {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,37 @@ export class Avatar {
|
||||
img.loading = "lazy";
|
||||
return img;
|
||||
}
|
||||
|
||||
static badgeElement(user, size = 32, sizeClass = "sm") {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "avatar-badge";
|
||||
const username = user.username || "";
|
||||
const img = document.createElement("img");
|
||||
img.src = `/avatar/multiavatar/${encodeURIComponent(user.avatar_seed || username)}?size=${size}`;
|
||||
img.className = `avatar-img avatar-${sizeClass}`;
|
||||
img.alt = username;
|
||||
img.loading = "lazy";
|
||||
badge.appendChild(img);
|
||||
badge.appendChild(Avatar.presenceDot(user));
|
||||
if (user.award_prominent && user.last_award_slug) {
|
||||
const award = document.createElement("img");
|
||||
award.className = "award-badge";
|
||||
award.src = `/awards/${encodeURIComponent(user.last_award_slug)}/64`;
|
||||
award.alt = "Latest award";
|
||||
award.loading = "lazy";
|
||||
badge.appendChild(award);
|
||||
}
|
||||
return badge;
|
||||
}
|
||||
|
||||
static presenceDot(user) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "presence-dot";
|
||||
dot.setAttribute("data-presence-uid", user.uid || "");
|
||||
dot.setAttribute("data-presence-last-seen", user.last_seen || "");
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
return dot;
|
||||
}
|
||||
}
|
||||
|
||||
window.Avatar = Avatar;
|
||||
|
||||
@@ -20,7 +20,7 @@ Self-contained, presentational UI is built as custom elements with the `dp-` pre
|
||||
|
||||
**Prompt seeding on the shared Devii opener (`data-devii-prompt`).** `DeviiTerminal.bindTriggers` binds every `[data-devii-open]` element; it now reads `trigger.dataset.deviiPrompt` and passes it to `DeviiTerminal.open(prompt)`, which calls `this.element.open()` and then `devii-terminal.prefill(text)` (sets `this.input.value`, moves the caret to the end, focuses). **It never auto-sends** - the member reads the request and presses Enter, keeping the assistant's first action explicitly user-initiated. This is a platform-wide capability available to any page, not a quiz-only shim: add `data-devii-prompt="..."` beside `data-devii-open` and the terminal opens pre-filled.
|
||||
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it, and the conversation list it builds live uses the shared `Avatar.badgeElement(user)` rather than hand-rolled dot markup. In `mode="embed"`, where no page-global instance may exist, it constructs a root-scoped `PresenceManager` instead of re-implementing presence - `dp-chat` owns no presence logic of its own. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
|
||||
`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.
|
||||
|
||||
@@ -48,6 +48,7 @@ Detail on each utility:
|
||||
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
|
||||
- **`EmojiPickerElement` (`static/js/EmojiPickerElement.js`).** The single wrapper around the vendored `emoji-picker-element`: `EmojiPickerElement.load()` lazily imports the vendor module once (shared promise, failures swallowed) and `EmojiPickerElement.create(onSelect)` returns a configured `<emoji-picker>` (data source `static/vendor/emoji-picker-element/data.json`) that calls `onSelect(unicode)` on `emoji-click`. Both consumers use it: `EmojiPicker` (insert at cursor in a textarea) and `ReactionBar` (react with any emoji). Never build an `<emoji-picker>`, re-import the vendor module, or repeat the data-source path elsewhere.
|
||||
- **`FloatingWindow` / `WindowManager` (`static/js/components/`).** The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend `FloatingWindow`.
|
||||
- **`PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) and `Avatar` (`static/js/Avatar.js`).** The single online-status renderer and the single avatar-markup builder. `PresenceManager` makes ONE subscription to `public.presence.roster` and drives EVERY `[data-presence-uid]` element from the pushed online uid set - there is no client-side clock and no expiry timer, so nothing can disagree with the feed's Online now panel. `Avatar.badgeElement(user)` builds the `.avatar-badge` + `.presence-dot` + award-badge trio (the JS twin of `templates/_presence_dot.html`) and is used by `OnlineUsers` and `dp-chat`. Never re-derive online status from a timestamp in feature code, and never hand-build a presence dot.
|
||||
- **`ScrollMemory` (`static/js/ScrollMemory.js`, `app.scrollMemory`).** Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets `history.scrollRestoration = "manual"` once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in `sessionStorage` (per-tab by definition, exactly the required scope): a position map keyed by exact `pathname + search` (hash ignored; saved by a throttled passive scroll listener plus a final write on `pagehide`/hidden `visibilitychange`, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load).
|
||||
**When it restores** - only when the situation is genuinely "going back": (1) navigation type `back_forward` (browser back without bfcache; with bfcache - `pageshow` `persisted` - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) `reload`, (3) a same-origin click on `a.back-link` / `a[data-scroll-back]` / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a `#fragment` always wins over restoration.
|
||||
**How it restores reliably**: a `requestAnimationFrame` loop re-applies the target position (clamped to the current `scrollHeight`, `behavior: "instant"` so the global `html { scroll-behavior: smooth }` never animates it) until the document height has been stable at the target for 10 frames or a 4s deadline passes, and aborts instantly on the first `wheel`/`touchstart`/`keydown`/`pointerdown` so it never fights the user. It also **upgrades bare back-links on load**: when the previous trail URL has the same pathname as a query-less `a.back-link`/`[data-scroll-back]` href (post page's `/feed` vs the `/feed?tab=recent&before=...` the user actually came from), the href is rewritten to the exact previous URL so tab/topic/cursor AND scroll survive the round trip. Give any new "back to X" anchor the `back-link` class (or `data-scroll-back`) and it participates automatically - never hand-roll `scrollTo` persistence per page. Guarded by `tests/e2e/feed.py::test_feed_scroll_restored_via_back_link` / `_via_browser_back` / `_not_restored_on_fresh_visit`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user