diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index c2007b1d3..35dddba29 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -9,6 +9,7 @@ import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; +import { Files } from "formidable"; export function setupEndpointsAccount(router: Router): void { router.get("/v1/accounts/verify_credentials", async (ctx) => { @@ -40,7 +41,8 @@ export function setupEndpointsAccount(router: Router): void { return; } - const acct = await UserHelpers.updateCredentials(user, (ctx.request as any).body as any); + const files = (ctx.request as any).files as Files | undefined; + const acct = await UserHelpers.updateCredentials(user, (ctx.request as any).body as any, files); ctx.body = convertAccountId(acct) } catch (e: any) { console.error(e); diff --git a/packages/backend/src/server/api/mastodon/endpoints/media.ts b/packages/backend/src/server/api/mastodon/endpoints/media.ts index eb162d879..099ad82be 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/media.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/media.ts @@ -75,8 +75,8 @@ export function setupEndpointsMedia(router: Router): void { } //FIXME: why do we have to cast this to any first? - const files = (ctx.request as any).files as Files; - const file = toSingleLast(files['file']); + const files = (ctx.request as any).files as Files | undefined; + const file = toSingleLast(files?.file); if (!file) { ctx.body = {error: "No image"}; ctx.status = 400; diff --git a/packages/backend/src/server/api/mastodon/helpers/media.ts b/packages/backend/src/server/api/mastodon/helpers/media.ts index 973b1bdd6..6411ca98e 100644 --- a/packages/backend/src/server/api/mastodon/helpers/media.ts +++ b/packages/backend/src/server/api/mastodon/helpers/media.ts @@ -7,7 +7,7 @@ import { File } from "formidable"; export class MediaHelpers { public static async uploadMedia(user: ILocalUser, file: File, body: any): Promise> { - return await addFile({ + return addFile({ user: user, path: file.filepath, name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined, @@ -17,6 +17,15 @@ export class MediaHelpers { .then(p => DriveFiles.pack(p)); } + public static async uploadMediaBasic(user: ILocalUser, file: File): Promise { + return addFile({ + user: user, + path: file.filepath, + name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined, + sensitive: false + }) + } + public static async updateMedia(user: ILocalUser, file: DriveFile, body: any): Promise> { await DriveFiles.update(file.id, { comment: body?.description ?? undefined diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts index 5ff83ba20..1eb88a161 100644 --- a/packages/backend/src/server/api/mastodon/helpers/user.ts +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -34,6 +34,12 @@ import acceptFollowRequest from "@/services/following/requests/accept.js"; import { rejectFollowRequest } from "@/services/following/reject.js"; import { Brackets, IsNull } from "typeorm"; import { IceshrimpVisibility, VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; +import { Files } from "formidable"; +import { toSingleLast } from "@/prelude/array.js"; +import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js"; +import { FileConverter } from "@/server/api/mastodon/converters/file.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; export type AccountCache = { locks: AsyncLock; @@ -53,6 +59,7 @@ export type updateCredsData = { locked: boolean; bot: boolean; discoverable: boolean; + fields_attributes?: { name: string, value: string }[]; } type RelationshipType = 'followers' | 'following'; @@ -145,17 +152,41 @@ export class UserHelpers { return this.getUserRelationshipTo(target.id, localUser.id); } - public static async updateCredentials(user: ILocalUser, formData: updateCredsData): Promise { - //FIXME: Actually implement this - //FIXME: handle multipart avatar & header image upload - //FIXME: handle field attributes - const obj: any = {}; + public static async updateCredentials(user: ILocalUser, formData: updateCredsData, files: Files | undefined): Promise { + const updates: Partial = {}; + const profileUpdates: Partial = {}; - if (formData.display_name) obj.name = formData.display_name; - if (formData.note) obj.description = formData.note; - if (formData.locked) obj.isLocked = formData.locked; - if (formData.bot) obj.isBot = formData.bot; - if (formData.discoverable) obj.isExplorable = formData.discoverable; + const avatar = toSingleLast(files?.avatar); + const header = toSingleLast(files?.header); + + if (avatar) { + const file = await MediaHelpers.uploadMediaBasic(user, avatar); + updates.avatarId = file.id; + } + + if (header) { + const file = await MediaHelpers.uploadMediaBasic(user, header); + updates.bannerId = file.id; + } + + if (formData.fields_attributes) { + profileUpdates.fields = await Promise.all(formData.fields_attributes.map(async field => { + const verified = field.value.startsWith("http") ? await verifyLink(field.value, user.username) : undefined; + return { + ...field, + verified + }; + })); + } + + if (formData.display_name) updates.name = formData.display_name; + if (formData.note) profileUpdates.description = formData.note; + if (formData.locked) updates.isLocked = formData.locked; + if (formData.bot) updates.isBot = formData.bot; + if (formData.discoverable) updates.isExplorable = formData.discoverable; + + if (Object.keys(updates).length > 0) await Users.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await UserProfiles.update({ userId: user.id }, profileUpdates); return this.verifyCredentials(user); } @@ -164,10 +195,16 @@ export class UserHelpers { const acct = UserConverter.encode(user); const profile = UserProfiles.findOneByOrFail({userId: user.id}); const privacy = this.getDefaultNoteVisibility(user); + const fields = profile.then(profile => profile.fields.map(field => { + return { + name: field.name, + value: field.value + } as MastodonEntity.Field; + })); return acct.then(acct => { const source = { - note: acct.note, - fields: acct.fields, + note: profile.then(profile => profile.description ?? ''), + fields: fields, privacy: privacy.then(p => VisibilityConverter.encode(p)), sensitive: profile.then(p => p.alwaysMarkNsfw), language: profile.then(p => p.lang ?? ''),