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/<id>/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.
This commit is contained in:
+43
-6
@@ -5,7 +5,9 @@
|
||||
* `<data dir>/avatars/u<id>.<png|jpg|gif|webp>` — a name made from the
|
||||
* account id and the type its magic bytes say, never from the request — and
|
||||
* `users.avatar_at` records when it was set, so the client can version the
|
||||
* URL. Raster only: an SVG served from the dashboard's origin runs script.
|
||||
* URL. Or, instead of a file, a LINK (`users.avatar_link`, checked by
|
||||
* avatar-url.js) the serving routes redirect the browser to — never
|
||||
* downloaded. Setting one clears the other. Raster only: an SVG served from the dashboard's origin runs script.
|
||||
* At most `MAX_AVATAR_BYTES`; the serving route adds `nosniff` and a
|
||||
* `sandbox` CSP like every other user-supplied file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -32,10 +34,35 @@ function fileName(userId, ext) {
|
||||
|
||||
const db = () => require("../db").db;
|
||||
|
||||
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
|
||||
function removeAvatar(userId) {
|
||||
const removeFiles = (userId) => {
|
||||
for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true });
|
||||
db().prepare("UPDATE users SET avatar_at = NULL WHERE id = ?").run(Number(userId));
|
||||
};
|
||||
|
||||
/** Deletes every stored avatar file of the account and clears `avatar_at` and the link. */
|
||||
function removeAvatar(userId) {
|
||||
removeFiles(userId);
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = NULL, avatar_link = NULL WHERE id = ?")
|
||||
.run(Number(userId));
|
||||
}
|
||||
|
||||
/** Stores `text` (EBADURL / EPRIVATEURL) as the account's avatar link; returns `avatar_at`. */
|
||||
function setAvatarLink(userId, text) {
|
||||
const link = require("./avatar-url").parseAvatarUrl(text);
|
||||
removeFiles(userId);
|
||||
const at = new Date().toISOString();
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = ?, avatar_link = ? WHERE id = ?")
|
||||
.run(at, link, Number(userId));
|
||||
return at;
|
||||
}
|
||||
|
||||
/** The account's avatar link, or null when it has none. */
|
||||
function avatarLink(userId) {
|
||||
return (
|
||||
db().prepare("SELECT avatar_link FROM users WHERE id = ?").get(Number(userId))?.avatar_link ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */
|
||||
@@ -54,7 +81,9 @@ function setAvatar(userId, buf) {
|
||||
if (other !== ext) fs.rmSync(fileName(userId, other), { force: true });
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
db().prepare("UPDATE users SET avatar_at = ? WHERE id = ?").run(at, Number(userId));
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = ?, avatar_link = NULL WHERE id = ?")
|
||||
.run(at, Number(userId));
|
||||
return at;
|
||||
}
|
||||
|
||||
@@ -68,4 +97,12 @@ function avatarFile(userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
|
||||
module.exports = {
|
||||
MAX_AVATAR_BYTES,
|
||||
setAvatar,
|
||||
setAvatarLink,
|
||||
removeAvatar,
|
||||
avatarFile,
|
||||
avatarLink,
|
||||
avatarsDir,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user