diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/common | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/common')
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 }; +} |