Tahinli/forge
public0 watching
★ 0
| 1 | # Profile: avatar, about and timezone (contract between workers) |
| 2 | |
| 3 | The 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 |
| 7 | ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; |
| 8 | ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'; |
| 9 | CREATE 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 | ``` |
| 16 | Avatars 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 |
| 21 | pub about: String, // raw markdown, at most 2000 chars |
| 22 | pub timezone: String, // IANA name, e.g. "Europe/Istanbul"; "UTC" by default |
| 23 | |
| 24 | // store |
| 25 | pub async fn set_profile(&self, user_id: &str, about: &str, timezone: &str) -> Result<()>; |
| 26 | pub async fn set_avatar(&self, user_id: &str, png: &[u8], hash: &str) -> Result<()>; // upsert |
| 27 | pub async fn delete_avatar(&self, user_id: &str) -> Result<()>; |
| 28 | pub async fn avatar_hash(&self, user_id: &str) -> Result<Option<String>>; |
| 29 | pub 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) |
| 32 | pub fn validate_timezone(name: &str) -> Result<()>; // must be a known IANA name |
| 33 | pub fn all_timezones() -> Vec<String>; // sorted, for the <select> |
| 34 | pub fn format_in_tz(unix: i64, tz: &str) -> String; // "2026-10-15 14:05 +03"; falls back to UTC on error |
| 35 | pub 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) |
| 38 | pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024; |
| 39 | pub 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. |
| 43 | pub 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. |