diff --git a/docs/api-change.md b/docs/api-change.md index 3082ba295c..91bd2a0c3e 100644 --- a/docs/api-change.md +++ b/docs/api-change.md @@ -2,6 +2,10 @@ Breaking changes are indicated by the :warning: icon. +## Unreleased + +- Added `i/export-followers` endpoint. + ## v20240714 - The old Mastodon API has been replaced with a new implementation based on Iceshrimp’s. diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts index f82f8cda3b..b46e7acc1f 100644 --- a/packages/backend/src/queue/index.ts +++ b/packages/backend/src/queue/index.ts @@ -256,6 +256,25 @@ export function createExportFollowingJob( ); } +export function createExportFollowersJob( + user: ThinUser, + excludeMuting = false, + excludeInactive = false, +) { + return dbQueue.add( + "exportFollowers", + { + user: user, + excludeMuting, + excludeInactive, + }, + { + removeOnComplete: true, + removeOnFail: true, + }, + ); +} + export function createExportMuteJob(user: ThinUser) { return dbQueue.add( "exportMute", diff --git a/packages/backend/src/queue/processors/db/export-followers.ts b/packages/backend/src/queue/processors/db/export-followers.ts new file mode 100644 index 0000000000..74b48c4199 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-followers.ts @@ -0,0 +1,115 @@ +import type Bull from "bull"; +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { getFullApAccount } from "backend-rs"; +import { createTemp } from "@/misc/create-temp.js"; +import { Users, Followings, Mutings } from "@/models/index.js"; +import { In, MoreThan, Not } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import type { Following } from "@/models/entities/following.js"; +import { inspect } from "node:util"; + +const logger = queueLogger.createSubLogger("export-followers"); + +export async function exportFollowers( + job: Bull.Job, + done: () => void, +): Promise { + logger.info(`Exporting followers of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`temp file created: ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + let cursor: Following["id"] | null = null; + + const mutings = job.data.excludeMuting + ? await Mutings.findBy({ + muterId: user.id, + }) + : []; + + while (true) { + const followers = (await Followings.find({ + where: { + followeeId: user.id, + ...(mutings.length > 0 + ? { followerId: Not(In(mutings.map((x) => x.muteeId))) } + : {}), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + })) as Following[]; + + if (followers.length === 0) { + break; + } + + cursor = followers[followers.length - 1].id; + + for (const follower of followers) { + const u = await Users.findOneBy({ id: follower.followerId }); + if (u == null) { + continue; + } + + if ( + job.data.excludeInactive && + u.updatedAt && + Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90 + ) { + continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(`${content}\n`, (err) => { + if (err) { + logger.warn(`failed to export followers of ${job.data.user.id}`); + logger.info(inspect(err)); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.info(`Exported to: ${path}`); + + const fileName = `followers-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.csv`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.info(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + done(); +} diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts index d8cc9298f9..448b37d7e0 100644 --- a/packages/backend/src/queue/processors/db/index.ts +++ b/packages/backend/src/queue/processors/db/index.ts @@ -3,6 +3,7 @@ import type { DbJobData } from "@/queue/types.js"; import { deleteDriveFiles } from "./delete-drive-files.js"; import { exportCustomEmojis } from "./export-custom-emojis.js"; import { exportNotes } from "./export-notes.js"; +import { exportFollowers } from "./export-followers.js"; import { exportFollowing } from "./export-following.js"; import { exportMute } from "./export-mute.js"; import { exportBlocking } from "./export-blocking.js"; @@ -22,6 +23,7 @@ const jobs = { deleteDriveFiles, exportCustomEmojis, exportNotes, + exportFollowers, exportFollowing, exportMute, exportBlocking, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 587a68206e..174e4921b9 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -174,6 +174,7 @@ import * as ep___i_authorizedApps from "./endpoints/i/authorized-apps.js"; import * as ep___i_changePassword from "./endpoints/i/change-password.js"; import * as ep___i_deleteAccount from "./endpoints/i/delete-account.js"; import * as ep___i_exportBlocking from "./endpoints/i/export-blocking.js"; +import * as ep___i_exportFollowers from "./endpoints/i/export-followers.js"; import * as ep___i_exportFollowing from "./endpoints/i/export-following.js"; import * as ep___i_exportMute from "./endpoints/i/export-mute.js"; import * as ep___i_exportNotes from "./endpoints/i/export-notes.js"; @@ -523,6 +524,7 @@ const eps = [ ["i/change-password", ep___i_changePassword], ["i/delete-account", ep___i_deleteAccount], ["i/export-blocking", ep___i_exportBlocking], + ["i/export-followers", ep___i_exportFollowers], ["i/export-following", ep___i_exportFollowing], ["i/export-mute", ep___i_exportMute], ["i/export-notes", ep___i_exportNotes], diff --git a/packages/backend/src/server/api/endpoints/i/export-followers.ts b/packages/backend/src/server/api/endpoints/i/export-followers.ts new file mode 100644 index 0000000000..d14bc5b51a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-followers.ts @@ -0,0 +1,25 @@ +import define from "@/server/api/define.js"; +import { createExportFollowersJob } from "@/queue/index.js"; +import { HOUR } from "backend-rs"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + excludeMuting: { type: "boolean", default: false }, + excludeInactive: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportFollowersJob(user, ps.excludeMuting, ps.excludeInactive); +}); diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue index 4dd64e0fe1..62d9d24946 100644 --- a/packages/client/src/pages/settings/import-export.vue +++ b/packages/client/src/pages/settings/import-export.vue @@ -24,9 +24,6 @@ - + + + + + + + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + + + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + + + {{ i18n.ts.export }} + + @@ -236,6 +258,15 @@ const exportFollowing = () => { .catch(onError); }; +const exportFollowers = () => { + os.api("i/export-followers", { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess) + .catch(onError); +}; + const exportBlocking = () => { os.api("i/export-blocking", {}).then(onExportSuccess).catch(onError); }; diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts index eccab27082..4e775562e1 100644 --- a/packages/firefish-js/src/api.types.ts +++ b/packages/firefish-js/src/api.types.ts @@ -499,6 +499,7 @@ export type Endpoints = { "i/delete-account": { req: { password: string }; res: null }; "i/export-blocking": { req: TODO; res: TODO }; "i/export-following": { req: TODO; res: TODO }; + "i/export-followers": { req: TODO; res: TODO }; "i/export-mute": { req: TODO; res: TODO }; "i/export-notes": { req: TODO; res: TODO }; "i/export-user-lists": { req: TODO; res: TODO };