Files
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

303 lines
11 KiB
JavaScript

/**
* @file me.js
* @description `/api/me` — the signed-in account's own UI preferences
* (`user_prefs`) and its first-run setup (docs/plans/onboarding), so they
* follow it to any browser, differ between the accounts of one dashboard, and
* the setup wizard shows once per account. Local mode is one account,
* `local`; server mode keys a row `u<id>` by the login cookie — the request
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
* key). Writes are same-origin and touch only the caller's own row.
* `/instructions` is the account's own agent instructions (issue #67, at most
* 16 000 characters, blank clears), put before the workspace's in every pane
* of a workspace the account owns (`launchExtras` in lib/lane-sources.js);
* `tools` names the AI tools whose panes receive them.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
*
* `/account` (server mode) is the signed-in account itself for Settings →
* Account: GET its profile, workspace/session counts and logins; PATCH its
* display name and email; `POST /password` changes its own password (the old
* one required, throttled like a step-up; every OTHER login ends);
* `POST /sign-out-others` ends every login but this one. These writes take the
* account from the login COOKIE only — a pane token (an agent acting as its
* owner) may not change the owner's login. Each change is audited.
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
* account writes like the profile's. The sidebar shows it beside the name.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const { Router } = express;
const { db } = require("../db");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
/** The localStorage keys an account's preferences are made of. */
const PREF_KEYS = [
"theme",
"agenthub.theme.dark",
"agenthub.theme.light",
"i18nextLng",
"agenthub.terminal.font",
"agenthub.terminal.fontSize",
"agenthub.ui.fontSize",
"agenthub.pet",
"agenthub.pet.size",
"agenthub.pet.names",
"agent-dashboard-sagi-enabled",
"agent-dashboard-sagi-muted",
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.tour",
"agenthub.chatUi",
"agenthub.workspace.allTabs",
];
const MAX_VALUE = 8 * 1024;
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
function userKey(req) {
if (!auth.isServerMode()) return "local";
const user = req.user || auth.userFromRequest(req);
return user ? `u${user.id}` : null;
}
const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?");
const upsert = db.prepare(
`INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?)
ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
);
const markOnboarded = db.prepare(
`INSERT INTO user_prefs (user_key, onboarded_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(user_key) DO UPDATE SET onboarded_at = COALESCE(onboarded_at, excluded.onboarded_at)`
);
function read(key) {
const row = getRow.get(key);
let prefs = {};
try {
prefs = row ? JSON.parse(row.prefs) : {};
} catch {
/* a damaged row reads as empty */
}
return { prefs, onboardedAt: row?.onboarded_at || null };
}
const unauth = (res) =>
res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } });
router.get("/prefs", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
res.json(read(key));
});
router.put("/prefs", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
const patch = req.body?.prefs;
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } });
}
const { prefs } = read(key);
for (const [k, v] of Object.entries(patch)) {
if (!PREF_KEYS.includes(k)) {
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
}
if (v === null) delete prefs[k];
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
else
return res
.status(400)
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
}
upsert.run(key, JSON.stringify(prefs));
if ("theme" in patch) followClaudeTheme(req, patch.theme);
res.json(read(key));
});
const sources = require("../lib/lane-sources");
const instructionsView = (key) => ({
instructions: sources.getUserInstructions(key) || "",
max: sources.MAX_INSTRUCTIONS,
// The tools a pane of which actually receives them (see launchExtras).
tools: [...sources.SUPPORTED_TOOLS],
});
router.get("/instructions", (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
res.json(instructionsView(key));
});
router.put("/instructions", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
try {
sources.setUserInstructions(key, req.body?.instructions ?? null);
} catch (err) {
const status = err.code === "ETOOBIG" ? 413 : 400; // as PUT /api/lanes/:id/instructions
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
res.json(instructionsView(key));
});
router.post("/onboarded", sameOriginGuard, (req, res) => {
const key = userKey(req);
if (!key) return unauth(res);
markOnboarded.run(key);
res.json(read(key));
});
// ── The account itself (server mode) ─────────────────────────────────────────
const { sessionWhere } = require("../lib/scope");
const { recordAccountChange } = require("../lib/audit");
const ACCOUNT_STATUS = {
EBADFIELD: 400,
EBADEMAIL: 400,
EBADPASSWORD: 400,
EBADIMAGE: 400,
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
};
function accountView(userId) {
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
const w = sessionWhere({ user: { id: userId, role: "developer" } });
return {
mode: "server",
user: auth.publicUser(row),
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(userId).n,
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params).n,
...auth.loginStats(userId),
};
}
/** A write to the account: the cookie's own user, never a pane token's. */
function accountWrite(handler) {
return async (req, res) => {
if (!auth.isServerMode()) {
return res
.status(403)
.json({ error: { code: "ELOCALMODE", message: "no account in local mode" } });
}
const user = auth.userFromRequest(req);
if (!user) return unauth(res);
try {
const body = req.body && typeof req.body === "object" ? req.body : {};
const { out, action } = await handler(req, user, body);
recordAccountChange(user.id, user.id, req.method, `${user.username}: ${action}`);
res.json(out);
} catch (err) {
const status = ACCOUNT_STATUS[err.code];
if (!status) return res.status(500).json({ error: { message: err.message } });
res.status(status).json({ error: { code: err.code, message: err.message } });
}
};
}
router.get("/account", (req, res) => {
if (!auth.isServerMode()) return res.json({ mode: "local" });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
res.json(accountView(user.id));
});
router.patch(
"/account",
sameOriginGuard,
accountWrite(async (_req, user, body) => {
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
auth.setProfile(user.id, fields);
return { out: accountView(user.id), action: "own profile" };
})
);
router.post(
"/password",
sameOriginGuard,
accountWrite(async (req, user, body) => {
await auth.confirmPassword(user, body.currentPassword);
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
return { out: { ok: true }, action: "own password changed" };
})
);
router.post(
"/sign-out-others",
sameOriginGuard,
accountWrite(async (req, user) => {
const ended = auth.endSessions(user.id, auth.cookieToken(req));
return { out: { ended }, action: `signed out ${ended} other login(s)` };
})
);
const avatars = require("../lib/avatars");
router.get("/avatar", (req, res) => {
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
const file = avatars.avatarFile(user.id);
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
res.set("Content-Type", file.type);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
router.put(
"/avatar",
sameOriginGuard,
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
accountWrite(async (req, user) => {
avatars.setAvatar(user.id, req.body);
return { out: accountView(user.id), action: "own avatar set" };
})
);
router.delete(
"/avatar",
sameOriginGuard,
accountWrite(async (_req, user) => {
avatars.removeAvatar(user.id);
return { out: accountView(user.id), action: "own avatar removed" };
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
return next(e);
});
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;
try {
const runAs = require("../lib/run-as");
const acct = runAs.accountForUser(req.user?.username);
if (acct) require("../lib/member-claude").setTheme(acct, mode);
} catch (err) {
console.warn("[me] Claude theme not written:", err.message);
}
}
module.exports = router;
module.exports.PREF_KEYS = PREF_KEYS;