Tahinli/forge
public0 watching
★ 0
Profile: avatar, about and timezone (contract between workers)
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.
Storage: migration crates/forge-core/migrations/0002_profile.sql
ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
CREATE TABLE avatars (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data BLOB NOT NULL, -- re-encoded PNG, 256x256
hash TEXT NOT NULL, -- hex sha256 of data (first 16 chars), used as ETag and as ?v=
updated_at TEXT NOT NULL
);
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.
forge-core
// model.rs: User gains
pub about: String, // raw markdown, at most 2000 chars
pub timezone: String, // IANA name, e.g. "Europe/Istanbul"; "UTC" by default
// store
pub async fn set_profile(&self, user_id: &str, about: &str, timezone: &str) -> Result<()>;
pub async fn set_avatar(&self, user_id: &str, png: &[u8], hash: &str) -> Result<()>; // upsert
pub async fn delete_avatar(&self, user_id: &str) -> Result<()>;
pub async fn avatar_hash(&self, user_id: &str) -> Result<Option<String>>;
pub async fn avatar(&self, user_id: &str) -> Result<Option<(Vec<u8>, String /*hash*/)>>;
// tz.rs (jiff, latest; bundled tzdb so it doesn't depend on the host)
pub fn validate_timezone(name: &str) -> Result<()>; // must be a known IANA name
pub fn all_timezones() -> Vec<String>; // sorted, for the <select>
pub fn format_in_tz(unix: i64, tz: &str) -> String; // "2026-10-15 14:05 +03"; falls back to UTC on error
pub fn local_time_now(tz: &str, now_unix: i64) -> String; // "14:05 (UTC+03:00)"
// avatar.rs (image, latest, only the png/jpeg/webp/gif decoders)
pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
pub struct ProcessedAvatar { pub png: Vec<u8>, pub hash: String }
/// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256
/// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown
/// formats, too-large input or a decode failure give Error::InvalidInput.
pub fn process_avatar(bytes: &[u8]) -> Result<ProcessedAvatar>;
about is limited to 2000 chars. Validation is in the store or the page (either is fine); the page shows the error.
forge-web
GET /avatars/{username}(route, cacheable):- Serves the PNG with
Content-Type: image/png,X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none',ETag: "<hash>", andCache-Control: public, max-age=31536000, immutablewhen?v=<hash>matches; otherwisemax-age=300.If-None-Matchgives a 304. - 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.
- Unknown user: 404.
- Serves the PNG with
- Helper
views::avatar_url(username, hash: Option<&str>) -> Stringgives/avatars/<username>?v=<hash or "default">. It's used in the nav, the profile page and the settings page. /settings/profile: the existing username-case form, plus:- avatar upload (
multipart/form-data) and remove - an "about" textarea
- a timezone
<select>that lists every timezone - every POST has CSRF
- avatar upload (
GET /{username}: the public profile page, showing:- avatar
- display username
- about, through
render_markdown(about, None) - "Local time: 14:05 (UTC+03:00)"
- repos visible to the viewer
- 404 for an unknown user
- Fixed routes (
/new,/login,/setup,/settings/*,/admin/*,/assets/*,/avatars/*) must keep winning; reserved names guarantee no user owns those paths.
- Absolute times (the
title/absolute text on commit, commits, tree and refs pages) use the logged-in viewer's timezone viatz::format_in_tz, or UTC for anonymous viewers. Relative times stay as they are.