20 KiB
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,usersmodel + 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 |
| 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
usersmodel + password hashing + theauthcontroller for the API-style endpoints. - Add cookie-session middleware for the HTML pages. Loco exposes the Axum
router, so
tower-sessionscan be layered in an initializer (src/initializers/). Store the user id in the session. - Alternatively store the JWT in an
HttpOnlycookie and add an extractor that reads it from the cookie instead of theAuthorizationheader.
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() |
| 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
userstable differs (haspid,api_key, reset/verification token columns). Reconcile: either adopt Loco's columns and addtheme, 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
userstable 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_attriggers. SeaORM convention is to setupdated_atin theActiveModel(before_savehook) 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 cookiePOST /auth/login/json→ login, JSON responseGET /auth/register→ register page (public)POST /auth/register→ register, HTML/HTMXPOST /auth/register/json→ register, JSONPOST /auth/logout→ clear sessionGET /auth/me→ current user (auth required)GET /auth/verify?token=→ verify emailPOST /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, incrementsview_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/dashboardGET /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 filesGET /swagger-ui/,GET /api-docs/openapi.json→ API docs
6. Feature details
6.1 Auth
- Register: validate email + password (8–64 chars), reject duplicate email,
argon2-hash password, insert user, assign default
userrole. - Login: look up by email, verify password (constant-time), create session.
- Admin bootstrap: on successful login, if
user.email(case-insensitive) equals configadmin_email, assignSuperAdminrole (idempotent) and write anaudit_logsrow withaction="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 scaffoldedmailers/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
UserPermissionsinto 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_logsentry — Admin+. - Audit log viewer — Moderator+.
AuditLogfields: 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_atset 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_idreferences an uploaded image id.
6.5 Audio dashboard
- Albums: CRUD, publish workflow, public list shows published only, slug URLs,
cover_image_idreferences 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_handlermust honourRangerequests (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 tolocalStorage, 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), readingdata-*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_idandaudio_albums.cover_image_id(stored as strings, not FKs).
6.8 Theme
- Per-user
themecolumn (light/dark); also a cookie for anonymous users. GET /themereturns current,GET /theme/settoggles/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
storagemodule; configure backends inconfig/*.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
- DB & models — port all 8 tables as SeaORM migrations;
cargo loco db entities; reconcileuserswith the scaffold (§4.1). - Auth + sessions — settle §3.1, get register/login/logout/me working, including admin-bootstrap.
- RBAC — roles/permissions, the permission-loading middleware, guard helpers.
- Storage + images — storage backend, image upload/serve (unblocks blog/audio cover images).
- Blog — CRUD + publish + public pages.
- Audio dashboard — albums, tracks (multipart upload), tags.
- Audio streaming + player — range-aware endpoints, then the player in the GUI.
- Admin — dashboard, user management, role UI, audit log.
- Theme, home/layout/navbar.
- 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
durationis frequentlyNULL(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. AdminandSuperAdminhave 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.