diff --git a/frontend/src/api/avatars.ts b/frontend/src/api/avatars.ts new file mode 100644 index 00000000..8a758a3a --- /dev/null +++ b/frontend/src/api/avatars.ts @@ -0,0 +1,25 @@ +import { apiJson } from "./client"; + +/** + * Upload an avatar image (raw file bytes as the body, like uploadFile). The + * gateway stores it and returns the public serving URL to drop into avatar_url. + */ +async function uploadAvatar(path: string, file: File): Promise { + const contentType = file.type || "application/octet-stream"; + const res = await apiJson<{ avatar_url: string }>(path, { + method: "POST", + headers: { "Content-Type": contentType }, + body: file, + }); + return res.avatar_url; +} + +/** Set the current user's avatar. Returns the new avatar_url. */ +export function uploadUserAvatar(file: File): Promise { + return uploadAvatar("/users/me/avatar", file); +} + +/** Set a bot's avatar (owner/admin). Returns the new avatar_url. */ +export function uploadBotAvatar(botId: string, file: File): Promise { + return uploadAvatar(`/bots/${botId}/avatar`, file); +} diff --git a/frontend/src/components/ui/AvatarUpload.tsx b/frontend/src/components/ui/AvatarUpload.tsx new file mode 100644 index 00000000..fe6aaf68 --- /dev/null +++ b/frontend/src/components/ui/AvatarUpload.tsx @@ -0,0 +1,81 @@ +import { useRef, useState } from "react"; +import { Camera, Loader2 } from "lucide-react"; +import toast from "react-hot-toast"; +import { Avatar } from "./avatar"; + +const MAX_BYTES = 5 * 1024 * 1024; + +/** + * An Avatar with a click-to-upload affordance: hover shows a camera overlay, + * clicking opens a file picker, and the picked image is shown optimistically + * (object URL) while `onUpload` runs. `onUpload` returns the new avatar_url so + * the caller can persist it (store / refetch). + */ +export function AvatarUpload({ + name, + id, + src, + size = "lg", + onUpload, +}: { + name?: string | null; + id?: string; + src?: string | null; + size?: "sm" | "md" | "lg"; + onUpload: (file: File) => Promise; +}) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [preview, setPreview] = useState(null); + + async function onPick(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; // let the user re-pick the same file + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("Please pick an image file"); + return; + } + if (file.size > MAX_BYTES) { + toast.error("Image must be 5 MB or smaller"); + return; + } + setBusy(true); + setPreview(URL.createObjectURL(file)); + try { + await onUpload(file); + toast.success("Avatar updated"); + } catch (err) { + setPreview(null); + toast.error(err instanceof Error ? err.message : "Upload failed"); + } finally { + setBusy(false); + } + } + + return ( + + ); +} diff --git a/frontend/src/features/bots/BotDetailPanel.tsx b/frontend/src/features/bots/BotDetailPanel.tsx index 29990395..637eb981 100644 --- a/frontend/src/features/bots/BotDetailPanel.tsx +++ b/frontend/src/features/bots/BotDetailPanel.tsx @@ -14,6 +14,8 @@ import { Trash2, } from "lucide-react"; import { disableBot, enableBot, deleteBot, updateBotProfile } from "@/api/bots"; +import { uploadBotAvatar } from "@/api/avatars"; +import { AvatarUpload } from "@/components/ui/AvatarUpload"; import { addChannelMember } from "@/api/channels"; import { BotPostureSection } from "./BotPostureSection"; import { BotPermissionGrantsSection } from "./BotPermissionGrantsSection"; @@ -345,10 +347,27 @@ function BotStatusEditor({ const inputCls = "w-full rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-xs text-zinc-200 outline-none focus:border-indigo-500/60"; + async function handleAvatarUpload(file: File) { + const url = await uploadBotAvatar(bot.bot_id, file); + onChanged(); // refetch so avatar_url updates wherever the bot is shown + return url; + } + return (

Status & information

+
+ + Click the avatar to upload an image (PNG/JPEG/WebP/GIF, ≤5 MB) +
+
(null); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); @@ -125,6 +127,7 @@ function ProfileEditCard() { setStatusEmoji(me.status_emoji ?? ""); setStatusText(me.status_text ?? ""); setBio(me.bio ?? ""); + setAvatarUrl(me.avatar_url ?? null); // Hydrate the store so the rest of the app sees the full profile. if (token) setAuth({ ...(user ?? { user_id: me.user_id, display_name: null }), ...me }, token); }) @@ -155,13 +158,27 @@ function ProfileEditCard() { } } + async function handleAvatarUpload(file: File) { + const url = await uploadUserAvatar(file); + setAvatarUrl(url); + // Hydrate the store so the avatar updates everywhere it's shown. + if (token) setAuth({ ...(user ?? { user_id: "", display_name: null }), avatar_url: url }, token); + return url; + } + const inputCls = "w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-indigo-500/60"; return (
- +

{statusEmoji && {statusEmoji}} diff --git a/server/src/api/avatars.rs b/server/src/api/avatars.rs new file mode 100644 index 00000000..d66eb571 --- /dev/null +++ b/server/src/api/avatars.rs @@ -0,0 +1,171 @@ +//! Avatar image upload + serving. +//! +//! Upload is JWT-gated (you set your own avatar; a bot's is owner/admin-only) and +//! writes the serving URL straight into the existing `avatar_url` column. Serving +//! is a PUBLIC route because an `` can't attach a Bearer token and an +//! avatar isn't sensitive. The content type is carried in the key's extension +//! (`avatars/{kind}/{id}/{uuid}.png`) so serving needs no extra column, and the +//! per-upload uuid cache-busts the URL on re-upload. +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{header::CONTENT_TYPE, HeaderMap}, + response::Response, + Extension, Json, +}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{ + api::{bots::ensure_bot_owner_or_admin, middleware::Claims}, + app_state::AppState, + errors::AppError, + infra::{http::file_response, s3}, +}; + +/// Avatars are small; cap well under the global 16 MiB body limit. +const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024; +/// One day; the URL is uuid-versioned, so re-uploads bust the cache anyway. +const AVATAR_CACHE_SECONDS: i64 = 86_400; + +/// image/* content type → stored extension (raster only). SVG is intentionally +/// unsupported — `file_response` forces it to download, so it'd never render. +fn ext_for_image(content_type: &str) -> Option<&'static str> { + match content_type.split(';').next().unwrap_or("").trim() { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + "image/gif" => Some("gif"), + _ => None, + } +} + +fn content_type_for_ext(ext: &str) -> Option<&'static str> { + match ext { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "webp" => Some("image/webp"), + "gif" => Some("image/gif"), + _ => None, + } +} + +fn content_type_header(headers: &HeaderMap) -> &str { + headers + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") +} + +/// Store image bytes and return the public serving URL. `kind` is "user"|"bot". +async fn store_avatar( + state: &AppState, + kind: &str, + owner_id: &str, + content_type: &str, + bytes: Bytes, +) -> Result { + if bytes.is_empty() { + return Err(AppError::BadRequest("empty avatar upload".into())); + } + if bytes.len() > MAX_AVATAR_BYTES { + return Err(AppError::PayloadTooLarge( + "avatar must be 5 MiB or smaller".into(), + )); + } + let ext = ext_for_image(content_type).ok_or_else(|| { + AppError::BadRequest("avatar must be a PNG, JPEG, WebP, or GIF image".into()) + })?; + let file = format!("{}.{}", Uuid::new_v4(), ext); + let key = format!("avatars/{kind}/{owner_id}/{file}"); + s3::put_object( + &state.s3, + &state.config.s3_bucket, + &key, + content_type, + bytes.to_vec(), + ) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + // Matches the public serve routes below. + Ok(format!("/api/v1/{kind}s/{owner_id}/avatar/{file}")) +} + +/// `POST /api/v1/users/me/avatar` — self-service; body is the raw image bytes. +pub async fn upload_user_avatar( + State(state): State, + Extension(claims): Extension, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + let ct = content_type_header(&headers).to_string(); + let url = store_avatar(&state, "user", &claims.sub, &ct, body).await?; + sqlx::query("UPDATE users SET avatar_url = $1 WHERE user_id = $2 AND is_deleted = FALSE") + .bind(&url) + .bind(&claims.sub) + .execute(&state.db) + .await?; + Ok(Json(json!({ "avatar_url": url }))) +} + +/// `POST /api/v1/bots/:bot_id/avatar` — owner/admin only. +pub async fn upload_bot_avatar( + State(state): State, + Extension(claims): Extension, + Path(bot_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + ensure_bot_owner_or_admin(&state, &claims, &bot_id).await?; + let ct = content_type_header(&headers).to_string(); + let url = store_avatar(&state, "bot", &bot_id, &ct, body).await?; + sqlx::query("UPDATE bot_accounts SET avatar_url = $1 WHERE bot_id = $2") + .bind(&url) + .bind(&bot_id) + .execute(&state.db) + .await?; + Ok(Json(json!({ "avatar_url": url }))) +} + +/// `GET /api/v1/users/:user_id/avatar/:file` — PUBLIC (no JWT); image bytes. +pub async fn get_user_avatar( + State(state): State, + Path((user_id, file)): Path<(String, String)>, +) -> Result { + serve_avatar(&state, "user", &user_id, &file).await +} + +/// `GET /api/v1/bots/:bot_id/avatar/:file` — PUBLIC (no JWT); image bytes. +pub async fn get_bot_avatar( + State(state): State, + Path((bot_id, file)): Path<(String, String)>, +) -> Result { + serve_avatar(&state, "bot", &bot_id, &file).await +} + +async fn serve_avatar( + state: &AppState, + kind: &str, + owner_id: &str, + file: &str, +) -> Result { + // Validate the path so a crafted key can't escape the avatars/ prefix: + // owner must be a uuid, file must be `{hex-uuid}.{known-ext}`. + Uuid::parse_str(owner_id).map_err(|_| AppError::NotFound)?; + let (stem, ext) = file.rsplit_once('.').ok_or(AppError::NotFound)?; + let content_type = content_type_for_ext(ext).ok_or(AppError::NotFound)?; + if stem.is_empty() || !stem.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + return Err(AppError::NotFound); + } + let key = format!("avatars/{kind}/{owner_id}/{file}"); + let bytes = s3::get_object(&state.s3, &state.config.s3_bucket, &key) + .await + .map_err(|_| AppError::NotFound)?; + Ok(file_response( + bytes, + file, + Some(content_type), + true, + Some(AVATAR_CACHE_SECONDS), + )) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 150d807e..dee3fd8f 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,6 +1,7 @@ pub mod acp_capability; pub mod approval; pub mod auth; +pub mod avatars; pub mod bot_permission; pub mod bots; pub mod channels; diff --git a/server/src/router.rs b/server/src/router.rs index fc8fbf7b..7e0c2fe2 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -344,6 +344,15 @@ fn build_authed_routes(state: AppState) -> Router { ) .route("/api/v1/files", post(api::files::upload_file)) .route("/api/v1/files/presign", post(api::files::request_presign)) + // Avatar upload (authed; serving is public — see build_public_routes). + .route( + "/api/v1/users/me/avatar", + post(api::avatars::upload_user_avatar), + ) + .route( + "/api/v1/bots/:bot_id/avatar", + post(api::avatars::upload_bot_avatar), + ) .route( "/api/v1/files/:file_id/confirm", post(api::files::confirm_upload), @@ -515,6 +524,16 @@ fn build_public_routes() -> Router { "/api/v1/bots/:bot_id/self-status", post(api::bots::bot_self_status), ) + // Avatar images: public so an `` (no auth header) resolves. The + // path is uuid-versioned + validated; the bytes aren't sensitive. + .route( + "/api/v1/users/:user_id/avatar/:file", + get(api::avatars::get_user_avatar), + ) + .route( + "/api/v1/bots/:bot_id/avatar/:file", + get(api::avatars::get_bot_avatar), + ) } fn build_ws_routes() -> Router {