forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
4.1 KiB
1# Profile: avatar, about and timezone (contract between workers)
2
3The global rules from `docs/m1-interfaces.md` apply: latest dependencies in root `[workspace.dependencies]`, no unwrap in non-test code, clippy `-D warnings` clean, no inline styles and no JS.
4
5## Storage: migration `crates/forge-core/migrations/0002_profile.sql`
6```sql
7ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
8ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
9CREATE TABLE avatars (
10 user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
11 data BLOB NOT NULL, -- re-encoded PNG, 256x256
12 hash TEXT NOT NULL, -- hex sha256 of data (first 16 chars), used as ETag and as ?v=
13 updated_at TEXT NOT NULL
14);
15```
16Avatars live in the DB, not on disk, so the standby replica and the backups carry them automatically. Each one is about 30–80 KB.
17
18## forge-core
19```rust
20// model.rs: User gains
21pub about: String, // raw markdown, at most 2000 chars
22pub timezone: String, // IANA name, e.g. "Europe/Istanbul"; "UTC" by default
23
24// store
25pub async fn set_profile(&self, user_id: &str, about: &str, timezone: &str) -> Result<()>;
26pub async fn set_avatar(&self, user_id: &str, png: &[u8], hash: &str) -> Result<()>; // upsert
27pub async fn delete_avatar(&self, user_id: &str) -> Result<()>;
28pub async fn avatar_hash(&self, user_id: &str) -> Result<Option<String>>;
29pub async fn avatar(&self, user_id: &str) -> Result<Option<(Vec<u8>, String /*hash*/)>>;
30
31// tz.rs (jiff, latest; bundled tzdb so it doesn't depend on the host)
32pub fn validate_timezone(name: &str) -> Result<()>; // must be a known IANA name
33pub fn all_timezones() -> Vec<String>; // sorted, for the <select>
34pub fn format_in_tz(unix: i64, tz: &str) -> String; // "2026-10-15 14:05 +03"; falls back to UTC on error
35pub fn local_time_now(tz: &str, now_unix: i64) -> String; // "14:05 (UTC+03:00)"
36
37// avatar.rs (image, latest, only the png/jpeg/webp/gif decoders)
38pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
39pub struct ProcessedAvatar { pub png: Vec<u8>, pub hash: String }
40/// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256
41/// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown
42/// formats, too-large input or a decode failure give Error::InvalidInput.
43pub fn process_avatar(bytes: &[u8]) -> Result<ProcessedAvatar>;
44```
45`about` is limited to 2000 chars. Validation is in the store or the page (either is fine); the page shows the error.
46
47## forge-web
48- `GET /avatars/{username}` (route, cacheable):
49 - Serves the PNG with `Content-Type: image/png`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'`, `ETag: "<hash>"`, and `Cache-Control: public, max-age=31536000, immutable` when `?v=<hash>` matches; otherwise `max-age=300`. `If-None-Match` gives a 304.
50 - **No avatar:** generated SVG with the first letter of the username on a color derived from the user id. It's built by our own code, the letter is XML-escaped, and it's still served with nosniff and the CSP above.
51 - Unknown user: 404.
52- Helper `views::avatar_url(username, hash: Option<&str>) -> String` gives `/avatars/<username>?v=<hash or "default">`. It's used in the nav, the profile page and the settings page.
53- `/settings/profile`: the existing username-case form, plus:
54 - avatar upload (`multipart/form-data`) and remove
55 - an "about" textarea
56 - a timezone `<select>` that lists every timezone
57 - every POST has CSRF
58- `GET /{username}`: the public profile page, showing:
59 - avatar
60 - display username
61 - about, through `render_markdown(about, None)`
62 - "Local time: 14:05 (UTC+03:00)"
63 - repos visible to the viewer
64 - 404 for an unknown user
65 - Fixed routes (`/new`, `/login`, `/setup`, `/settings/*`, `/admin/*`, `/assets/*`, `/avatars/*`) must keep winning; reserved names guarantee no user owns those paths.
66- **Absolute times** (the `title`/absolute text on commit, commits, tree and refs pages) use the logged-in viewer's timezone via `tz::format_in_tz`, or UTC for anonymous viewers. Relative times stay as they are.