Files
universal_web_loco_rewrite/REWRITE_SPEC.md
2026-05-17 18:15:22 +02:00

20 KiB
Raw Blame History

Universal Web — Loco.rs Rewrite Specification

This document is the implementation spec for re-building the existing Axum app (parent directory ../) on top of Loco.rs. It captures every feature so the rewrite reaches parity. The GUI is being rewritten as part of this effort, so templates/markup are not meant to be ported verbatim — only behaviour and routes.

  • Source app: ../ — Axum 0.8, sqlx, Askama, axum-login, OpenDAL.
  • Target app: this directory — Loco 0.16 (SaaS, server-side rendered), SeaORM 1.1, Tera.
  • Scaffold already generated by loco new: auth controller, users model + migration, auth mailer, background worker example, Tera view engine, i18n.

1. Stack mapping (old → Loco)

Concern Old (Axum) Loco target
HTTP framework Axum 0.8 + tower Loco (Axum under the hood) — controllers/
DB access sqlx 0.8 (raw SQL, query_as) SeaORM 1.1 — entities in models/_entities/
Migrations migrations/*.sql (sqlx-cli) migration/ crate (SeaORM, Rust DSL)
Templating Askama (compile-time) Tera (assets/views/) via view_engine initializer
Auth/session axum-login + tower-sessions (PG store) Decision needed — see §3.1. Loco ships JWT auth.
Password hashing argon2 0.5 Loco built-in (AuthnUser/hash_password) — argon2
File storage OpenDAL 0.50 (fs/S3/Azure/GCS) Loco storage module (local/S3/multi-mirror)
Background jobs none (manual) Loco workers (workers/) — bg mode async selected
Email none (verification stored, not sent) Loco mailers (mailers/) — scaffold already present
API docs utoipa + Swagger UI + Scalar Manual — see §13
Validation garde validator crate (already a dep)
Config .env + config.rs singleton config/{development,production,test}.yaml
Logging tracing-subscriber Loco logger (configured in yaml)

2. Feature inventory (parity checklist)

The old app ships these feature modules. Each must exist in the rewrite:

  • Auth — register, login, logout, current-user, email verification
  • RBAC — 4 roles, ~14 permissions, per-request permission loading
  • Admin — dashboard, user management, role assignment, audit log
  • Blog — articles CRUD, publish workflow, public listing, view counts
  • Audio dashboard — albums + tracks + tags CRUD, publish workflow
  • Audio streaming — range-aware track/file streaming, raw upload
  • Audio player — persistent bottom-bar player (frontend)
  • Images — upload + serve, used as cover/featured images
  • Theme — per-user light/dark preference
  • Storage — pluggable backend (fs default, S3/Azure/GCS capable)
  • Home + layout — landing page, dynamic navbar, footer
  • Swagger/OpenAPI — API docs (optional, lower priority)

3. Key migration decisions / gotchas

3.1 Sessions vs JWT

The old app uses cookie sessions (axum-login + tower-sessions-sqlx-store, 1-day inactivity timeout, PG-backed session table). Loco's SaaS starter ships JWT bearer auth by default.

Because this is a server-side rendered app (forms post HTML, navbar reflects auth state), bearer tokens are awkward. Recommended path:

  • Keep Loco's users model + password hashing + the auth controller for the API-style endpoints.
  • Add cookie-session middleware for the HTML pages. Loco exposes the Axum router, so tower-sessions can be layered in an initializer (src/initializers/). Store the user id in the session.
  • Alternatively store the JWT in an HttpOnly cookie and add an extractor that reads it from the cookie instead of the Authorization header.

Pick one and apply it consistently. The old behaviour to match: login sets a cookie, navbar//auth/me reflect it, logout clears it, ~1-day expiry.

3.2 sqlx → SeaORM

Every raw query becomes a SeaORM entity + ActiveModel. Generate entities from migrations with cargo loco db entities. Slug-uniqueness and ON CONFLICT DO NOTHING semantics must be re-expressed (unique index + handle the error).

3.3 Askama → Tera

Templates are NOT ported. Re-author markup as Tera under assets/views/. The new GUI may keep HTMX + Alpine + DaisyUI (still works with server-rendered Tera) or go a different direction — that is a GUI decision, not a backend one.

3.4 OpenDAL → Loco storage

Loco has its own storage abstraction (local, S3, multi-backend mirror/replica). Map the old Storage/ImageStorage/AudioStorage wrappers onto it. Default to local (uploads/); keep S3/Azure/GCS reachable via config only.

3.5 Range requests for audio

The old stream_track forwards the request so ServeFile honours Range headers (seeking/progressive playback). The Loco equivalent must also emit Accept-Ranges/Content-Range — use tower-http ServeFile/ServeDir or a range-aware handler. Do not regress this; it is required for the player's seek bar.


4. Database schema

Old app: 8 migrations under ../migrations/. Re-create as SeaORM migrations in migration/src/ (the loco new scaffold already created m20220101_000001_users). Prefer Loco generators: cargo loco generate model <name> ....

4.1 users

column type notes
id UUID PK gen_random_uuid()
email VARCHAR(255) UNIQUE NOT NULL
password_hash TEXT NOT NULL (argon2)
email_verified BOOLEAN default FALSE
email_verified_at TIMESTAMPTZ nullable
theme VARCHAR(10) default 'light'
created_at TIMESTAMPTZ default NOW
updated_at TIMESTAMPTZ default NOW

Loco's scaffold users table differs (has pid, api_key, reset/verification token columns). Reconcile: either adopt Loco's columns and add theme, or align Loco's model to this schema. Adopting Loco's is less work — it already wires tokens for verification/reset.

4.2 email_verifications

id UUID PK, user_id UUID FK→users ON DELETE CASCADE, token VARCHAR(255) UNIQUE, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ. Indexes on token, user_id.

Loco's users table already carries verification tokens — this table may become redundant if you adopt Loco's auth columns.

4.3 user_roles

user_id UUID FK→users CASCADE, role VARCHAR(50) (user|moderator|admin| super_admin), assigned_by UUID FK→users ON DELETE SET NULL, assigned_at TIMESTAMPTZ. PK = (user_id, role). Index on user_id. Migration back-fills user role for every existing user.

4.4 blog_articles

id UUID PK, title VARCHAR(500), slug VARCHAR(500) UNIQUE, content TEXT, excerpt VARCHAR(1000) NULL, published BOOL default false, author_id UUID FK→users CASCADE, featured_image_id VARCHAR(500) NULL, view_count INT default 0, created_at, updated_at (trigger-updated), published_at TIMESTAMPTZ NULL. Indexes: slug, (published, published_at DESC), author_id.

4.5 audit_logs

id UUID PK, admin_user_id UUID FK→users CASCADE, action VARCHAR(100), target_type VARCHAR(50) NULL, target_id UUID NULL, details JSONB NULL, ip_address INET NULL, user_agent TEXT NULL, created_at TIMESTAMPTZ. Indexes: admin_user_id, action, (target_type,target_id), created_at DESC.

4.6 audio_albums

id UUID PK, title VARCHAR(500), slug VARCHAR(500) UNIQUE, description TEXT NULL, cover_image_id VARCHAR(500) NULL, artist VARCHAR(500) NULL, release_date DATE NULL, published BOOL default false, uploader_id UUID FK→users CASCADE, view_count INT default 0, created_at, updated_at (trigger), published_at NULL. Indexes: slug, published, uploader_id.

4.7 audio_tracks

id UUID PK, album_id UUID FK→audio_albums CASCADE, title VARCHAR(500), slug VARCHAR(500), audio_file_id VARCHAR(500) NOT NULL, track_number INT NULL, duration INT NULL (seconds), featured BOOL default false, play_count INT default 0, created_at, updated_at (trigger). UNIQUE(album_id, slug).

4.8 audio_tags + audio_track_tags

audio_tags: id UUID PK, name VARCHAR(100) UNIQUE, slug VARCHAR(100) UNIQUE, created_at. audio_track_tags (M:N junction): track_id UUID FK→audio_tracks CASCADE, tag_id UUID FK→audio_tags CASCADE, created_at, PK=(track_id,tag_id).

The old schema uses updated_at triggers. SeaORM convention is to set updated_at in the ActiveModel (before_save hook) instead — either works.


5. Routes (parity table)

method path → behaviour (auth requirement). Loco controllers live in src/controllers/; register each with .add_route(...) in app.rs.

Auth (/auth)

  • GET /auth/login → login page (public)
  • POST /auth/login → login, HTML/HTMX response; sets session cookie
  • POST /auth/login/json → login, JSON response
  • GET /auth/register → register page (public)
  • POST /auth/register → register, HTML/HTMX
  • POST /auth/register/json → register, JSON
  • POST /auth/logout → clear session
  • GET /auth/me → current user (auth required)
  • GET /auth/verify?token= → verify email
  • POST /auth/verify/resend → resend verification (auth required)

RBAC (/rbac)

  • GET /rbac/permissions → list all permissions (public)
  • GET /rbac/roles → list all roles (public)
  • GET /rbac/me → current user roles+permissions (auth)
  • GET /rbac/users/{id}/roles → (auth)
  • GET /rbac/users/{id}/permissions → (auth)
  • POST /rbac/users/{id}/roles → assign role (SuperAdmin)
  • DELETE /rbac/users/{id}/roles/{role} → remove role (SuperAdmin)

Blog

  • GET /blog → published articles (public)
  • GET /blog/{slug} → article, increments view_count (public)
  • GET /admin/blog & /admin/blog/articles → admin list (Moderator+)
  • GET|POST /admin/blog/articles/create → create form / submit (Moderator+)
  • GET|POST /admin/blog/articles/{id}/edit → edit form / submit (Moderator+)
  • GET /admin/blog/articles/{id}/delete → delete (Moderator+)

Admin (/admin)

  • GET /admin → redirect to /admin/dashboard
  • GET /admin/dashboard → dashboard + system stats (Moderator+)
  • GET /admin/users & /admin/users/list → user list, filter+paginate (Admin+)
  • GET /admin/users/{id} → user detail (Admin+)
  • GET /admin/users/{id}/roles → roles form (Admin+)
  • POST /admin/users/{id}/roles/assign → assign (Admin+)
  • POST /admin/users/{id}/roles/{role}/remove → remove (Admin+)
  • GET /admin/audit-logs → audit log list (Moderator+)

Audio (public + admin)

  • POST /audio/upload → raw audio upload (auth)
  • GET /audio/stream/{filename} → stream raw file, range-aware (public)
  • GET /audio/albums → published albums (public)
  • GET /audio/albums/{slug} → album + tracks (public)
  • GET /audio/tracks/{id}/stream → stream track, range-aware (public)
  • GET /admin/audio/albums → admin album list (Moderator+)
  • GET|POST /admin/audio/albums/create → (Moderator+)
  • GET|POST /admin/audio/albums/{id}/edit → (Moderator+)
  • GET /admin/audio/albums/{id}/delete → (Moderator+)
  • GET /admin/audio/albums/{id}/tracks → track list (Moderator+)
  • GET /admin/audio/albums/{id}/tracks/upload → upload form (Moderator+)
  • POST /admin/audio/albums/{id}/tracks/upload-file → multipart upload, 50 MB (Moderator+)
  • GET|POST /admin/audio/tracks/{id}/edit → (Moderator+)
  • GET /admin/audio/tracks/{id}/delete → (Moderator+)
  • GET /admin/audio/tags → tag list (Moderator+)
  • POST /admin/audio/tags → create tag (Moderator+)

Images

  • POST /images/upload → upload, validate ext+MIME, max 10 MB (auth)
  • GET /images/serve/{filename} → serve with MIME + cache headers (public)

Misc

  • GET / → home (public)
  • GET /layout/navbar → navbar fragment reflecting auth state (public)
  • GET /theme → current theme; GET /theme/set?... → set theme (public)
  • GET /static/* → static files
  • GET /swagger-ui/, GET /api-docs/openapi.json → API docs

6. Feature details

6.1 Auth

  • Register: validate email + password (864 chars), reject duplicate email, argon2-hash password, insert user, assign default user role.
  • Login: look up by email, verify password (constant-time), create session.
  • Admin bootstrap: on successful login, if user.email (case-insensitive) equals config admin_email, assign SuperAdmin role (idempotent) and write an audit_logs row with action="admin_bootstrap". This is how the first admin is created — there is no seed user. Keep this behaviour.
  • Email verification: token table + email_verified/email_verified_at. The old app stored tokens but never emailed them — the Loco rewrite should actually send the email via the scaffolded mailers/auth.
  • Every response that changes auth state should let the navbar refresh (GET /layout/navbar).

6.2 RBAC

  • Roles: SuperAdmin, Admin (same perms as SuperAdmin), Moderator, User.
  • Permissions (~14): users.{create,read,update,delete,manage_roles}, roles.assign, content.{create,read,update,delete,moderate}, images.{create,read,update,delete}.
  • Role→permission mapping:
    • SuperAdmin / Admin → all permissions.
    • Moderator → content.read/update/moderate, users.read, images.read/delete.
    • User (default) → content.read, images.create/read.
  • A middleware loads the current user's UserPermissions into request extensions on every request so handlers/views can check access. In Loco, do this with a middleware layer or a custom extractor. Provide guard helpers equivalent to "Moderator+", "Admin+", "SuperAdmin".

6.3 Admin

  • Dashboard with system stats (user counts, content counts) — Moderator+.
  • User management: list with search filter + pagination, detail view — Admin+.
  • Role assign/remove per user, each writing an audit_logs entry — Admin+.
  • Audit log viewer — Moderator+.
  • AuditLog fields: action, target_type, target_id, JSONB details, ip_address, user_agent. Populate IP + user-agent from request headers.

6.4 Blog

  • Public: list published articles (paginate), view by slug (increments view_count).
  • Admin (Moderator+): create / edit / delete, draft↔publish (published_at set on publish), drafts visible only in admin.
  • Slug auto-generated from title: lowercase, alphanumeric + hyphens, collapse repeats; uniqueness enforced by DB — surface a clear "already exists" error.
  • author_id = current user. featured_image_id references an uploaded image id.

6.5 Audio dashboard

  • Albums: CRUD, publish workflow, public list shows published only, slug URLs, cover_image_id references an image, view_count.
  • Tracks: belong to an album, multipart upload (50 MB cap, extensions mp3 wav ogg flac aac m4a webm), track_number, duration (seconds, nullable — upload does not extract it; the player reads it from the audio element instead), featured, play_count, slug unique per album.
  • Tags: create + assign to tracks (M:N). Tag has unique name + slug.

6.6 Audio streaming & player

  • stream_track / stream_handler must honour Range requests (see §3.5).
  • The old app has a persistent bottom-bar player (templates/player/player.html): hidden <audio> + Alpine.js, play/pause, prev/next, seek bar, volume persisted to localStorage, auto-advance on track end. It is a sibling of the main content so HTMX navigation never tears it down — music keeps playing across pages.
  • Pages drive it via one global JS API: MusicPlayer.playQueue(containerSelector, startIndex), reading data-* attributes off track rows (no JSON in markup).
  • This is a frontend concern — reproduce the behaviour in the new GUI; the backend only needs the range-aware stream endpoints + track metadata.

6.7 Images

  • Upload: multipart, validate extension (jpg jpeg png webp gif) and MIME, max 10 MB, store via storage backend, return a file id.
  • Serve: public, correct MIME (guessed from extension), long-cache headers.
  • Image ids are referenced by blog_articles.featured_image_id and audio_albums.cover_image_id (stored as strings, not FKs).

6.8 Theme

  • Per-user theme column (light/dark); also a cookie for anonymous users.
  • GET /theme returns current, GET /theme/set toggles/sets it.
  • Maps to DaisyUI themes in the old GUI (winter/dracula). New GUI decides its own theming, but keep the per-user persisted preference.

6.9 Storage

  • Default backend: local filesystem under uploads/ (uploads/images, uploads/audio). Keep S3/Azure/GCS reachable via config only.
  • Path-traversal protection on every user-supplied filename/path.
  • Use Loco's storage module; configure backends in config/*.yaml.

7. Configuration / env

Old .env:

DATABASE_URL=postgres://uni_web_user:1@localhost/universal_web
ADMIN_EMAIL=filippriec@gmail.com

In Loco, move these into config/development.yaml (DB url under database:) and add a custom settings: entry for admin_email (read via ctx.config.settings). The old default bind address is 0.0.0.0:3000 — set under server: in the yaml.


8. What the Loco scaffold already gives you

Already generated in this directory — reuse, don't rebuild:

  • src/controllers/auth.rs — register / login / verify / forgot / reset endpoints.
  • src/models/users.rs + _entities/users.rs — user model with password hashing.
  • src/mailers/auth/ — welcome / forgot-password / magic-link email templates.
  • src/workers/downloader.rs — example background worker (bg mode = async).
  • src/initializers/view_engine.rs — Tera view engine + i18n wiring.
  • migration/ — SeaORM migration crate.
  • config/{development,production,test}.yaml — config files.

9. Suggested implementation order

  1. DB & models — port all 8 tables as SeaORM migrations; cargo loco db entities; reconcile users with the scaffold (§4.1).
  2. Auth + sessions — settle §3.1, get register/login/logout/me working, including admin-bootstrap.
  3. RBAC — roles/permissions, the permission-loading middleware, guard helpers.
  4. Storage + images — DONE: storage backend, image upload/serve (unblocks blog/audio cover images).
  5. Blog — CRUD + publish + public pages.
  6. Audio dashboard — albums, tracks (multipart upload), tags.
  7. Audio streaming + player — DONE for range-aware endpoints; player remains GUI work.
  8. Admin — dashboard, user management, role UI, audit log.
  9. Theme, home/layout/navbar.
  10. Swagger/OpenAPI (optional), tests, polish.

10. Testing

The old app has 19 tests (17 integration hitting a live server + 2 multipart upload) plus unit tests; see ../TESTING.md. Loco has a first-class testing story (#[tokio::test] + loco-rs testing feature, serial_test, insta snapshots — all already dev-deps). Re-create integration coverage for every route in §5, especially: public pages return 200, protected routes return 401/403 without auth, multipart uploads, and range requests on the streaming endpoints.


11. Notes / known quirks carried over

  • Track duration is frequently NULL (upload never extracts it). The player reads duration from the <audio> element's metadata, so the seek bar works anyway — don't block on server-side duration extraction.
  • Admin and SuperAdmin have identical permission sets; only role-assignment endpoints are SuperAdmin-gated.
  • Image references (featured_image_id, cover_image_id) are plain strings, not foreign keys — the rewrite may upgrade these to real FKs if desired.