summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/i
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/endpoints/i
parentupdate deps (diff)
downloadsharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/endpoints/i')
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/done.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts150
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/password-less.ts21
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts59
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts32
-rw-r--r--packages/backend/src/server/api/endpoints/i/apps.ts43
-rw-r--r--packages/backend/src/server/api/endpoints/i/authorized-apps.ts44
-rw-r--r--packages/backend/src/server/api/endpoints/i/change-password.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/i/delete-account.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-blocking.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-following.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-mute.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-notes.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i/export-user-lists.ts16
-rw-r--r--packages/backend/src/server/api/endpoints/i/favorites.ts50
-rw-r--r--packages/backend/src/server/api/endpoints/i/gallery/likes.ts57
-rw-r--r--packages/backend/src/server/api/endpoints/i/gallery/posts.ts49
-rw-r--r--packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts33
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-blocking.ts60
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-following.ts59
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-muting.ts60
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-user-lists.ts59
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts138
-rw-r--r--packages/backend/src/server/api/endpoints/i/page-likes.ts57
-rw-r--r--packages/backend/src/server/api/endpoints/i/pages.ts49
-rw-r--r--packages/backend/src/server/api/endpoints/i/pin.ts59
-rw-r--r--packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts37
-rw-r--r--packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts25
-rw-r--r--packages/backend/src/server/api/endpoints/i/read-announcement.ts60
-rw-r--r--packages/backend/src/server/api/endpoints/i/regenerate-token.ts44
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/get-all.ts33
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/get-detail.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/get.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/keys.ts28
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/remove.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/scopes.ts29
-rw-r--r--packages/backend/src/server/api/endpoints/i/registry/set.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/i/revoke-token.ts31
-rw-r--r--packages/backend/src/server/api/endpoints/i/signin-history.ts35
-rw-r--r--packages/backend/src/server/api/endpoints/i/unpin.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts94
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts294
-rw-r--r--packages/backend/src/server/api/endpoints/i/user-group-invites.ts61
46 files changed, 2442 insertions, 0 deletions
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<number, any> = 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<string, any>;
+
+ 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<string, string>;
+
+ 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:<br><a href="${link}">${link}</a>`,
+ `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<User>;
+ const profileUpdates = {} as Partial<UserProfile>;
+
+ 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);
+});