forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
4.1 KiB

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>", and Cache-Control: public, max-age=31536000, immutable when ?v=<hash> matches; otherwise max-age=300. If-None-Match gives 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.
  • 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.
  • /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
  • 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 via tz::format_in_tz, or UTC for anonymous viewers. Relative times stay as they are.