From 26952ae0d9f27e778fc8e7ae351bcc61af038bad Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 22 Oct 2026 14:34:50 +0700 Subject: [PATCH] feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes - POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users//image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat. --- client/src/components/lanes/FileExplorer.tsx | 213 +++++++++++++----- .../lanes/__tests__/FileExplorer.test.tsx | 57 ++++- .../components/settings/AccountSettings.tsx | 28 ++- .../__tests__/AccountSettings.test.tsx | 33 +++ client/src/i18n/locales/en/lanes.json | 2 + client/src/i18n/locales/en/settings.json | 57 +---- client/src/i18n/locales/vi/lanes.json | 2 + client/src/i18n/locales/vi/settings.json | 75 ++---- .../screens.snapshot.test.tsx.snap | 2 +- docs/API.md | 4 +- docs/SERVER-MODE.md | 5 +- server/__tests__/me-avatar.test.js | 45 +++- server/db.js | 7 +- server/lib/avatar-redirect.js | 21 ++ server/lib/avatar-url.js | 122 ++-------- server/lib/avatars.js | 49 +++- server/openapi-extra/auth.js | 12 +- server/routes/me.js | 12 +- server/routes/users.js | 6 + 19 files changed, 463 insertions(+), 289 deletions(-) create mode 100644 server/lib/avatar-redirect.js diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index d2a7e685..1f4042f9 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -22,6 +22,9 @@ * another user's. An admin's Trash opens on one folder per user (with its * item count); each folder is THAT user's Trash, to restore from or purge * without waiting the 7 days. + * The sidebar nests each workspace under its owner's home (a chevron drops + * them down, remembered per browser); one with no owner home stays in a flat + * Workspaces list. * Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*` * (server/lib/file-explorer.js). * @author Nguyễn Ngọc Trí Vĩ @@ -93,6 +96,7 @@ import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { CloneHereDialog } from "./CloneHereDialog"; import { isSubmitEnter } from "../../lib/keys"; +import { useAuth } from "../AuthGate"; /** One upload batch at most (the server caps one file the same). */ export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; @@ -137,6 +141,11 @@ export function packUploads(files: T[]) { const MIME = "application/x-agenthub-fx"; const VIEW_KEY = "agenthub.files.view"; const HIDDEN_KEY = "agenthub.files.hidden"; +/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */ +const HOMES_OPEN_KEY = "agenthub.files.homesOpen"; +/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the + * viewer may not open that home (an analyst's view of other users). */ +type UserRow = FsHome & { noHome?: boolean }; type Clip = { place: FsPlace; paths: string[]; cut: boolean }; // The clipboard outlives the dialog and a workspace switch, like a desktop's. @@ -319,6 +328,16 @@ export function FileBrowser({ bytes: number; } | null>(null); const [lanes, setLanes] = useState([]); + const { state: auth } = useAuth(); + const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null; + const [openHomes, setOpenHomes] = useState>(() => { + try { + const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]')); + return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); + } catch { + return new Set(["me"]); + } + }); const [clip, setClip] = useState(clipboard); const [dropping, setDropping] = useState(false); const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null); @@ -892,6 +911,52 @@ export function FileBrowser({ onCancel={() => setRenaming(null)} /> ) : null; + // Each workspace drops down under its owner: the caller's own under My + // home, another user's under theirs (`owner_name` = that home's id). An + // owner whose home this viewer may not open (an analyst's view of other + // users) still gets a row, one that only groups. A workspace with no owner + // stays in the flat Workspaces list. Which rows are open is per browser. + const { lanesOfHome, userRows } = useMemo(() => { + const ids = new Set(homes.map((h) => h.id)); + const map = new Map(); + const extra = new Map(); + for (const l of lanes) { + const home = + myId != null && l.owner_id === myId && ids.has("me") + ? "me" + : l.owner_name && l.owner_name !== "me" + ? l.owner_name + : null; + if (!home) continue; + map.set(home, [...(map.get(home) ?? []), l]); + if (!ids.has(home) && !extra.has(home)) { + extra.set(home, { + id: home, + label: l.owner_display_name || home, + readOnly: true, + noHome: true, + }); + } + } + const rows: UserRow[] = [ + ...homes.filter((h) => h.id !== "me"), + ...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)), + ]; + return { lanesOfHome: map, userRows: rows }; + }, [homes, lanes, myId]); + const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id)); + const looseLanes = lanes.filter((l) => !grouped.has(l.id)); + // A home holding the open workspace shows it, whatever was stored. + const homeIsOpen = (id: string, own: Lane[]) => + openHomes.has(id) || + (view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target))); + const toggleHome = (id: string, own: Lane[]) => { + const next = new Set(openHomes); + if (homeIsOpen(id, own)) next.delete(id); + else next.add(id); + setOpenHomes(next); + writePref(HOMES_OPEN_KEY, JSON.stringify([...next])); + }; const placeClass = (active: boolean) => `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${ active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2" @@ -920,64 +985,104 @@ export function FileBrowser({ {/* ── Places ── */}