From 0e4a111f81cceed275d9bec2695f6e401fb654d8 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 12 Nov 2021 02:02:25 +0900 Subject: refactoring Resolve #7779 --- .../backend/src/server/api/endpoints/i/2fa/done.ts | 41 +++ .../src/server/api/endpoints/i/2fa/key-done.ts | 150 +++++++++++ .../server/api/endpoints/i/2fa/password-less.ts | 21 ++ .../src/server/api/endpoints/i/2fa/register-key.ts | 59 +++++ .../src/server/api/endpoints/i/2fa/register.ts | 54 ++++ .../src/server/api/endpoints/i/2fa/remove-key.ts | 45 ++++ .../src/server/api/endpoints/i/2fa/unregister.ts | 32 +++ .../backend/src/server/api/endpoints/i/apps.ts | 43 +++ .../src/server/api/endpoints/i/authorized-apps.ts | 44 +++ .../src/server/api/endpoints/i/change-password.ts | 39 +++ .../src/server/api/endpoints/i/delete-account.ts | 48 ++++ .../src/server/api/endpoints/i/export-blocking.ts | 16 ++ .../src/server/api/endpoints/i/export-following.ts | 16 ++ .../src/server/api/endpoints/i/export-mute.ts | 16 ++ .../src/server/api/endpoints/i/export-notes.ts | 16 ++ .../server/api/endpoints/i/export-user-lists.ts | 16 ++ .../src/server/api/endpoints/i/favorites.ts | 50 ++++ .../src/server/api/endpoints/i/gallery/likes.ts | 57 ++++ .../src/server/api/endpoints/i/gallery/posts.ts | 49 ++++ .../api/endpoints/i/get-word-muted-notes-count.ts | 33 +++ .../src/server/api/endpoints/i/import-blocking.ts | 60 +++++ .../src/server/api/endpoints/i/import-following.ts | 59 +++++ .../src/server/api/endpoints/i/import-muting.ts | 60 +++++ .../server/api/endpoints/i/import-user-lists.ts | 59 +++++ .../src/server/api/endpoints/i/notifications.ts | 138 ++++++++++ .../src/server/api/endpoints/i/page-likes.ts | 57 ++++ .../backend/src/server/api/endpoints/i/pages.ts | 49 ++++ packages/backend/src/server/api/endpoints/i/pin.ts | 59 +++++ .../api/endpoints/i/read-all-messaging-messages.ts | 37 +++ .../api/endpoints/i/read-all-unread-notes.ts | 25 ++ .../server/api/endpoints/i/read-announcement.ts | 60 +++++ .../src/server/api/endpoints/i/regenerate-token.ts | 44 +++ .../src/server/api/endpoints/i/registry/get-all.ts | 33 +++ .../server/api/endpoints/i/registry/get-detail.ts | 48 ++++ .../src/server/api/endpoints/i/registry/get.ts | 45 ++++ .../api/endpoints/i/registry/keys-with-type.ts | 41 +++ .../src/server/api/endpoints/i/registry/keys.ts | 28 ++ .../src/server/api/endpoints/i/registry/remove.ts | 45 ++++ .../src/server/api/endpoints/i/registry/scopes.ts | 29 ++ .../src/server/api/endpoints/i/registry/set.ts | 61 +++++ .../src/server/api/endpoints/i/revoke-token.ts | 31 +++ .../src/server/api/endpoints/i/signin-history.ts | 35 +++ .../backend/src/server/api/endpoints/i/unpin.ts | 45 ++++ .../src/server/api/endpoints/i/update-email.ts | 94 +++++++ .../backend/src/server/api/endpoints/i/update.ts | 294 +++++++++++++++++++++ .../server/api/endpoints/i/user-group-invites.ts | 61 +++++ 46 files changed, 2442 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/done.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/key-done.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/password-less.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/register-key.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/register.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts create mode 100644 packages/backend/src/server/api/endpoints/i/2fa/unregister.ts create mode 100644 packages/backend/src/server/api/endpoints/i/apps.ts create mode 100644 packages/backend/src/server/api/endpoints/i/authorized-apps.ts create mode 100644 packages/backend/src/server/api/endpoints/i/change-password.ts create mode 100644 packages/backend/src/server/api/endpoints/i/delete-account.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-blocking.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-following.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-mute.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-notes.ts create mode 100644 packages/backend/src/server/api/endpoints/i/export-user-lists.ts create mode 100644 packages/backend/src/server/api/endpoints/i/favorites.ts create mode 100644 packages/backend/src/server/api/endpoints/i/gallery/likes.ts create mode 100644 packages/backend/src/server/api/endpoints/i/gallery/posts.ts create mode 100644 packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts create mode 100644 packages/backend/src/server/api/endpoints/i/import-blocking.ts create mode 100644 packages/backend/src/server/api/endpoints/i/import-following.ts create mode 100644 packages/backend/src/server/api/endpoints/i/import-muting.ts create mode 100644 packages/backend/src/server/api/endpoints/i/import-user-lists.ts create mode 100644 packages/backend/src/server/api/endpoints/i/notifications.ts create mode 100644 packages/backend/src/server/api/endpoints/i/page-likes.ts create mode 100644 packages/backend/src/server/api/endpoints/i/pages.ts create mode 100644 packages/backend/src/server/api/endpoints/i/pin.ts create mode 100644 packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts create mode 100644 packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts create mode 100644 packages/backend/src/server/api/endpoints/i/read-announcement.ts create mode 100644 packages/backend/src/server/api/endpoints/i/regenerate-token.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/get-all.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/get-detail.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/get.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/keys.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/remove.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/scopes.ts create mode 100644 packages/backend/src/server/api/endpoints/i/registry/set.ts create mode 100644 packages/backend/src/server/api/endpoints/i/revoke-token.ts create mode 100644 packages/backend/src/server/api/endpoints/i/signin-history.ts create mode 100644 packages/backend/src/server/api/endpoints/i/unpin.ts create mode 100644 packages/backend/src/server/api/endpoints/i/update-email.ts create mode 100644 packages/backend/src/server/api/endpoints/i/update.ts create mode 100644 packages/backend/src/server/api/endpoints/i/user-group-invites.ts (limited to 'packages/backend/src/server/api/endpoints/i') diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..2bd2128cce --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + token: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const token = ps.token.replace(/\s/g, ''); + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.twoFactorTempSecret == null) { + throw new Error('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorTempSecret, + encoding: 'base32', + token: token + }); + + if (!verified) { + throw new Error('not verified'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts new file mode 100644 index 0000000000..b4d3af235a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -0,0 +1,150 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { promisify } from 'util'; +import * as cbor from 'cbor'; +import define from '../../../define'; +import { + UserProfiles, + UserSecurityKeys, + AttestationChallenges, + Users +} from '@/models/index'; +import config from '@/config/index'; +import { procedures, hash } from '../../../2fa'; +import { publishMainStream } from '@/services/stream'; + +const cborDecodeFirst = promisify(cbor.decodeFirst) as any; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + clientDataJSON: { + validator: $.str + }, + attestationObject: { + validator: $.str + }, + password: { + validator: $.str + }, + challengeId: { + validator: $.str + }, + name: { + validator: $.str + } + } +}; + +const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type != 'webauthn.create') { + throw new Error('not a creation attestation'); + } + if (clientData.origin != config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); + + const attestation = await cborDecodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + const flags = attestation.authData[32]; + + // tslint:disable-next-line:no-bitwise + if (!(flags & 1)) { + throw new Error('user not present'); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map = await cborDecodeFirst(publicKeyData); + if (publicKey.get(3) != -7) { + throw new Error('alg mismatch'); + } + + if (!(procedures as any)[attestation.fmt]) { + throw new Error('unsupported fmt'); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash + }); + if (!verificationData.valid) throw new Error('signature invalid'); + + const attestationChallenge = await AttestationChallenges.findOne({ + userId: user.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: hash(clientData.challenge).toString('hex') + }); + + if (!attestationChallenge) { + throw new Error('non-existent challenge'); + } + + await AttestationChallenges.delete({ + userId: user.id, + id: ps.challengeId + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error('expired challenge'); + } + + const credentialIdString = credentialId.toString('hex'); + + await UserSecurityKeys.save({ + userId: user.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString('hex') + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return { + id: credentialIdString, + name: ps.name + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 0000000000..064828b638 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,21 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + value: { + validator: $.boolean + } + } +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts new file mode 100644 index 0000000000..1b385a10ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, AttestationChallenges } from '@/models/index'; +import { promisify } from 'util'; +import * as crypto from 'crypto'; +import { genId } from '@/misc/gen-id'; +import { hash } from '../../../2fa'; + +const randomBytes = promisify(crypto.randomBytes); + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy.toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.save({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: true + }); + + return { + challengeId, + challenge + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..b03b98188a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import config from '@/config/index'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await UserProfiles.update(user.id, { + twoFactorTempSecret: secret.base32 + }); + + // Get the data URL of the authenticator URL + const dataUrl = await QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + })); + + return { + qr: dataUrl, + secret: secret.base32, + label: user.username, + issuer: config.host + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts new file mode 100644 index 0000000000..dea56301ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, UserSecurityKeys, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + credentialId: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Make sure we only delete the user's own creds + await UserSecurityKeys.delete({ + userId: user.id, + id: ps.credentialId + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return {}; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..af53033daa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: null, + twoFactorEnabled: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts new file mode 100644 index 0000000000..994528e5c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + sort: { + validator: $.optional.str.or([ + '+createdAt', + '-createdAt', + '+lastUsedAt', + '-lastUsedAt', + ]), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = AccessTokens.createQueryBuilder('token') + .where('token.userId = :userId', { userId: user.id }); + + switch (ps.sort) { + case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; + case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; + case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; + default: query.orderBy('token.id', 'ASC'); break; + } + + const tokens = await query.getMany(); + + return await Promise.all(tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts new file mode 100644 index 0000000000..042fcd14e8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens, Apps } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + }, + + sort: { + validator: $.optional.str.or('desc|asc'), + default: 'desc', + } + } +}; + +export default define(meta, async (ps, user) => { + // Get tokens + const tokens = await AccessTokens.find({ + where: { + userId: user.id + }, + take: ps.limit!, + skip: ps.offset, + order: { + id: ps.sort == 'asc' ? 1 : -1 + } + }); + + return await Promise.all(tokens.map(token => Apps.pack(token.appId, user, { + detail: true + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts new file mode 100644 index 0000000000..7ea5f8c488 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + currentPassword: { + validator: $.str + }, + + newPassword: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.currentPassword, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.newPassword, salt); + + await UserProfiles.update(user.id, { + password: hash + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts new file mode 100644 index 0000000000..10e5adf64a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles, Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + const userDetailed = await Users.findOneOrFail(user.id); + if (userDetailed.isDeleted) { + return; + } + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + + await Users.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts new file mode 100644 index 0000000000..e4797da0c1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportBlockingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts new file mode 100644 index 0000000000..b0f154cda8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportFollowingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts new file mode 100644 index 0000000000..46d547fa53 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportMuteJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportMuteJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts new file mode 100644 index 0000000000..441bf16896 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportNotesJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1day'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportNotesJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts new file mode 100644 index 0000000000..24043a862a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1min'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportUserListsJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..b79d68ae73 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteFavorites } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'notes', 'favorites'], + + requireCredential: true as const, + + kind: 'read:favorites', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteFavorite', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(NoteFavorites.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + .andWhere(`favorite.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('favorite.note', 'note'); + + const favorites = await query + .take(ps.limit!) + .getMany(); + + return await NoteFavorites.packMany(favorites, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts new file mode 100644 index 0000000000..7a2935a5ec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryLikes } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.post', 'post'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await GalleryLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts new file mode 100644 index 0000000000..21bb8759fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :meId`, { meId: user.id }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts new file mode 100644 index 0000000000..6b9be98582 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -0,0 +1,33 @@ +import define from '../../define'; +import { MutedNotes } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + count: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + return { + count: await MutedNotes.count({ + userId: user.id, + reason: 'word' + }) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts new file mode 100644 index 0000000000..d44d0b6077 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ebb53e5f-6574-9c0c-0b92-7ca6def56d7e' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'b6fab7d6-d945-d67c-dfdb-32da1cd12cfe' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'b7fbf0b1-aeef-3b21-29ef-fadd4cb72ccf' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '6f3a4dcc-f060-a707-4950-806fbdbe60d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportBlockingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts new file mode 100644 index 0000000000..b3de397661 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b98644cf-a5ac-4277-a502-0b8054a709a3' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '660f3599-bce0-4f95-9dde-311fd841c183' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'dee9d4ed-ad07-43ed-8b34-b2856398bc60' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '31a1b42c-06f7-42ae-8a38-a661c5c9f691' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportFollowingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts new file mode 100644 index 0000000000..c17434c587 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportMutingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e674141e-bd2a-ba85-e616-aefb187c9c2a' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '568c6e42-c86c-ba09-c004-517f83f9f1a8' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: '9b4ada6d-d7f7-0472-0713-4f558bd1ec9c' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: 'd2f12af1-e7b4-feac-86a3-519548f2728e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportMutingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts new file mode 100644 index 0000000000..9069a019a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ea9cc34f-c415-4bc6-a6fe-28ac40357049' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'a3c9edda-dd9b-4596-be6a-150ef813745c' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'ae6e7a22-971b-4b52-b2be-fc0b9b121fe9' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '99efe367-ce6e-4d44-93f8-5fae7b040356' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportUserListsJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..56668d03b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,138 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { readNotification } from '../../common/read-notification'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notifications, Followings, Mutings, Users } from '@/models/index'; +import { notificationTypes } from '@/types'; +import read from '@/services/note/read'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true as const, + + kind: 'read:notifications', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + following: { + validator: $.optional.bool, + default: false + }, + + unreadOnly: { + validator: $.optional.bool, + default: false + }, + + markAsRead: { + validator: $.optional.bool, + default: true + }, + + includeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + }, + + excludeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Notification', + } + }, +}; + +export default define(meta, async (ps, user) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + const suspendedQuery = Users.createQueryBuilder('users') + .select('users.id') + .where('users.isSuspended = TRUE'); + + const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) + .andWhere(`notification.notifieeId = :meId`, { meId: user.id }) + .leftJoinAndSelect('notification.notifier', 'notifier') + .leftJoinAndSelect('notification.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + query.setParameters(mutingQuery.getParameters()); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + + if (ps.following) { + query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere(`notification.type IN (:...includeTypes)`, { includeTypes: ps.includeTypes }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere(`notification.type NOT IN (:...excludeTypes)`, { excludeTypes: ps.excludeTypes }); + } + + if (ps.unreadOnly) { + query.andWhere(`notification.isRead = false`); + } + + const notifications = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + readNotification(user.id, notifications.map(x => x.id)); + } + + const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + + if (notes.length > 0) { + read(user.id, notes); + } + + return await Notifications.packMany(notifications, user.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000000..fa2bc31730 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { PageLikes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:page-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await PageLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000000..ee87fffa2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Pages } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:pages', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..de94220ba9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { addPinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '56734f8b-3928-431e-bf80-6ff87df40cb3' + }, + + pinLimitExceeded: { + message: 'You can not pin notes any more.', + code: 'PIN_LIMIT_EXCEEDED', + id: '72dab508-c64d-498f-8740-a8eec1ba385a' + }, + + alreadyPinned: { + message: 'That note has already been pinned.', + code: 'ALREADY_PINNED', + id: '8b18c2b7-68fe-4edb-9892-c0cbaeb6c913' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await addPinned(user, ps.noteId).catch(e => { + if (e.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); + if (e.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); + if (e.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000000..9aca7611c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,37 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { MessagingMessages, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['account', 'messaging'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Update documents + await MessagingMessages.update({ + recipientId: user.id, + isRead: false + }, { + isRead: true + }); + + const joinings = await UserGroupJoinings.find({ userId: user.id }); + + await Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${user.id}')`) as any + }) + .where(`groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: user.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: user.id }) + .execute())); + + publishMainStream(user.id, 'readAllMessagingMessages'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts new file mode 100644 index 0000000000..2a7102a590 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -0,0 +1,25 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { NoteUnreads } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Remove documents + await NoteUnreads.delete({ + userId: user.id + }); + + // 全て既読になったイベントを発行 + publishMainStream(user.id, 'readAllUnreadMentions'); + publishMainStream(user.id, 'readAllUnreadSpecifiedNotes'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts new file mode 100644 index 0000000000..2f5036f953 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { genId } from '@/misc/gen-id'; +import { AnnouncementReads, Announcements, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + announcementId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: '184663db-df88-4bc2-8b52-fb85f0681939' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Check if announcement exists + const announcement = await Announcements.findOne(ps.announcementId); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await AnnouncementReads.findOne({ + announcementId: ps.announcementId, + userId: user.id + }); + + if (read != null) { + return; + } + + // Create read + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!await Users.getHasUnreadAnnouncement(user.id)) { + publishMainStream(user.id, 'readAllAnnouncements'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts new file mode 100644 index 0000000000..1cce2d37be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import generateUserToken from '../../common/generate-native-user-token'; +import define from '../../define'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await Users.update(user.id, { + token: secret + }); + + // Publish event + publishMainStream(user.id, 'myTokenRegenerated'); + + // Terminate streaming + setTimeout(() => { + publishUserEvent(user.id, 'terminate', {}); + }, 5000); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts new file mode 100644 index 0000000000..c8eaf83a25 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + res[item.key] = item.value; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts new file mode 100644 index 0000000000..992800c44c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts new file mode 100644 index 0000000000..569c3a9280 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts new file mode 100644 index 0000000000..16a4fee374 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts new file mode 100644 index 0000000000..3a8aeaa195 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts new file mode 100644 index 0000000000..07bc23d4a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + await RegistryItems.remove(item); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 0000000000..ecbdb05a8e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,29 @@ +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts new file mode 100644 index 0000000000..f129ee1b70 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str.min(1) + }, + + value: { + validator: $.nullable.any + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts new file mode 100644 index 0000000000..bed868def4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -0,0 +1,31 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + tokenId: { + validator: $.type(ID) + } + } +}; + +export default define(meta, async (ps, user) => { + const token = await AccessTokens.findOne(ps.tokenId); + + if (token) { + await AccessTokens.delete({ + id: ps.tokenId, + userId: user.id, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts new file mode 100644 index 0000000000..a2c10148c6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Signins } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Signins.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + .andWhere(`signin.userId = :meId`, { meId: user.id }); + + const history = await query.take(ps.limit!).getMany(); + + return await Promise.all(history.map(record => Signins.pack(record))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts new file mode 100644 index 0000000000..dc79e255ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { removePinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '454170ce-9d63-4a43-9da1-ea10afe81e21' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await removePinned(user, ps.noteId).catch(e => { + if (e.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts new file mode 100644 index 0000000000..9b6fb9c410 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import rndstr from 'rndstr'; +import config from '@/config/index'; +import * as ms from 'ms'; +import * as bcrypt from 'bcryptjs'; +import { Users, UserProfiles } from '@/models/index'; +import { sendEmail } from '@/services/send-email'; +import { ApiError } from '../../error'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + password: { + validator: $.str + }, + + email: { + validator: $.optional.nullable.str + }, + }, + + errors: { + incorrectPassword: { + message: 'Incorrect password.', + code: 'INCORRECT_PASSWORD', + id: 'e54c1d7e-e7d6-4103-86b6-0a95069b4ad3' + }, + + unavailable: { + message: 'Unavailable email address.', + code: 'UNAVAILABLE', + id: 'a2defefb-f220-8849-0af6-17f816099323' + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); + } + + if (ps.email != null) { + const available = await validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } + + await UserProfiles.update(user.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await UserProfiles.update(user.id, { + emailVerifyCode: code + }); + + const link = `${config.url}/verify-email/${code}`; + + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:
${link}`, + `To verify email, please click this link: ${link}`); + } + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..d0f201ab60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -0,0 +1,294 @@ +import $ from 'cafy'; +import * as mfm from 'mfm-js'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import acceptAllFollowRequests from '@/services/following/requests/accept-all'; +import { publishToFollowers } from '@/services/i/update'; +import define from '../../define'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; +import { extractHashtags } from '@/misc/extract-hashtags'; +import * as langmap from 'langmap'; +import { updateUsertags } from '@/services/update-hashtag'; +import { ApiError } from '../../error'; +import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { notificationTypes } from '@/types'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.optional.nullable.use(Users.validateName), + }, + + description: { + validator: $.optional.nullable.use(Users.validateDescription), + }, + + lang: { + validator: $.optional.nullable.str.or(Object.keys(langmap)), + }, + + location: { + validator: $.optional.nullable.use(Users.validateLocation), + }, + + birthday: { + validator: $.optional.nullable.use(Users.validateBirthday), + }, + + avatarId: { + validator: $.optional.nullable.type(ID), + }, + + bannerId: { + validator: $.optional.nullable.type(ID), + }, + + fields: { + validator: $.optional.arr($.object()).range(1, 4), + }, + + isLocked: { + validator: $.optional.bool, + }, + + isExplorable: { + validator: $.optional.bool, + }, + + hideOnlineStatus: { + validator: $.optional.bool, + }, + + publicReactions: { + validator: $.optional.bool, + }, + + ffVisibility: { + validator: $.optional.str, + }, + + carefulBot: { + validator: $.optional.bool, + }, + + autoAcceptFollowed: { + validator: $.optional.bool, + }, + + noCrawle: { + validator: $.optional.bool, + }, + + isBot: { + validator: $.optional.bool, + }, + + isCat: { + validator: $.optional.bool, + }, + + injectFeaturedNote: { + validator: $.optional.bool, + }, + + receiveAnnouncementEmail: { + validator: $.optional.bool, + }, + + alwaysMarkNsfw: { + validator: $.optional.bool, + }, + + pinnedPageId: { + validator: $.optional.nullable.type(ID), + }, + + mutedWords: { + validator: $.optional.arr($.arr($.str)) + }, + + mutingNotificationTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) + }, + + emailNotificationTypes: { + validator: $.optional.arr($.str) + }, + }, + + errors: { + noSuchAvatar: { + message: 'No such avatar file.', + code: 'NO_SUCH_AVATAR', + id: '539f3a45-f215-4f81-a9a8-31293640207f' + }, + + noSuchBanner: { + message: 'No such banner file.', + code: 'NO_SUCH_BANNER', + id: '0d8f5629-f210-41c2-9433-735831a58595' + }, + + avatarNotAnImage: { + message: 'The file specified as an avatar is not an image.', + code: 'AVATAR_NOT_AN_IMAGE', + id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191' + }, + + bannerNotAnImage: { + message: 'The file specified as a banner is not an image.', + code: 'BANNER_NOT_AN_IMAGE', + id: '75aedb19-2afd-4e6d-87fc-67941256fa60' + }, + + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '8e01b590-7eb9-431b-a239-860e086c408e' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, _user, token) => { + const user = await Users.findOneOrFail(_user.id); + const isSecure = token == null; + + const updates = {} as Partial; + const profileUpdates = {} as Partial; + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } + if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; + if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + + if (ps.avatarId) { + const avatar = await DriveFiles.findOne(ps.avatarId); + + if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true); + + if (avatar.blurhash) { + updates.avatarBlurhash = avatar.blurhash; + } + } + + if (ps.bannerId) { + const banner = await DriveFiles.findOne(ps.bannerId); + + if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerUrl = DriveFiles.getPublicUrl(banner, false); + + if (banner.blurhash) { + updates.bannerBlurhash = banner.blurhash; + } + } + + if (ps.pinnedPageId) { + const page = await Pages.findOne(ps.pinnedPageId); + + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } + + //#region emojis/tags + + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? user.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + const tokens = mfm.parsePlain(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + updateUsertags(user, tags); + //#endregion + + if (Object.keys(updates).length > 0) await Users.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await UserProfiles.update(user.id, profileUpdates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + publishUserEvent(user.id, 'updateUserProfile', await UserProfiles.findOne(user.id)); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && ps.isLocked === false) { + acceptAllFollowRequests(user); + } + + // フォロワーにUpdateを配信 + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000000..1ebde243ca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { UserGroupInvitations } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'groups'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + group: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(UserGroupInvitations.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere(`invitation.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); + + const invitations = await query + .take(ps.limit!) + .getMany(); + + return await UserGroupInvitations.packMany(invitations); +}); -- cgit v1.2.3-freya