Skip to content

Commit 342abd4

Browse files
committed
Merge branch 'admin/accounts'
2 parents ba3ea88 + 340f8fb commit 342abd4

6 files changed

Lines changed: 794 additions & 105 deletions

File tree

CHANGES.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ To be released.
4141
- Added dereferenceable local `QuoteAuthorization` ActivityPub objects
4242
for accepted quotes.
4343

44+
- Added custom field editing to the admin account creation and editing
45+
forms, allowing up to 10 label–value pairs per profile (beyond
46+
Mastodon's limit of 4). Field values support Markdown and mention
47+
syntax. The Mastodon-compatible `PATCH /api/v1/accounts/
48+
update_credentials` endpoint now also accepts up to 10 custom fields
49+
via `fields_attributes[0]` through `fields_attributes[9]`.
50+
51+
- Fixed a bug in `PATCH /api/v1/accounts/update_credentials` where
52+
submitting any credential update (e.g. `display_name`) without
53+
`fields_attributes` would silently wipe all existing custom profile
54+
fields from the public profile, API responses, and federation output.
55+
4456
- Added an ActivityPub `quote-inline` fallback to the `content` of explicit
4557
quote posts created through the Mastodon API. Software that does not
4658
support quote posts can now still show the quoted post permalink, while
@@ -94,6 +106,16 @@ To be released.
94106
are removed; UnoCSS emits a single _src/public/uno.css_ whose
95107
URL is cache-busted by file mtime.
96108

109+
- Added avatar and header image upload to the admin account creation and
110+
editing forms, with drag-and-drop support and in-page image preview.
111+
Files are stored using the same storage backend as the Mastodon-compatible
112+
API (`PATCH /api/v1/accounts/update_credentials`).
113+
114+
- Fixed a performance bug on the account edit page where saving an account
115+
always triggered a network lookup of [@hollo@hollo.social] regardless of
116+
whether the “Receive Hollo news” setting had actually changed. The lookup
117+
now only happens when the news-following state genuinely changes.
118+
97119
- Improved the performance of authenticated API requests by replacing the
98120
complex multi-table JOIN query in the `tokenRequired` middleware with a
99121
lightweight single-table lookup. Account owner data is now fetched on
@@ -112,6 +134,7 @@ To be released.
112134
- Upgraded Fedify to 2.2.1.
113135

114136
[FEP-044f]: https://w3id.org/fep/044f
137+
[@hollo@hollo.social]: https://hollo.social/@hollo
115138
[#67]: https://github.com/fedify-dev/hollo/issues/67
116139
[#127]: https://github.com/fedify-dev/hollo/issues/127
117140
[#457]: https://github.com/fedify-dev/hollo/pull/457

src/api/v1/accounts.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { mkdir } from "node:fs/promises";
2+
3+
import { eq } from "drizzle-orm";
14
import { beforeEach, describe, expect, it } from "vitest";
25

36
import { cleanDatabase } from "../../../tests/helpers";
@@ -9,7 +12,7 @@ import {
912
} from "../../../tests/helpers/oauth";
1013
import db from "../../db";
1114
import app from "../../index";
12-
import { posts } from "../../schema";
15+
import { accountOwners, accounts, posts } from "../../schema";
1316
import { uuidv7 } from "../../uuid";
1417

1518
describe.sequential("/api/v1/accounts/verify_credentials", () => {
@@ -117,6 +120,72 @@ describe.sequential("/api/v1/accounts/verify_credentials", () => {
117120
});
118121
});
119122

123+
describe.sequential("/api/v1/accounts/update_credentials", () => {
124+
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
125+
let account: Awaited<ReturnType<typeof createAccount>>;
126+
127+
beforeEach(async () => {
128+
await cleanDatabase();
129+
await mkdir("tmp/fakes", { recursive: true });
130+
131+
account = await createAccount({ generateKeyPair: true });
132+
client = await createOAuthApplication({
133+
scopes: ["write:accounts"],
134+
});
135+
});
136+
137+
it("clears existing profile fields when blank field slots are submitted", async () => {
138+
expect.assertions(7);
139+
140+
await db
141+
.update(accounts)
142+
.set({
143+
fieldHtmls: {
144+
Website: '<a href="https://example.com">https://example.com</a>',
145+
},
146+
})
147+
.where(eq(accounts.id, account.id));
148+
await db
149+
.update(accountOwners)
150+
.set({
151+
fields: {
152+
Website: "https://example.com",
153+
},
154+
})
155+
.where(eq(accountOwners.id, account.id));
156+
157+
const accessToken = await getAccessToken(client, account, [
158+
"write:accounts",
159+
]);
160+
const form = new FormData();
161+
form.set("fields_attributes[0][name]", "");
162+
form.set("fields_attributes[0][value]", "");
163+
164+
const response = await app.request("/api/v1/accounts/update_credentials", {
165+
method: "PATCH",
166+
headers: {
167+
authorization: bearerAuthorization(accessToken),
168+
},
169+
body: form,
170+
});
171+
172+
expect(response.status).toBe(200);
173+
expect(response.headers.get("content-type")).toBe("application/json");
174+
expect(response.headers.get("access-control-allow-origin")).toBe("*");
175+
176+
const credentialAccount = await response.json();
177+
expect(credentialAccount.fields).toEqual([]);
178+
expect(credentialAccount.source.fields).toEqual([]);
179+
180+
const updatedAccount = await db.query.accountOwners.findFirst({
181+
where: eq(accountOwners.id, account.id),
182+
with: { account: true },
183+
});
184+
expect(updatedAccount?.fields).toEqual({});
185+
expect(updatedAccount?.account.fieldHtmls).toEqual({});
186+
});
187+
});
188+
120189
describe.sequential("/api/v1/accounts/:id/statuses", () => {
121190
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
122191
let account: Awaited<ReturnType<typeof createAccount>>;

src/api/v1/accounts.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,26 @@ app.patch(
9898
"source[privacy]": z.enum(["public", "unlisted", "private"]).optional(),
9999
"source[sensitive]": z.enum(["true", "false"]).optional(),
100100
"source[language]": z.string().optional(),
101-
"fields_attributes[0][name]": z.string().optional(),
102-
"fields_attributes[0][value]": z.string().optional(),
103-
"fields_attributes[1][name]": z.string().optional(),
104-
"fields_attributes[1][value]": z.string().optional(),
105-
"fields_attributes[2][name]": z.string().optional(),
106-
"fields_attributes[2][value]": z.string().optional(),
107-
"fields_attributes[3][name]": z.string().optional(),
108-
"fields_attributes[3][value]": z.string().optional(),
101+
"fields_attributes[0][name]": z.string().max(255).optional(),
102+
"fields_attributes[0][value]": z.string().max(255).optional(),
103+
"fields_attributes[1][name]": z.string().max(255).optional(),
104+
"fields_attributes[1][value]": z.string().max(255).optional(),
105+
"fields_attributes[2][name]": z.string().max(255).optional(),
106+
"fields_attributes[2][value]": z.string().max(255).optional(),
107+
"fields_attributes[3][name]": z.string().max(255).optional(),
108+
"fields_attributes[3][value]": z.string().max(255).optional(),
109+
"fields_attributes[4][name]": z.string().max(255).optional(),
110+
"fields_attributes[4][value]": z.string().max(255).optional(),
111+
"fields_attributes[5][name]": z.string().max(255).optional(),
112+
"fields_attributes[5][value]": z.string().max(255).optional(),
113+
"fields_attributes[6][name]": z.string().max(255).optional(),
114+
"fields_attributes[6][value]": z.string().max(255).optional(),
115+
"fields_attributes[7][name]": z.string().max(255).optional(),
116+
"fields_attributes[7][value]": z.string().max(255).optional(),
117+
"fields_attributes[8][name]": z.string().max(255).optional(),
118+
"fields_attributes[8][value]": z.string().max(255).optional(),
119+
"fields_attributes[9][name]": z.string().max(255).optional(),
120+
"fields_attributes[9][value]": z.string().max(255).optional(),
109121
}),
110122
),
111123
async (c) => {
@@ -167,23 +179,42 @@ app.patch(
167179
username: account.handle,
168180
}),
169181
};
170-
const fields = Object.entries(owner.fields);
171-
const fieldHtmls: [string, string][] = [];
172-
for (const i of [0, 1, 2, 3] as const) {
182+
const fields: ([string, string] | undefined)[] = Object.entries(
183+
owner.fields,
184+
);
185+
let anyFieldAttributeSubmitted = false;
186+
for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const) {
173187
const name = form[`fields_attributes[${i}][name]`];
174188
const value = form[`fields_attributes[${i}][value]`];
189+
if (name == null && value == null) {
190+
continue;
191+
}
192+
anyFieldAttributeSubmitted = true;
175193
if (
176194
name == null ||
177195
name.trim() === "" ||
178196
value == null ||
179197
value.trim() === ""
180198
) {
199+
fields[i] = undefined;
181200
continue;
182201
}
183202
fields[i] = [name, value];
184-
const contentHtml = (await formatText(db, fields[i][1], fmtOpts)).html;
185-
fieldHtmls.push([fields[i][0], contentHtml]);
186203
}
204+
const denseFields = fields.filter((f): f is [string, string] => f != null);
205+
const fieldHtmlsRecord: Record<string, string> = anyFieldAttributeSubmitted
206+
? Object.fromEntries(
207+
await Promise.all(
208+
denseFields.map(
209+
async ([fieldName, fieldValue]) =>
210+
[
211+
fieldName,
212+
(await formatText(db, fieldValue, fmtOpts)).html,
213+
] as [string, string],
214+
),
215+
),
216+
)
217+
: account.fieldHtmls;
187218
const bioResult =
188219
form.note == null ? null : await formatText(db, form.note, fmtOpts);
189220
const name = form.display_name ?? account.name;
@@ -200,7 +231,7 @@ app.patch(
200231
bioHtml: bioResult == null ? account.bioHtml : bioResult.html,
201232
avatarUrl,
202233
coverUrl,
203-
fieldHtmls: Object.fromEntries(fieldHtmls),
234+
fieldHtmls: fieldHtmlsRecord,
204235
protected:
205236
form.locked == null ? account.protected : form.locked === "true",
206237
sensitive:
@@ -220,7 +251,9 @@ app.patch(
220251
.update(accountOwners)
221252
.set({
222253
bio: form.note ?? owner.bio,
223-
fields: Object.fromEntries(fields),
254+
fields: anyFieldAttributeSubmitted
255+
? Object.fromEntries(denseFields)
256+
: owner.fields,
224257
visibility: form["source[privacy]"] ?? owner.visibility,
225258
language: form["source[language]"] ?? owner.language,
226259
})

0 commit comments

Comments
 (0)