summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/server/api/common')
-rw-r--r--packages/backend/src/server/api/common/generate-block-query.ts42
-rw-r--r--packages/backend/src/server/api/common/generate-channel-query.ts24
-rw-r--r--packages/backend/src/server/api/common/generate-muted-note-query.ts13
-rw-r--r--packages/backend/src/server/api/common/generate-muted-note-thread-query.ts17
-rw-r--r--packages/backend/src/server/api/common/generate-muted-user-query.ts40
-rw-r--r--packages/backend/src/server/api/common/generate-native-user-token.ts3
-rw-r--r--packages/backend/src/server/api/common/generate-replies-query.ts27
-rw-r--r--packages/backend/src/server/api/common/generate-visibility-query.ts40
-rw-r--r--packages/backend/src/server/api/common/getters.ts56
-rw-r--r--packages/backend/src/server/api/common/inject-featured.ts56
-rw-r--r--packages/backend/src/server/api/common/inject-promo.ts34
-rw-r--r--packages/backend/src/server/api/common/is-native-token.ts1
-rw-r--r--packages/backend/src/server/api/common/make-pagination-query.ts28
-rw-r--r--packages/backend/src/server/api/common/read-messaging-message.ts122
-rw-r--r--packages/backend/src/server/api/common/read-notification.ts43
-rw-r--r--packages/backend/src/server/api/common/signin.ts44
-rw-r--r--packages/backend/src/server/api/common/signup.ts113
17 files changed, 703 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts
new file mode 100644
index 0000000000..4fd6184738
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-block-query.ts
@@ -0,0 +1,42 @@
+import { User } from '@/models/entities/user';
+import { Blockings } from '@/models/index';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+// ここでいうBlockedは被Blockedの意
+export function generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const blockingQuery = Blockings.createQueryBuilder('blocking')
+ .select('blocking.blockerId')
+ .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
+
+ // 投稿の作者にブロックされていない かつ
+ // 投稿の返信先の作者にブロックされていない かつ
+ // 投稿の引用元の作者にブロックされていない
+ q
+ .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.replyUserId IS NULL`)
+ .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.renoteUserId IS NULL`)
+ .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
+ }));
+
+ q.setParameters(blockingQuery.getParameters());
+}
+
+export function generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const blockingQuery = Blockings.createQueryBuilder('blocking')
+ .select('blocking.blockeeId')
+ .where('blocking.blockerId = :blockerId', { blockerId: me.id });
+
+ const blockedQuery = Blockings.createQueryBuilder('blocking')
+ .select('blocking.blockerId')
+ .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
+
+ q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
+ q.setParameters(blockingQuery.getParameters());
+
+ q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
+ q.setParameters(blockedQuery.getParameters());
+}
diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts
new file mode 100644
index 0000000000..80a0acf7f9
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-channel-query.ts
@@ -0,0 +1,24 @@
+import { User } from '@/models/entities/user';
+import { ChannelFollowings } from '@/models/index';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) {
+ if (me == null) {
+ q.andWhere('note.channelId IS NULL');
+ } else {
+ q.leftJoinAndSelect('note.channel', 'channel');
+
+ const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing')
+ .select('channelFollowing.followeeId')
+ .where('channelFollowing.followerId = :followerId', { followerId: me.id });
+
+ q.andWhere(new Brackets(qb => { qb
+ // チャンネルのノートではない
+ .where('note.channelId IS NULL')
+ // または自分がフォローしているチャンネルのノート
+ .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
+ }));
+
+ q.setParameters(channelFollowingQuery.getParameters());
+ }
+}
diff --git a/packages/backend/src/server/api/common/generate-muted-note-query.ts b/packages/backend/src/server/api/common/generate-muted-note-query.ts
new file mode 100644
index 0000000000..0737842613
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-muted-note-query.ts
@@ -0,0 +1,13 @@
+import { User } from '@/models/entities/user';
+import { MutedNotes } from '@/models/index';
+import { SelectQueryBuilder } from 'typeorm';
+
+export function generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const mutedQuery = MutedNotes.createQueryBuilder('muted')
+ .select('muted.noteId')
+ .where('muted.userId = :userId', { userId: me.id });
+
+ q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
+
+ q.setParameters(mutedQuery.getParameters());
+}
diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts
new file mode 100644
index 0000000000..7e2cbd498b
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts
@@ -0,0 +1,17 @@
+import { User } from '@/models/entities/user';
+import { NoteThreadMutings } from '@/models/index';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted')
+ .select('threadMuted.threadId')
+ .where('threadMuted.userId = :userId', { userId: me.id });
+
+ q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
+ q.andWhere(new Brackets(qb => { qb
+ .where(`note.threadId IS NULL`)
+ .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
+ }));
+
+ q.setParameters(mutedQuery.getParameters());
+}
diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts
new file mode 100644
index 0000000000..7e200b87ef
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts
@@ -0,0 +1,40 @@
+import { User } from '@/models/entities/user';
+import { Mutings } from '@/models/index';
+import { SelectQueryBuilder, Brackets } from 'typeorm';
+
+export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User) {
+ const mutingQuery = Mutings.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: me.id });
+
+ if (exclude) {
+ mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
+ }
+
+ // 投稿の作者をミュートしていない かつ
+ // 投稿の返信先の作者をミュートしていない かつ
+ // 投稿の引用元の作者をミュートしていない
+ q
+ .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.replyUserId IS NULL`)
+ .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
+ }))
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.renoteUserId IS NULL`)
+ .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
+ }));
+
+ q.setParameters(mutingQuery.getParameters());
+}
+
+export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
+ const mutingQuery = Mutings.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: me.id });
+
+ q
+ .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
+
+ q.setParameters(mutingQuery.getParameters());
+}
diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/server/api/common/generate-native-user-token.ts
new file mode 100644
index 0000000000..1f791c57ce
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-native-user-token.ts
@@ -0,0 +1,3 @@
+import { secureRndstr } from '@/misc/secure-rndstr';
+
+export default () => secureRndstr(16, true);
diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts
new file mode 100644
index 0000000000..fbc41b2c25
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-replies-query.ts
@@ -0,0 +1,27 @@
+import { User } from '@/models/entities/user';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) {
+ if (me == null) {
+ q.andWhere(new Brackets(qb => { qb
+ .where(`note.replyId IS NULL`) // 返信ではない
+ .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
+ .where(`note.replyId IS NOT NULL`)
+ .andWhere('note.replyUserId = note.userId');
+ }));
+ }));
+ } else {
+ q.andWhere(new Brackets(qb => { qb
+ .where(`note.replyId IS NULL`) // 返信ではない
+ .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
+ .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
+ .where(`note.replyId IS NOT NULL`)
+ .andWhere('note.userId = :meId', { meId: me.id });
+ }))
+ .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
+ .where(`note.replyId IS NOT NULL`)
+ .andWhere('note.replyUserId = note.userId');
+ }));
+ }));
+ }
+}
diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts
new file mode 100644
index 0000000000..813e8b6c09
--- /dev/null
+++ b/packages/backend/src/server/api/common/generate-visibility-query.ts
@@ -0,0 +1,40 @@
+import { User } from '@/models/entities/user';
+import { Followings } from '@/models/index';
+import { Brackets, SelectQueryBuilder } from 'typeorm';
+
+export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) {
+ if (me == null) {
+ q.andWhere(new Brackets(qb => { qb
+ .where(`note.visibility = 'public'`)
+ .orWhere(`note.visibility = 'home'`);
+ }));
+ } else {
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: me.id });
+
+ q.andWhere(new Brackets(qb => { qb
+ // 公開投稿である
+ .where(new Brackets(qb => { qb
+ .where(`note.visibility = 'public'`)
+ .orWhere(`note.visibility = 'home'`);
+ }))
+ // または 自分自身
+ .orWhere('note.userId = :userId1', { userId1: me.id })
+ // または 自分宛て
+ .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`)
+ .orWhere(new Brackets(qb => { qb
+ // または フォロワー宛ての投稿であり、
+ .where('note.visibility = \'followers\'')
+ .andWhere(new Brackets(qb => { qb
+ // 自分がフォロワーである
+ .where(`note.userId IN (${ followingQuery.getQuery() })`)
+ // または 自分の投稿へのリプライ
+ .orWhere('note.replyUserId = :userId3', { userId3: me.id });
+ }));
+ }));
+ }));
+
+ q.setParameters(followingQuery.getParameters());
+ }
+}
diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts
new file mode 100644
index 0000000000..4b2ee8f1da
--- /dev/null
+++ b/packages/backend/src/server/api/common/getters.ts
@@ -0,0 +1,56 @@
+import { IdentifiableError } from '@/misc/identifiable-error';
+import { User } from '@/models/entities/user';
+import { Note } from '@/models/entities/note';
+import { Notes, Users } from '@/models/index';
+
+/**
+ * Get note for API processing
+ */
+export async function getNote(noteId: Note['id']) {
+ const note = await Notes.findOne(noteId);
+
+ if (note == null) {
+ throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
+ }
+
+ return note;
+}
+
+/**
+ * Get user for API processing
+ */
+export async function getUser(userId: User['id']) {
+ const user = await Users.findOne(userId);
+
+ if (user == null) {
+ throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.');
+ }
+
+ return user;
+}
+
+/**
+ * Get remote user for API processing
+ */
+export async function getRemoteUser(userId: User['id']) {
+ const user = await getUser(userId);
+
+ if (!Users.isRemoteUser(user)) {
+ throw new Error('user is not a remote user');
+ }
+
+ return user;
+}
+
+/**
+ * Get local user for API processing
+ */
+export async function getLocalUser(userId: User['id']) {
+ const user = await getUser(userId);
+
+ if (!Users.isLocalUser(user)) {
+ throw new Error('user is not a local user');
+ }
+
+ return user;
+}
diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts
new file mode 100644
index 0000000000..1dc13c83ef
--- /dev/null
+++ b/packages/backend/src/server/api/common/inject-featured.ts
@@ -0,0 +1,56 @@
+import rndstr from 'rndstr';
+import { Note } from '@/models/entities/note';
+import { User } from '@/models/entities/user';
+import { Notes, UserProfiles, NoteReactions } from '@/models/index';
+import { generateMutedUserQuery } from './generate-muted-user-query';
+import { generateBlockedUserQuery } from './generate-block-query';
+
+// TODO: リアクション、Renote、返信などをしたノートは除外する
+
+export async function injectFeatured(timeline: Note[], user?: User | null) {
+ if (timeline.length < 5) return;
+
+ if (user) {
+ const profile = await UserProfiles.findOneOrFail(user.id);
+ if (!profile.injectFeaturedNote) return;
+ }
+
+ const max = 30;
+ const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
+
+ const query = Notes.createQueryBuilder('note')
+ .addSelect('note.score')
+ .where('note.userHost IS NULL')
+ .andWhere(`note.score > 0`)
+ .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) })
+ .andWhere(`note.visibility = 'public'`)
+ .innerJoinAndSelect('note.user', 'user');
+
+ if (user) {
+ query.andWhere('note.userId != :userId', { userId: user.id });
+
+ generateMutedUserQuery(query, user);
+ generateBlockedUserQuery(query, user);
+
+ const reactionQuery = NoteReactions.createQueryBuilder('reaction')
+ .select('reaction.noteId')
+ .where('reaction.userId = :userId', { userId: user.id });
+
+ query.andWhere(`note.id NOT IN (${ reactionQuery.getQuery() })`);
+ }
+
+ const notes = await query
+ .orderBy('note.score', 'DESC')
+ .take(max)
+ .getMany();
+
+ if (notes.length === 0) return;
+
+ // Pick random one
+ const featured = notes[Math.floor(Math.random() * notes.length)];
+
+ (featured as any)._featuredId_ = rndstr('a-z0-9', 8);
+
+ // Inject featured
+ timeline.splice(3, 0, featured);
+}
diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts
new file mode 100644
index 0000000000..87767a65bf
--- /dev/null
+++ b/packages/backend/src/server/api/common/inject-promo.ts
@@ -0,0 +1,34 @@
+import rndstr from 'rndstr';
+import { Note } from '@/models/entities/note';
+import { User } from '@/models/entities/user';
+import { PromoReads, PromoNotes, Notes, Users } from '@/models/index';
+
+export async function injectPromo(timeline: Note[], user?: User | null) {
+ if (timeline.length < 5) return;
+
+ // TODO: readやexpireフィルタはクエリ側でやる
+
+ const reads = user ? await PromoReads.find({
+ userId: user.id
+ }) : [];
+
+ let promos = await PromoNotes.find();
+
+ promos = promos.filter(n => n.expiresAt.getTime() > Date.now());
+ promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId));
+
+ if (promos.length === 0) return;
+
+ // Pick random promo
+ const promo = promos[Math.floor(Math.random() * promos.length)];
+
+ const note = await Notes.findOneOrFail(promo.noteId);
+
+ // Join
+ note.user = await Users.findOneOrFail(note.userId);
+
+ (note as any)._prId_ = rndstr('a-z0-9', 8);
+
+ // Inject promo
+ timeline.splice(3, 0, note);
+}
diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/server/api/common/is-native-token.ts
new file mode 100644
index 0000000000..2833c570c8
--- /dev/null
+++ b/packages/backend/src/server/api/common/is-native-token.ts
@@ -0,0 +1 @@
+export default (token: string) => token.length === 16;
diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts
new file mode 100644
index 0000000000..51c11e5dff
--- /dev/null
+++ b/packages/backend/src/server/api/common/make-pagination-query.ts
@@ -0,0 +1,28 @@
+import { SelectQueryBuilder } from 'typeorm';
+
+export function makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number) {
+ if (sinceId && untilId) {
+ q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
+ q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
+ q.orderBy(`${q.alias}.id`, 'DESC');
+ } else if (sinceId) {
+ q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
+ q.orderBy(`${q.alias}.id`, 'ASC');
+ } else if (untilId) {
+ q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
+ q.orderBy(`${q.alias}.id`, 'DESC');
+ } else if (sinceDate && untilDate) {
+ q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
+ q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'DESC');
+ } else if (sinceDate) {
+ q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'ASC');
+ } else if (untilDate) {
+ q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
+ q.orderBy(`${q.alias}.createdAt`, 'DESC');
+ } else {
+ q.orderBy(`${q.alias}.id`, 'DESC');
+ }
+ return q;
+}
diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts
new file mode 100644
index 0000000000..33f41b2770
--- /dev/null
+++ b/packages/backend/src/server/api/common/read-messaging-message.ts
@@ -0,0 +1,122 @@
+import { publishMainStream, publishGroupMessagingStream } from '@/services/stream';
+import { publishMessagingStream } from '@/services/stream';
+import { publishMessagingIndexStream } from '@/services/stream';
+import { User, IRemoteUser } from '@/models/entities/user';
+import { MessagingMessage } from '@/models/entities/messaging-message';
+import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index';
+import { In } from 'typeorm';
+import { IdentifiableError } from '@/misc/identifiable-error';
+import { UserGroup } from '@/models/entities/user-group';
+import { toArray } from '@/prelude/array';
+import { renderReadActivity } from '@/remote/activitypub/renderer/read';
+import { renderActivity } from '@/remote/activitypub/renderer/index';
+import { deliver } from '@/queue/index';
+import orderedCollection from '@/remote/activitypub/renderer/ordered-collection';
+
+/**
+ * Mark messages as read
+ */
+export async function readUserMessagingMessage(
+ userId: User['id'],
+ otherpartyId: User['id'],
+ messageIds: MessagingMessage['id'][]
+) {
+ if (messageIds.length === 0) return;
+
+ const messages = await MessagingMessages.find({
+ id: In(messageIds)
+ });
+
+ for (const message of messages) {
+ if (message.recipientId !== userId) {
+ throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).');
+ }
+ }
+
+ // Update documents
+ await MessagingMessages.update({
+ id: In(messageIds),
+ userId: otherpartyId,
+ recipientId: userId,
+ isRead: false
+ }, {
+ isRead: true
+ });
+
+ // Publish event
+ publishMessagingStream(otherpartyId, userId, 'read', messageIds);
+ publishMessagingIndexStream(userId, 'read', messageIds);
+
+ if (!await Users.getHasUnreadMessagingMessage(userId)) {
+ // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
+ publishMainStream(userId, 'readAllMessagingMessages');
+ }
+}
+
+/**
+ * Mark messages as read
+ */
+export async function readGroupMessagingMessage(
+ userId: User['id'],
+ groupId: UserGroup['id'],
+ messageIds: MessagingMessage['id'][]
+) {
+ if (messageIds.length === 0) return;
+
+ // check joined
+ const joining = await UserGroupJoinings.findOne({
+ userId: userId,
+ userGroupId: groupId
+ });
+
+ if (joining == null) {
+ throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).');
+ }
+
+ const messages = await MessagingMessages.find({
+ id: In(messageIds)
+ });
+
+ const reads: MessagingMessage['id'][] = [];
+
+ for (const message of messages) {
+ if (message.userId === userId) continue;
+ if (message.reads.includes(userId)) continue;
+
+ // Update document
+ await MessagingMessages.createQueryBuilder().update()
+ .set({
+ reads: (() => `array_append("reads", '${joining.userId}')`) as any
+ })
+ .where('id = :id', { id: message.id })
+ .execute();
+
+ reads.push(message.id);
+ }
+
+ // Publish event
+ publishGroupMessagingStream(groupId, 'read', {
+ ids: reads,
+ userId: userId
+ });
+ publishMessagingIndexStream(userId, 'read', reads);
+
+ if (!await Users.getHasUnreadMessagingMessage(userId)) {
+ // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
+ publishMainStream(userId, 'readAllMessagingMessages');
+ }
+}
+
+export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) {
+ messages = toArray(messages).filter(x => x.uri);
+ const contents = messages.map(x => renderReadActivity(user, x));
+
+ if (contents.length > 1) {
+ const collection = orderedCollection(null, contents.length, undefined, undefined, contents);
+ deliver(user, renderActivity(collection), recipient.inbox);
+ } else {
+ for (const content of contents) {
+ deliver(user, renderActivity(content), recipient.inbox);
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts
new file mode 100644
index 0000000000..a4406c9eeb
--- /dev/null
+++ b/packages/backend/src/server/api/common/read-notification.ts
@@ -0,0 +1,43 @@
+import { publishMainStream } from '@/services/stream';
+import { User } from '@/models/entities/user';
+import { Notification } from '@/models/entities/notification';
+import { Notifications, Users } from '@/models/index';
+import { In } from 'typeorm';
+
+export async function readNotification(
+ userId: User['id'],
+ notificationIds: Notification['id'][]
+) {
+ // Update documents
+ await Notifications.update({
+ id: In(notificationIds),
+ isRead: false
+ }, {
+ isRead: true
+ });
+
+ post(userId);
+}
+
+export async function readNotificationByQuery(
+ userId: User['id'],
+ query: Record<string, any>
+) {
+ // Update documents
+ await Notifications.update({
+ ...query,
+ notifieeId: userId,
+ isRead: false
+ }, {
+ isRead: true
+ });
+
+ post(userId);
+}
+
+async function post(userId: User['id']) {
+ if (!await Users.getHasUnreadNotification(userId)) {
+ // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
+ publishMainStream(userId, 'readAllNotifications');
+ }
+}
diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts
new file mode 100644
index 0000000000..4c7aacf1cd
--- /dev/null
+++ b/packages/backend/src/server/api/common/signin.ts
@@ -0,0 +1,44 @@
+import * as Koa from 'koa';
+
+import config from '@/config/index';
+import { ILocalUser } from '@/models/entities/user';
+import { Signins } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+import { publishMainStream } from '@/services/stream';
+
+export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) {
+ if (redirect) {
+ //#region Cookie
+ ctx.cookies.set('igi', user.token, {
+ path: '/',
+ // SEE: https://github.com/koajs/koa/issues/974
+ // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header
+ secure: config.url.startsWith('https'),
+ httpOnly: false
+ });
+ //#endregion
+
+ ctx.redirect(config.url);
+ } else {
+ ctx.body = {
+ id: user.id,
+ i: user.token
+ };
+ ctx.status = 200;
+ }
+
+ (async () => {
+ // Append signin history
+ const record = await Signins.save({
+ id: genId(),
+ createdAt: new Date(),
+ userId: user.id,
+ ip: ctx.ip,
+ headers: ctx.headers,
+ success: true
+ });
+
+ // Publish signin event
+ publishMainStream(user.id, 'signin', await Signins.pack(record));
+ })();
+}
diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts
new file mode 100644
index 0000000000..2ba0d8e479
--- /dev/null
+++ b/packages/backend/src/server/api/common/signup.ts
@@ -0,0 +1,113 @@
+import * as bcrypt from 'bcryptjs';
+import { generateKeyPair } from 'crypto';
+import generateUserToken from './generate-native-user-token';
+import { User } from '@/models/entities/user';
+import { Users, UsedUsernames } from '@/models/index';
+import { UserProfile } from '@/models/entities/user-profile';
+import { getConnection } from 'typeorm';
+import { genId } from '@/misc/gen-id';
+import { toPunyNullable } from '@/misc/convert-host';
+import { UserKeypair } from '@/models/entities/user-keypair';
+import { usersChart } from '@/services/chart/index';
+import { UsedUsername } from '@/models/entities/used-username';
+
+export async function signup(opts: {
+ username: User['username'];
+ password?: string | null;
+ passwordHash?: UserProfile['password'] | null;
+ host?: string | null;
+}) {
+ const { username, password, passwordHash, host } = opts;
+ let hash = passwordHash;
+
+ // Validate username
+ if (!Users.validateLocalUsername.ok(username)) {
+ throw new Error('INVALID_USERNAME');
+ }
+
+ if (password != null && passwordHash == null) {
+ // Validate password
+ if (!Users.validatePassword.ok(password)) {
+ throw new Error('INVALID_PASSWORD');
+ }
+
+ // Generate hash of password
+ const salt = await bcrypt.genSalt(8);
+ hash = await bcrypt.hash(password, salt);
+ }
+
+ // Generate secret
+ const secret = generateUserToken();
+
+ // Check username duplication
+ if (await Users.findOne({ usernameLower: username.toLowerCase(), host: null })) {
+ throw new Error('DUPLICATED_USERNAME');
+ }
+
+ // Check deleted username duplication
+ if (await UsedUsernames.findOne({ username: username.toLowerCase() })) {
+ throw new Error('USED_USERNAME');
+ }
+
+ const keyPair = await new Promise<string[]>((res, rej) =>
+ generateKeyPair('rsa', {
+ modulusLength: 4096,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem'
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ cipher: undefined,
+ passphrase: undefined
+ }
+ } as any, (err, publicKey, privateKey) =>
+ err ? rej(err) : res([publicKey, privateKey])
+ ));
+
+ let account!: User;
+
+ // Start transaction
+ await getConnection().transaction(async transactionalEntityManager => {
+ const exist = await transactionalEntityManager.findOne(User, {
+ usernameLower: username.toLowerCase(),
+ host: null
+ });
+
+ if (exist) throw new Error(' the username is already used');
+
+ account = await transactionalEntityManager.save(new User({
+ id: genId(),
+ createdAt: new Date(),
+ username: username,
+ usernameLower: username.toLowerCase(),
+ host: toPunyNullable(host),
+ token: secret,
+ isAdmin: (await Users.count({
+ host: null,
+ })) === 0,
+ }));
+
+ await transactionalEntityManager.save(new UserKeypair({
+ publicKey: keyPair[0],
+ privateKey: keyPair[1],
+ userId: account.id
+ }));
+
+ await transactionalEntityManager.save(new UserProfile({
+ userId: account.id,
+ autoAcceptFollowed: true,
+ password: hash,
+ }));
+
+ await transactionalEntityManager.save(new UsedUsername({
+ createdAt: new Date(),
+ username: username.toLowerCase(),
+ }));
+ });
+
+ usersChart.update(account, true);
+
+ return { account, secret };
+}