Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions frontend/src/api/avatars.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string> {
return uploadAvatar(`/bots/${botId}/avatar`, file);
}
81 changes: 81 additions & 0 deletions frontend/src/components/ui/AvatarUpload.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<string | null>(null);

async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy}
className="group relative flex-shrink-0 rounded-full"
title="Change avatar"
>
<Avatar name={name} id={id} src={preview || src || undefined} size={size} />
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
{busy ? (
<Loader2 className="h-4 w-4 animate-spin text-white" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</span>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={onPick}
/>
</button>
);
}
19 changes: 19 additions & 0 deletions frontend/src/features/bots/BotDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-3 space-y-3">
<p className="text-xs font-semibold text-zinc-400">Status & information</p>

<div className="flex items-center gap-3">
<AvatarUpload
name={bot.display_name || bot.username}
id={bot.bot_id}
src={bot.avatar_url}
size="md"
onUpload={handleAvatarUpload}
/>
<span className="text-[11px] text-zinc-500">Click the avatar to upload an image (PNG/JPEG/WebP/GIF, ≤5 MB)</span>
</div>

<div className="flex gap-2">
<input
value={statusEmoji}
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import toast from "react-hot-toast";
import { useAuthStore, useIsAdmin } from "@/stores/authStore";
import { changePassword, logout as logoutApi } from "@/api/auth";
import { getMe, updateMe } from "@/api/users";
import { Avatar } from "@/components/ui/avatar";
import { uploadUserAvatar } from "@/api/avatars";
import { AvatarUpload } from "@/components/ui/AvatarUpload";
import { Button } from "@/components/ui/button";
import { BotsManager } from "@/features/bots/BotsManager";
import { WorkbenchManager } from "@/features/workbench/WorkbenchManager";
Expand Down Expand Up @@ -113,6 +114,7 @@ function ProfileEditCard() {
const [statusEmoji, setStatusEmoji] = useState("");
const [statusText, setStatusText] = useState("");
const [bio, setBio] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);

Expand All @@ -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);
})
Expand Down Expand Up @@ -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 (
<div className="bg-zinc-900 rounded-2xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center gap-4">
<Avatar name={displayName || user?.username} id={user?.user_id} size="lg" />
<AvatarUpload
name={displayName || user?.username}
id={user?.user_id}
src={avatarUrl}
size="lg"
onUpload={handleAvatarUpload}
/>
<div className="min-w-0">
<p className="font-semibold text-zinc-100 truncate">
{statusEmoji && <span className="mr-1">{statusEmoji}</span>}
Expand Down
171 changes: 171 additions & 0 deletions server/src/api/avatars.rs
Original file line number Diff line number Diff line change
@@ -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 `<img src>` 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<String, AppError> {
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<AppState>,
Extension(claims): Extension<Claims>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, 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<AppState>,
Extension(claims): Extension<Claims>,
Path(bot_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, 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<AppState>,
Path((user_id, file)): Path<(String, String)>,
) -> Result<Response, AppError> {
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<AppState>,
Path((bot_id, file)): Path<(String, String)>,
) -> Result<Response, AppError> {
serve_avatar(&state, "bot", &bot_id, &file).await
}

async fn serve_avatar(
state: &AppState,
kind: &str,
owner_id: &str,
file: &str,
) -> Result<Response, AppError> {
// 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),
))
}
1 change: 1 addition & 0 deletions server/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading