なんかもうめっちゃ変えた
This commit is contained in:
		
							
								
								
									
										584
									
								
								packages/backend/src/server/ActivityPubServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										584
									
								
								packages/backend/src/server/ActivityPubServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,584 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Router from '@koa/router'; | ||||
| import json from 'koa-json-body'; | ||||
| import httpSignature from '@peertube/http-signature'; | ||||
| import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; | ||||
| import * as url from '@/misc/prelude/url.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import type { ILocalUser, User } from '@/models/entities/User.js'; | ||||
| import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; | ||||
| import type { Following } from '@/models/entities/Following.js'; | ||||
| import { countIf } from '@/misc/prelude/array.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import type { FindOptionsWhere } from 'typeorm'; | ||||
|  | ||||
| const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; | ||||
| const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ActivityPubServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		@Inject(DI.noteReactionsRepository) | ||||
| 		private noteReactionsRepository: NoteReactionsRepository, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 		@Inject(DI.userNotePiningsRepository) | ||||
| 		private userNotePiningsRepository: UserNotePiningsRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		private utilityService: UtilityService, | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 		private queueService: QueueService, | ||||
| 		private userKeypairStoreService: UserKeypairStoreService, | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	#setResponseType(ctx: Router.RouterContext) { | ||||
| 		const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); | ||||
| 		if (accept === LD_JSON) { | ||||
| 			ctx.response.type = LD_JSON; | ||||
| 		} else { | ||||
| 			ctx.response.type = ACTIVITY_JSON; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Pack Create<Note> or Announce Activity | ||||
| 	 * @param note Note | ||||
| 	 */ | ||||
| 	async #packActivity(note: Note): Promise<any> { | ||||
| 		if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { | ||||
| 			const renote = await Notes.findOneByOrFail({ id: note.renoteId }); | ||||
| 			return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); | ||||
| 		} | ||||
|  | ||||
| 		return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); | ||||
| 	} | ||||
|  | ||||
| 	#inbox(ctx: Router.RouterContext) { | ||||
| 		let signature; | ||||
|  | ||||
| 		try { | ||||
| 			signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); | ||||
| 		} catch (e) { | ||||
| 			ctx.status = 401; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this.queueService.inbox(ctx.request.body, signature); | ||||
|  | ||||
| 		ctx.status = 202; | ||||
| 	} | ||||
|  | ||||
| 	async #followers(ctx: Router.RouterContext) { | ||||
| 		const userId = ctx.params.user; | ||||
|  | ||||
| 		const cursor = ctx.request.query.cursor; | ||||
| 		if (cursor != null && typeof cursor !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const page = ctx.request.query.page === 'true'; | ||||
|  | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			ctx.status = 404; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		//#region Check ff visibility | ||||
| 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | ||||
|  | ||||
| 		if (profile.ffVisibility === 'private') { | ||||
| 			ctx.status = 403; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 			return; | ||||
| 		} else if (profile.ffVisibility === 'followers') { | ||||
| 			ctx.status = 403; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 			return; | ||||
| 		} | ||||
| 		//#endregion | ||||
|  | ||||
| 		const limit = 10; | ||||
| 		const partOf = `${this.config.url}/users/${userId}/followers`; | ||||
|  | ||||
| 		if (page) { | ||||
| 			const query = { | ||||
| 				followeeId: user.id, | ||||
| 			} as FindOptionsWhere<Following>; | ||||
|  | ||||
| 			// カーソルが指定されている場合 | ||||
| 			if (cursor) { | ||||
| 				query.id = LessThan(cursor); | ||||
| 			} | ||||
|  | ||||
| 			// Get followers | ||||
| 			const followings = await this.followingsRepository.find({ | ||||
| 				where: query, | ||||
| 				take: limit + 1, | ||||
| 				order: { id: -1 }, | ||||
| 			}); | ||||
|  | ||||
| 			// 「次のページ」があるかどうか | ||||
| 			const inStock = followings.length === limit + 1; | ||||
| 			if (inStock) followings.pop(); | ||||
|  | ||||
| 			const renderedFollowers = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followerId))); | ||||
| 			const rendered = this.apRendererService.renderOrderedCollectionPage( | ||||
| 				`${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					cursor, | ||||
| 				})}`, | ||||
| 				user.followersCount, renderedFollowers, partOf, | ||||
| 				undefined, | ||||
| 				inStock ? `${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					cursor: followings[followings.length - 1].id, | ||||
| 				})}` : undefined, | ||||
| 			); | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} else { | ||||
| 			// index page | ||||
| 			const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async #following(ctx: Router.RouterContext) { | ||||
| 		const userId = ctx.params.user; | ||||
|  | ||||
| 		const cursor = ctx.request.query.cursor; | ||||
| 		if (cursor != null && typeof cursor !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		const page = ctx.request.query.page === 'true'; | ||||
| 	 | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
| 	 | ||||
| 		if (user == null) { | ||||
| 			ctx.status = 404; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		//#region Check ff visibility | ||||
| 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | ||||
| 	 | ||||
| 		if (profile.ffVisibility === 'private') { | ||||
| 			ctx.status = 403; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 			return; | ||||
| 		} else if (profile.ffVisibility === 'followers') { | ||||
| 			ctx.status = 403; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 			return; | ||||
| 		} | ||||
| 		//#endregion | ||||
| 	 | ||||
| 		const limit = 10; | ||||
| 		const partOf = `${this.config.url}/users/${userId}/following`; | ||||
| 	 | ||||
| 		if (page) { | ||||
| 			const query = { | ||||
| 				followerId: user.id, | ||||
| 			} as FindOptionsWhere<Following>; | ||||
| 	 | ||||
| 			// カーソルが指定されている場合 | ||||
| 			if (cursor) { | ||||
| 				query.id = LessThan(cursor); | ||||
| 			} | ||||
| 	 | ||||
| 			// Get followings | ||||
| 			const followings = await this.followingsRepository.find({ | ||||
| 				where: query, | ||||
| 				take: limit + 1, | ||||
| 				order: { id: -1 }, | ||||
| 			}); | ||||
| 	 | ||||
| 			// 「次のページ」があるかどうか | ||||
| 			const inStock = followings.length === limit + 1; | ||||
| 			if (inStock) followings.pop(); | ||||
| 	 | ||||
| 			const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); | ||||
| 			const rendered = this.apRendererService.renderOrderedCollectionPage( | ||||
| 				`${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					cursor, | ||||
| 				})}`, | ||||
| 				user.followingCount, renderedFollowees, partOf, | ||||
| 				undefined, | ||||
| 				inStock ? `${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					cursor: followings[followings.length - 1].id, | ||||
| 				})}` : undefined, | ||||
| 			); | ||||
| 	 | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} else { | ||||
| 			// index page | ||||
| 			const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async #featured(ctx: Router.RouterContext) { | ||||
| 		const userId = ctx.params.user; | ||||
|  | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			ctx.status = 404; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const pinings = await this.userNotePiningsRepository.find({ | ||||
| 			where: { userId: user.id }, | ||||
| 			order: { id: 'DESC' }, | ||||
| 		}); | ||||
|  | ||||
| 		const pinnedNotes = await Promise.all(pinings.map(pining => | ||||
| 			this.notesRepository.findOneByOrFail({ id: pining.noteId }))); | ||||
|  | ||||
| 		const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); | ||||
|  | ||||
| 		const rendered = this.apRendererService.renderOrderedCollection( | ||||
| 			`${this.config.url}/users/${userId}/collections/featured`, | ||||
| 			renderedNotes.length, undefined, undefined, renderedNotes, | ||||
| 		); | ||||
|  | ||||
| 		ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		this.#setResponseType(ctx); | ||||
| 	} | ||||
|  | ||||
| 	async #outbox(ctx: Router.RouterContext) { | ||||
| 		const userId = ctx.params.user; | ||||
|  | ||||
| 		const sinceId = ctx.request.query.since_id; | ||||
| 		if (sinceId != null && typeof sinceId !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		const untilId = ctx.request.query.until_id; | ||||
| 		if (untilId != null && typeof untilId !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		const page = ctx.request.query.page === 'true'; | ||||
| 	 | ||||
| 		if (countIf(x => x != null, [sinceId, untilId]) > 1) { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			id: userId, | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
| 	 | ||||
| 		if (user == null) { | ||||
| 			ctx.status = 404; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		const limit = 20; | ||||
| 		const partOf = `${this.config.url}/users/${userId}/outbox`; | ||||
| 	 | ||||
| 		if (page) { | ||||
| 			const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) | ||||
| 				.andWhere('note.userId = :userId', { userId: user.id }) | ||||
| 				.andWhere(new Brackets(qb => { qb | ||||
| 					.where('note.visibility = \'public\'') | ||||
| 					.orWhere('note.visibility = \'home\''); | ||||
| 				})) | ||||
| 				.andWhere('note.localOnly = FALSE'); | ||||
| 	 | ||||
| 			const notes = await query.take(limit).getMany(); | ||||
| 	 | ||||
| 			if (sinceId) notes.reverse(); | ||||
| 	 | ||||
| 			const activities = await Promise.all(notes.map(note => this.#packActivity(note))); | ||||
| 			const rendered = this.apRendererService.renderOrderedCollectionPage( | ||||
| 				`${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					since_id: sinceId, | ||||
| 					until_id: untilId, | ||||
| 				})}`, | ||||
| 				user.notesCount, activities, partOf, | ||||
| 				notes.length ? `${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					since_id: notes[0].id, | ||||
| 				})}` : undefined, | ||||
| 				notes.length ? `${partOf}?${url.query({ | ||||
| 					page: 'true', | ||||
| 					until_id: notes[notes.length - 1].id, | ||||
| 				})}` : undefined, | ||||
| 			); | ||||
| 	 | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} else { | ||||
| 			// index page | ||||
| 			const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount, | ||||
| 				`${partOf}?page=true`, | ||||
| 				`${partOf}?page=true&since_id=000000000000000000000000`, | ||||
| 			); | ||||
| 			ctx.body = this.apRendererService.renderActivity(rendered); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async #userInfo(ctx: Router.RouterContext, user: User | null) { | ||||
| 		if (user == null) { | ||||
| 			ctx.status = 404; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser)); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		this.#setResponseType(ctx); | ||||
| 	} | ||||
|  | ||||
| 	public createRouter() { | ||||
| 		// Init router | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		//#region Routing | ||||
| 		function isActivityPubReq(ctx: Router.RouterContext) { | ||||
| 			ctx.response.vary('Accept'); | ||||
| 			const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); | ||||
| 			return typeof accepted === 'string' && !accepted.match(/html/); | ||||
| 		} | ||||
|  | ||||
| 		// inbox | ||||
| 		router.post('/inbox', json(), ctx => this.#inbox(ctx)); | ||||
| 		router.post('/users/:user/inbox', json(), ctx => this.#inbox(ctx)); | ||||
|  | ||||
| 		// note | ||||
| 		router.get('/notes/:note', async (ctx, next) => { | ||||
| 			if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 			const note = await this.notesRepository.findOneBy({ | ||||
| 				id: ctx.params.note, | ||||
| 				visibility: In(['public' as const, 'home' as const]), | ||||
| 				localOnly: false, | ||||
| 			}); | ||||
|  | ||||
| 			if (note == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// リモートだったらリダイレクト | ||||
| 			if (note.userHost != null) { | ||||
| 				if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { | ||||
| 					ctx.status = 500; | ||||
| 					return; | ||||
| 				} | ||||
| 				ctx.redirect(note.uri); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false)); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		}); | ||||
|  | ||||
| 		// note activity | ||||
| 		router.get('/notes/:note/activity', async ctx => { | ||||
| 			const note = await this.notesRepository.findOneBy({ | ||||
| 				id: ctx.params.note, | ||||
| 				userHost: IsNull(), | ||||
| 				visibility: In(['public' as const, 'home' as const]), | ||||
| 				localOnly: false, | ||||
| 			}); | ||||
|  | ||||
| 			if (note == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(await this.#packActivity(note)); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		}); | ||||
|  | ||||
| 		// outbox | ||||
| 		router.get('/users/:user/outbox', (ctx) => this.#outbox(ctx)); | ||||
|  | ||||
| 		// followers | ||||
| 		router.get('/users/:user/followers', (ctx) => this.#followers(ctx)); | ||||
|  | ||||
| 		// following | ||||
| 		router.get('/users/:user/following', (ctx) => this.#following(ctx)); | ||||
|  | ||||
| 		// featured | ||||
| 		router.get('/users/:user/collections/featured', (ctx) => this.#featured(ctx)); | ||||
|  | ||||
| 		// publickey | ||||
| 		router.get('/users/:user/publickey', async ctx => { | ||||
| 			const userId = ctx.params.user; | ||||
|  | ||||
| 			const user = await this.usersRepository.findOneBy({ | ||||
| 				id: userId, | ||||
| 				host: IsNull(), | ||||
| 			}); | ||||
|  | ||||
| 			if (user == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); | ||||
|  | ||||
| 			if (this.userEntityService.isLocalUser(user)) { | ||||
| 				ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair)); | ||||
| 				ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 				this.#setResponseType(ctx); | ||||
| 			} else { | ||||
| 				ctx.status = 400; | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/users/:user', async (ctx, next) => { | ||||
| 			if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 			const userId = ctx.params.user; | ||||
|  | ||||
| 			const user = await this.usersRepository.findOneBy({ | ||||
| 				id: userId, | ||||
| 				host: IsNull(), | ||||
| 				isSuspended: false, | ||||
| 			}); | ||||
|  | ||||
| 			await this.#userInfo(ctx, user); | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/@:user', async (ctx, next) => { | ||||
| 			if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 			const user = await this.usersRepository.findOneBy({ | ||||
| 				usernameLower: ctx.params.user.toLowerCase(), | ||||
| 				host: IsNull(), | ||||
| 				isSuspended: false, | ||||
| 			}); | ||||
|  | ||||
| 			await this.#userInfo(ctx, user); | ||||
| 		}); | ||||
| 		//#endregion | ||||
|  | ||||
| 		// emoji | ||||
| 		router.get('/emojis/:emoji', async ctx => { | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ | ||||
| 				host: IsNull(), | ||||
| 				name: ctx.params.emoji, | ||||
| 			}); | ||||
|  | ||||
| 			if (emoji == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji)); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		}); | ||||
|  | ||||
| 		// like | ||||
| 		router.get('/likes/:like', async ctx => { | ||||
| 			const reaction = await this.noteReactionsRepository.findOneBy({ id: ctx.params.like }); | ||||
|  | ||||
| 			if (reaction == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); | ||||
|  | ||||
| 			if (note == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note)); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		}); | ||||
|  | ||||
| 		// follow | ||||
| 		router.get('/follows/:follower/:followee', async ctx => { | ||||
| 			// This may be used before the follow is completed, so we do not | ||||
| 			// check if the following exists. | ||||
|  | ||||
| 			const [follower, followee] = await Promise.all([ | ||||
| 				this.usersRepository.findOneBy({ | ||||
| 					id: ctx.params.follower, | ||||
| 					host: IsNull(), | ||||
| 				}), | ||||
| 				this.usersRepository.findOneBy({ | ||||
| 					id: ctx.params.followee, | ||||
| 					host: Not(IsNull()), | ||||
| 				}), | ||||
| 			]); | ||||
|  | ||||
| 			if (follower == null || followee == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 			this.#setResponseType(ctx); | ||||
| 		}); | ||||
|  | ||||
| 		return router; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										178
									
								
								packages/backend/src/server/FileServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								packages/backend/src/server/FileServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import { fileURLToPath } from 'node:url'; | ||||
| import { dirname } from 'node:path'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Koa from 'koa'; | ||||
| import cors from '@koa/cors'; | ||||
| import Router from '@koa/router'; | ||||
| import send from 'koa-send'; | ||||
| import rename from 'rename'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { createTemp } from '@/misc/create-temp.js'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | ||||
| import { StatusError } from '@/misc/status-error.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import { DownloadService } from '@/core/DownloadService.js'; | ||||
| import { ImageProcessingService } from '@/core/ImageProcessingService.js'; | ||||
| import { VideoProcessingService } from '@/core/VideoProcessingService.js'; | ||||
| import { InternalStorageService } from '@/core/InternalStorageService.js'; | ||||
| import { contentDisposition } from '@/misc/content-disposition.js'; | ||||
| import { FileInfoService } from '@/core/FileInfoService.js'; | ||||
|  | ||||
| const serverLogger = new Logger('server', 'gray', false); | ||||
|  | ||||
| const _filename = fileURLToPath(import.meta.url); | ||||
| const _dirname = dirname(_filename); | ||||
|  | ||||
| const assets = `${_dirname}/../../server/file/assets/`; | ||||
|  | ||||
| const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { | ||||
| 	serverLogger.error(e); | ||||
| 	ctx.status = 500; | ||||
| 	ctx.set('Cache-Control', 'max-age=300'); | ||||
| }; | ||||
|  | ||||
| @Injectable() | ||||
| export class FileServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 		private fileInfoService: FileInfoService, | ||||
| 		private downloadService: DownloadService, | ||||
| 		private imageProcessingService: ImageProcessingService, | ||||
| 		private videoProcessingService: VideoProcessingService, | ||||
| 		private internalStorageService: InternalStorageService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public createServer() { | ||||
| 		const app = new Koa(); | ||||
| 		app.use(cors()); | ||||
| 		app.use(async (ctx, next) => { | ||||
| 			ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); | ||||
| 			await next(); | ||||
| 		}); | ||||
|  | ||||
| 		// Init router | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		router.get('/app-default.jpg', ctx => { | ||||
| 			const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); | ||||
| 			ctx.body = file; | ||||
| 			ctx.set('Content-Type', 'image/jpeg'); | ||||
| 			ctx.set('Cache-Control', 'max-age=31536000, immutable'); | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/:key', ctx => this.#sendDriveFile(ctx)); | ||||
| 		router.get('/:key/(.*)', ctx => this.#sendDriveFile(ctx)); | ||||
|  | ||||
| 		// Register router | ||||
| 		app.use(router.routes()); | ||||
|  | ||||
| 		return app; | ||||
| 	} | ||||
|  | ||||
| 	async #sendDriveFile(ctx: Koa.Context) { | ||||
| 		const key = ctx.params.key; | ||||
|  | ||||
| 		// Fetch drive file | ||||
| 		const file = await this.driveFilesRepository.createQueryBuilder('file') | ||||
| 			.where('file.accessKey = :accessKey', { accessKey: key }) | ||||
| 			.orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) | ||||
| 			.orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) | ||||
| 			.getOne(); | ||||
|  | ||||
| 		if (file == null) { | ||||
| 			ctx.status = 404; | ||||
| 			ctx.set('Cache-Control', 'max-age=86400'); | ||||
| 			await send(ctx as any, '/dummy.png', { root: assets }); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const isThumbnail = file.thumbnailAccessKey === key; | ||||
| 		const isWebpublic = file.webpublicAccessKey === key; | ||||
|  | ||||
| 		if (!file.storedInternal) { | ||||
| 			if (file.isLink && file.uri) {	// 期限切れリモートファイル | ||||
| 				const [path, cleanup] = await createTemp(); | ||||
|  | ||||
| 				try { | ||||
| 					await this.downloadService.downloadUrl(file.uri, path); | ||||
|  | ||||
| 					const { mime, ext } = await this.fileInfoService.detectType(path); | ||||
|  | ||||
| 					const convertFile = async () => { | ||||
| 						if (isThumbnail) { | ||||
| 							if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(mime)) { | ||||
| 								return await this.imageProcessingService.convertToWebp(path, 498, 280); | ||||
| 							} else if (mime.startsWith('video/')) { | ||||
| 								return await this.videoProcessingService.generateVideoThumbnail(path); | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						if (isWebpublic) { | ||||
| 							if (['image/svg+xml'].includes(mime)) { | ||||
| 								return await this.imageProcessingService.convertToPng(path, 2048, 2048); | ||||
| 							} | ||||
| 						} | ||||
|  | ||||
| 						return { | ||||
| 							data: fs.readFileSync(path), | ||||
| 							ext, | ||||
| 							type: mime, | ||||
| 						}; | ||||
| 					}; | ||||
|  | ||||
| 					const image = await convertFile(); | ||||
| 					ctx.body = image.data; | ||||
| 					ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); | ||||
| 					ctx.set('Cache-Control', 'max-age=31536000, immutable'); | ||||
| 				} catch (err) { | ||||
| 					serverLogger.error(`${err}`); | ||||
|  | ||||
| 					if (err instanceof StatusError && err.isClientError) { | ||||
| 						ctx.status = err.statusCode; | ||||
| 						ctx.set('Cache-Control', 'max-age=86400'); | ||||
| 					} else { | ||||
| 						ctx.status = 500; | ||||
| 						ctx.set('Cache-Control', 'max-age=300'); | ||||
| 					} | ||||
| 				} finally { | ||||
| 					cleanup(); | ||||
| 				} | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			ctx.status = 204; | ||||
| 			ctx.set('Cache-Control', 'max-age=86400'); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (isThumbnail || isWebpublic) { | ||||
| 			const { mime, ext } = await this.fileInfoService.detectType(this.internalStorageService.resolvePath(key)); | ||||
| 			const filename = rename(file.name, { | ||||
| 				suffix: isThumbnail ? '-thumb' : '-web', | ||||
| 				extname: ext ? `.${ext}` : undefined, | ||||
| 			}).toString(); | ||||
|  | ||||
| 			ctx.body = this.internalStorageService.read(key); | ||||
| 			ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); | ||||
| 			ctx.set('Cache-Control', 'max-age=31536000, immutable'); | ||||
| 			ctx.set('Content-Disposition', contentDisposition('inline', filename)); | ||||
| 		} else { | ||||
| 			const readable = this.internalStorageService.read(file.accessKey!); | ||||
| 			readable.on('error', commonReadableHandlerGenerator(ctx)); | ||||
| 			ctx.body = readable; | ||||
| 			ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); | ||||
| 			ctx.set('Cache-Control', 'max-age=31536000, immutable'); | ||||
| 			ctx.set('Content-Disposition', contentDisposition('inline', file.name)); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										137
									
								
								packages/backend/src/server/MediaProxyServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								packages/backend/src/server/MediaProxyServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Koa from 'koa'; | ||||
| import cors from '@koa/cors'; | ||||
| import Router from '@koa/router'; | ||||
| import sharp from 'sharp'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { isMimeImage } from '@/misc/is-mime-image.js'; | ||||
| import { createTemp } from '@/misc/create-temp.js'; | ||||
| import { DownloadService } from '@/core/DownloadService.js'; | ||||
| import { ImageProcessingService } from '@/core/ImageProcessingService.js'; | ||||
| import type { IImage } from '@/core/ImageProcessingService.js'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | ||||
| import { StatusError } from '@/misc/status-error.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import { FileInfoService } from '@/core/FileInfoService.js'; | ||||
|  | ||||
| const serverLogger = new Logger('server', 'gray', false); | ||||
|  | ||||
| @Injectable() | ||||
| export class MediaProxyServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		private fileInfoService: FileInfoService, | ||||
| 		private downloadService: DownloadService, | ||||
| 		private imageProcessingService: ImageProcessingService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public createServer() { | ||||
| 		const app = new Koa(); | ||||
| 		app.use(cors()); | ||||
| 		app.use(async (ctx, next) => { | ||||
| 			ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); | ||||
| 			await next(); | ||||
| 		}); | ||||
|  | ||||
| 		// Init router | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		router.get('/:url*', ctx => this.#handler(ctx)); | ||||
|  | ||||
| 		// Register router | ||||
| 		app.use(router.routes()); | ||||
|  | ||||
| 		return app; | ||||
| 	} | ||||
|  | ||||
| 	async #handler(ctx: Koa.Context) { | ||||
| 		const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; | ||||
| 	 | ||||
| 		if (typeof url !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
| 	 | ||||
| 		// Create temp file | ||||
| 		const [path, cleanup] = await createTemp(); | ||||
| 	 | ||||
| 		try { | ||||
| 			await this.downloadService.downloadUrl(url, path); | ||||
| 	 | ||||
| 			const { mime, ext } = await this.fileInfoService.detectType(path); | ||||
| 			const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); | ||||
| 	 | ||||
| 			let image: IImage; | ||||
| 	 | ||||
| 			if ('static' in ctx.query && isConvertibleImage) { | ||||
| 				image = await this.imageProcessingService.convertToWebp(path, 498, 280); | ||||
| 			} else if ('preview' in ctx.query && isConvertibleImage) { | ||||
| 				image = await this.imageProcessingService.convertToWebp(path, 200, 200); | ||||
| 			} else if ('badge' in ctx.query) { | ||||
| 				if (!isConvertibleImage) { | ||||
| 					// 画像でないなら404でお茶を濁す | ||||
| 					throw new StatusError('Unexpected mime', 404); | ||||
| 				} | ||||
| 	 | ||||
| 				const mask = sharp(path) | ||||
| 					.resize(96, 96, { | ||||
| 						fit: 'inside', | ||||
| 						withoutEnlargement: false, | ||||
| 					}) | ||||
| 					.greyscale() | ||||
| 					.normalise() | ||||
| 					.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast | ||||
| 					.flatten({ background: '#000' }) | ||||
| 					.toColorspace('b-w'); | ||||
| 	 | ||||
| 				const stats = await mask.clone().stats(); | ||||
| 	 | ||||
| 				if (stats.entropy < 0.1) { | ||||
| 					// エントロピーがあまりない場合は404にする | ||||
| 					throw new StatusError('Skip to provide badge', 404); | ||||
| 				} | ||||
| 	 | ||||
| 				const data = sharp({ | ||||
| 					create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, | ||||
| 				}) | ||||
| 					.pipelineColorspace('b-w') | ||||
| 					.boolean(await mask.png().toBuffer(), 'eor'); | ||||
| 	 | ||||
| 				image = { | ||||
| 					data: await data.png().toBuffer(), | ||||
| 					ext: 'png', | ||||
| 					type: 'image/png', | ||||
| 				}; | ||||
| 			}	else if (mime === 'image/svg+xml') { | ||||
| 				image = await this.imageProcessingService.convertToWebp(path, 2048, 2048, 1); | ||||
| 			} else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { | ||||
| 				throw new StatusError('Rejected type', 403, 'Rejected type'); | ||||
| 			} else { | ||||
| 				image = { | ||||
| 					data: fs.readFileSync(path), | ||||
| 					ext, | ||||
| 					type: mime, | ||||
| 				}; | ||||
| 			} | ||||
| 	 | ||||
| 			ctx.set('Content-Type', image.type); | ||||
| 			ctx.set('Cache-Control', 'max-age=31536000, immutable'); | ||||
| 			ctx.body = image.data; | ||||
| 		} catch (err) { | ||||
| 			serverLogger.error(`${err}`); | ||||
| 	 | ||||
| 			if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { | ||||
| 				ctx.status = err.statusCode; | ||||
| 			} else { | ||||
| 				ctx.status = 500; | ||||
| 			} | ||||
| 		} finally { | ||||
| 			cleanup(); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										129
									
								
								packages/backend/src/server/NodeinfoServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								packages/backend/src/server/NodeinfoServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Router from '@koa/router'; | ||||
| import { IsNull, MoreThan } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { NotesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
|  | ||||
| const nodeinfo2_1path = '/nodeinfo/2.1'; | ||||
| const nodeinfo2_0path = '/nodeinfo/2.0'; | ||||
|  | ||||
| @Injectable() | ||||
| export class NodeinfoServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public getLinks() { | ||||
| 		return [/* (awaiting release) { | ||||
| 			rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', | ||||
| 			href: config.url + nodeinfo2_1path | ||||
| 		}, */{ | ||||
| 				rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', | ||||
| 				href: this.config.url + nodeinfo2_0path, | ||||
| 			}]; | ||||
| 	} | ||||
|  | ||||
| 	public createRouter() { | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		const nodeinfo2 = async () => { | ||||
| 			const now = Date.now(); | ||||
| 			const [ | ||||
| 				meta, | ||||
| 				total, | ||||
| 				activeHalfyear, | ||||
| 				activeMonth, | ||||
| 				localPosts, | ||||
| 			] = await Promise.all([ | ||||
| 				this.metaService.fetch(true), | ||||
| 				this.usersRepository.count({ where: { host: IsNull() } }), | ||||
| 				this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 15552000000)) } }), | ||||
| 				this.usersRepository.count({ where: { host: IsNull(), lastActiveDate: MoreThan(new Date(now - 2592000000)) } }), | ||||
| 				this.notesRepository.count({ where: { userHost: IsNull() } }), | ||||
| 			]); | ||||
|  | ||||
| 			const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; | ||||
|  | ||||
| 			return { | ||||
| 				software: { | ||||
| 					name: 'misskey', | ||||
| 					version: this.config.version, | ||||
| 					repository: meta.repositoryUrl, | ||||
| 				}, | ||||
| 				protocols: ['activitypub'], | ||||
| 				services: { | ||||
| 					inbound: [] as string[], | ||||
| 					outbound: ['atom1.0', 'rss2.0'], | ||||
| 				}, | ||||
| 				openRegistrations: !meta.disableRegistration, | ||||
| 				usage: { | ||||
| 					users: { total, activeHalfyear, activeMonth }, | ||||
| 					localPosts, | ||||
| 					localComments: 0, | ||||
| 				}, | ||||
| 				metadata: { | ||||
| 					nodeName: meta.name, | ||||
| 					nodeDescription: meta.description, | ||||
| 					maintainer: { | ||||
| 						name: meta.maintainerName, | ||||
| 						email: meta.maintainerEmail, | ||||
| 					}, | ||||
| 					langs: meta.langs, | ||||
| 					tosUrl: meta.ToSUrl, | ||||
| 					repositoryUrl: meta.repositoryUrl, | ||||
| 					feedbackUrl: meta.feedbackUrl, | ||||
| 					disableRegistration: meta.disableRegistration, | ||||
| 					disableLocalTimeline: meta.disableLocalTimeline, | ||||
| 					disableGlobalTimeline: meta.disableGlobalTimeline, | ||||
| 					emailRequiredForSignup: meta.emailRequiredForSignup, | ||||
| 					enableHcaptcha: meta.enableHcaptcha, | ||||
| 					enableRecaptcha: meta.enableRecaptcha, | ||||
| 					maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, | ||||
| 					enableTwitterIntegration: meta.enableTwitterIntegration, | ||||
| 					enableGithubIntegration: meta.enableGithubIntegration, | ||||
| 					enableDiscordIntegration: meta.enableDiscordIntegration, | ||||
| 					enableEmail: meta.enableEmail, | ||||
| 					enableServiceWorker: meta.enableServiceWorker, | ||||
| 					proxyAccountName: proxyAccount ? proxyAccount.username : null, | ||||
| 					themeColor: meta.themeColor ?? '#86b300', | ||||
| 				}, | ||||
| 			}; | ||||
| 		}; | ||||
|  | ||||
| 		const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); | ||||
|  | ||||
| 		router.get(nodeinfo2_1path, async ctx => { | ||||
| 			const base = await cache.fetch(null, () => nodeinfo2()); | ||||
|  | ||||
| 			ctx.body = { version: '2.1', ...base }; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=600'); | ||||
| 		}); | ||||
|  | ||||
| 		router.get(nodeinfo2_0path, async ctx => { | ||||
| 			const base = await cache.fetch(null, () => nodeinfo2()); | ||||
|  | ||||
| 			delete base.software.repository; | ||||
|  | ||||
| 			ctx.body = { version: '2.0', ...base }; | ||||
| 			ctx.set('Cache-Control', 'public, max-age=600'); | ||||
| 		}); | ||||
|  | ||||
| 		return router; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										92
									
								
								packages/backend/src/server/ServerModule.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								packages/backend/src/server/ServerModule.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| import { Module } from '@nestjs/common'; | ||||
| import { EndpointsModule } from '@/server/api/EndpointsModule.js'; | ||||
| import { CoreModule } from '@/core/CoreModule.js'; | ||||
| import { ApiCallService } from './api/ApiCallService.js'; | ||||
| import { FileServerService } from './FileServerService.js'; | ||||
| import { MediaProxyServerService } from './MediaProxyServerService.js'; | ||||
| import { NodeinfoServerService } from './NodeinfoServerService.js'; | ||||
| import { ServerService } from './ServerService.js'; | ||||
| import { WellKnownServerService } from './WellKnownServerService.js'; | ||||
| import { GetterService } from './api/common/GetterService.js'; | ||||
| import { DiscordServerService } from './api/integration/DiscordServerService.js'; | ||||
| import { GithubServerService } from './api/integration/GithubServerService.js'; | ||||
| import { TwitterServerService } from './api/integration/TwitterServerService.js'; | ||||
| import { ChannelsService } from './api/stream/ChannelsService.js'; | ||||
| import { ActivityPubServerService } from './ActivityPubServerService.js'; | ||||
| import { ApiLoggerService } from './api/ApiLoggerService.js'; | ||||
| import { ApiServerService } from './api/ApiServerService.js'; | ||||
| import { AuthenticateService } from './api/AuthenticateService.js'; | ||||
| import { RateLimiterService } from './api/RateLimiterService.js'; | ||||
| import { SigninApiService } from './api/SigninApiService.js'; | ||||
| import { SigninService } from './api/SigninService.js'; | ||||
| import { SignupApiService } from './api/SignupApiService.js'; | ||||
| import { StreamingApiServerService } from './api/StreamingApiServerService.js'; | ||||
| import { ClientServerService } from './web/ClientServerService.js'; | ||||
| import { FeedService } from './web/FeedService.js'; | ||||
| import { UrlPreviewService } from './web/UrlPreviewService.js'; | ||||
| import { MainChannelService } from './api/stream/channels/main.js'; | ||||
| import { AdminChannelService } from './api/stream/channels/admin.js'; | ||||
| import { AntennaChannelService } from './api/stream/channels/antenna.js'; | ||||
| import { ChannelChannelService } from './api/stream/channels/channel.js'; | ||||
| import { DriveChannelService } from './api/stream/channels/drive.js'; | ||||
| import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; | ||||
| import { HashtagChannelService } from './api/stream/channels/hashtag.js'; | ||||
| import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; | ||||
| import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; | ||||
| import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; | ||||
| import { MessagingIndexChannelService } from './api/stream/channels/messaging-index.js'; | ||||
| import { MessagingChannelService } from './api/stream/channels/messaging.js'; | ||||
| import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; | ||||
| import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; | ||||
| import { UserListChannelService } from './api/stream/channels/user-list.js'; | ||||
|  | ||||
| @Module({ | ||||
| 	imports: [ | ||||
| 		EndpointsModule, | ||||
| 		CoreModule, | ||||
| 	], | ||||
| 	providers: [ | ||||
| 		ClientServerService, | ||||
| 		FeedService, | ||||
| 		UrlPreviewService, | ||||
| 		ActivityPubServerService, | ||||
| 		FileServerService, | ||||
| 		MediaProxyServerService, | ||||
| 		NodeinfoServerService, | ||||
| 		ServerService, | ||||
| 		WellKnownServerService, | ||||
| 		GetterService, | ||||
| 		DiscordServerService, | ||||
| 		GithubServerService, | ||||
| 		TwitterServerService, | ||||
| 		ChannelsService, | ||||
| 		ApiCallService, | ||||
| 		ApiLoggerService, | ||||
| 		ApiServerService, | ||||
| 		AuthenticateService, | ||||
| 		RateLimiterService, | ||||
| 		SigninApiService, | ||||
| 		SigninService, | ||||
| 		SignupApiService, | ||||
| 		StreamingApiServerService, | ||||
| 		MainChannelService, | ||||
| 		AdminChannelService, | ||||
| 		AntennaChannelService, | ||||
| 		ChannelChannelService, | ||||
| 		DriveChannelService, | ||||
| 		GlobalTimelineChannelService, | ||||
| 		HashtagChannelService, | ||||
| 		HomeTimelineChannelService, | ||||
| 		HybridTimelineChannelService, | ||||
| 		LocalTimelineChannelService, | ||||
| 		MessagingIndexChannelService, | ||||
| 		MessagingChannelService, | ||||
| 		QueueStatsChannelService, | ||||
| 		ServerStatsChannelService, | ||||
| 		UserListChannelService, | ||||
| 	], | ||||
| 	exports: [ | ||||
| 		ServerService, | ||||
| 	], | ||||
| }) | ||||
| export class ServerModule {} | ||||
							
								
								
									
										177
									
								
								packages/backend/src/server/ServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								packages/backend/src/server/ServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| import cluster from 'node:cluster'; | ||||
| import * as fs from 'node:fs'; | ||||
| import * as http from 'node:http'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Koa from 'koa'; | ||||
| import Router from '@koa/router'; | ||||
| import mount from 'koa-mount'; | ||||
| import koaLogger from 'koa-logger'; | ||||
| import * as slow from 'koa-slow'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import { envOption } from '@/env.js'; | ||||
| import * as Acct from '@/misc/acct.js'; | ||||
| import { genIdenticon } from '@/misc/gen-identicon.js'; | ||||
| import { createTemp } from '@/misc/create-temp.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { ActivityPubServerService } from './ActivityPubServerService.js'; | ||||
| import { NodeinfoServerService } from './NodeinfoServerService.js'; | ||||
| import { ApiServerService } from './api/ApiServerService.js'; | ||||
| import { StreamingApiServerService } from './api/StreamingApiServerService.js'; | ||||
| import { WellKnownServerService } from './WellKnownServerService.js'; | ||||
| import { MediaProxyServerService } from './MediaProxyServerService.js'; | ||||
| import { FileServerService } from './FileServerService.js'; | ||||
| import { ClientServerService } from './web/ClientServerService.js'; | ||||
|  | ||||
| const serverLogger = new Logger('server', 'gray', false); | ||||
|  | ||||
| @Injectable() | ||||
| export class ServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private apiServerService: ApiServerService, | ||||
| 		private streamingApiServerService: StreamingApiServerService, | ||||
| 		private activityPubServerService: ActivityPubServerService, | ||||
| 		private wellKnownServerService: WellKnownServerService, | ||||
| 		private nodeinfoServerService: NodeinfoServerService, | ||||
| 		private fileServerService: FileServerService, | ||||
| 		private mediaProxyServerService: MediaProxyServerService, | ||||
| 		private clientServerService: ClientServerService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public launch() { | ||||
| 		// Init app | ||||
| 		const koa = new Koa(); | ||||
| 		koa.proxy = true; | ||||
|  | ||||
| 		if (!['production', 'test'].includes(process.env.NODE_ENV ?? '')) { | ||||
| 		// Logger | ||||
| 			koa.use(koaLogger(str => { | ||||
| 				serverLogger.info(str); | ||||
| 			})); | ||||
|  | ||||
| 			// Delay | ||||
| 			if (envOption.slow) { | ||||
| 				koa.use(slow({ | ||||
| 					delay: 3000, | ||||
| 				})); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// HSTS | ||||
| 		// 6months (15552000sec) | ||||
| 		if (this.config.url.startsWith('https') && !this.config.disableHsts) { | ||||
| 			koa.use(async (ctx, next) => { | ||||
| 				ctx.set('strict-transport-security', 'max-age=15552000; preload'); | ||||
| 				await next(); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		koa.use(mount('/api', this.apiServerService.createApiServer(koa))); | ||||
| 		koa.use(mount('/files', this.fileServerService.createServer())); | ||||
| 		koa.use(mount('/proxy', this.mediaProxyServerService.createServer())); | ||||
|  | ||||
| 		// Init router | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		// Routing | ||||
| 		router.use(this.activityPubServerService.createRouter().routes()); | ||||
| 		router.use(this.nodeinfoServerService.createRouter().routes()); | ||||
| 		router.use(this.wellKnownServerService.createRouter().routes()); | ||||
|  | ||||
| 		router.get('/avatar/@:acct', async ctx => { | ||||
| 			const { username, host } = Acct.parse(ctx.params.acct); | ||||
| 			const user = await this.usersRepository.findOne({ | ||||
| 				where: { | ||||
| 					usernameLower: username.toLowerCase(), | ||||
| 					host: (host == null) || (host === this.config.host) ? IsNull() : host, | ||||
| 					isSuspended: false, | ||||
| 				}, | ||||
| 				relations: ['avatar'], | ||||
| 			}); | ||||
|  | ||||
| 			if (user) { | ||||
| 				ctx.redirect(this.userEntityService.getAvatarUrlSync(user)); | ||||
| 			} else { | ||||
| 				ctx.redirect('/static-assets/user-unknown.png'); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/identicon/:x', async ctx => { | ||||
| 			const [temp, cleanup] = await createTemp(); | ||||
| 			await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); | ||||
| 			ctx.set('Content-Type', 'image/png'); | ||||
| 			ctx.body = fs.createReadStream(temp).on('close', () => cleanup()); | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/verify-email/:code', async ctx => { | ||||
| 			const profile = await this.userProfilesRepository.findOneBy({ | ||||
| 				emailVerifyCode: ctx.params.code, | ||||
| 			}); | ||||
|  | ||||
| 			if (profile != null) { | ||||
| 				ctx.body = 'Verify succeeded!'; | ||||
| 				ctx.status = 200; | ||||
|  | ||||
| 				await this.userProfilesRepository.update({ userId: profile.userId }, { | ||||
| 					emailVerified: true, | ||||
| 					emailVerifyCode: null, | ||||
| 				}); | ||||
|  | ||||
| 				this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { | ||||
| 					detail: true, | ||||
| 					includeSecrets: true, | ||||
| 				})); | ||||
| 			} else { | ||||
| 				ctx.status = 404; | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		// Register router | ||||
| 		koa.use(router.routes()); | ||||
|  | ||||
| 		koa.use(mount(this.clientServerService.createApp())); | ||||
|  | ||||
| 		const server = http.createServer(koa.callback()); | ||||
|  | ||||
| 		this.streamingApiServerService.attachStreamingApi(server); | ||||
|  | ||||
| 		server.on('error', e => { | ||||
| 			switch ((e as any).code) { | ||||
| 				case 'EACCES': | ||||
| 					serverLogger.error(`You do not have permission to listen on port ${this.config.port}.`); | ||||
| 					break; | ||||
| 				case 'EADDRINUSE': | ||||
| 					serverLogger.error(`Port ${this.config.port} is already in use by another process.`); | ||||
| 					break; | ||||
| 				default: | ||||
| 					serverLogger.error(e); | ||||
| 					break; | ||||
| 			} | ||||
|  | ||||
| 			if (cluster.isWorker) { | ||||
| 			process.send!('listenFailed'); | ||||
| 			} else { | ||||
| 			// disableClustering | ||||
| 				process.exit(1); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		server.listen(this.config.port); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										168
									
								
								packages/backend/src/server/WellKnownServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								packages/backend/src/server/WellKnownServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Router from '@koa/router'; | ||||
| import { IsNull, MoreThan } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import * as Acct from '@/misc/acct.js'; | ||||
| import { NodeinfoServerService } from './NodeinfoServerService.js'; | ||||
| import type { FindOptionsWhere } from 'typeorm'; | ||||
|  | ||||
| @Injectable() | ||||
| export class WellKnownServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		private nodeinfoServerService: NodeinfoServerService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public createRouter() { | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		const XRD = (...x: { element: string, value?: string, attributes?: Record<string, string> }[]) => | ||||
| 			`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) => | ||||
| 				`<${ | ||||
| 					Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) | ||||
| 				}${ | ||||
| 					typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/' | ||||
| 				}>`).reduce((a, c) => a + c, '')}</XRD>`; | ||||
|  | ||||
| 		const allPath = '/.well-known/(.*)'; | ||||
| 		const webFingerPath = '/.well-known/webfinger'; | ||||
| 		const jrd = 'application/jrd+json'; | ||||
| 		const xrd = 'application/xrd+xml'; | ||||
|  | ||||
| 		router.use(allPath, async (ctx, next) => { | ||||
| 			ctx.set({ | ||||
| 				'Access-Control-Allow-Headers': 'Accept', | ||||
| 				'Access-Control-Allow-Methods': 'GET, OPTIONS', | ||||
| 				'Access-Control-Allow-Origin': '*', | ||||
| 				'Access-Control-Expose-Headers': 'Vary', | ||||
| 			}); | ||||
| 			await next(); | ||||
| 		}); | ||||
|  | ||||
| 		router.options(allPath, async ctx => { | ||||
| 			ctx.status = 204; | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/.well-known/host-meta', async ctx => { | ||||
| 			ctx.set('Content-Type', xrd); | ||||
| 			ctx.body = XRD({ element: 'Link', attributes: { | ||||
| 				rel: 'lrdd', | ||||
| 				type: xrd, | ||||
| 				template: `${this.config.url}${webFingerPath}?resource={uri}`, | ||||
| 			} }); | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/.well-known/host-meta.json', async ctx => { | ||||
| 			ctx.set('Content-Type', jrd); | ||||
| 			ctx.body = { | ||||
| 				links: [{ | ||||
| 					rel: 'lrdd', | ||||
| 					type: jrd, | ||||
| 					template: `${this.config.url}${webFingerPath}?resource={uri}`, | ||||
| 				}], | ||||
| 			}; | ||||
| 		}); | ||||
|  | ||||
| 		router.get('/.well-known/nodeinfo', async ctx => { | ||||
| 			ctx.body = { links: this.nodeinfoServerService.getLinks() }; | ||||
| 		}); | ||||
|  | ||||
| 		/* TODO | ||||
| router.get('/.well-known/change-password', async ctx => { | ||||
| }); | ||||
| */ | ||||
|  | ||||
| 		router.get(webFingerPath, async ctx => { | ||||
| 			const fromId = (id: User['id']): FindOptionsWhere<User> => ({ | ||||
| 				id, | ||||
| 				host: IsNull(), | ||||
| 				isSuspended: false, | ||||
| 			}); | ||||
|  | ||||
| 			const generateQuery = (resource: string): FindOptionsWhere<User> | number => | ||||
| 				resource.startsWith(`${this.config.url.toLowerCase()}/users/`) ? | ||||
| 					fromId(resource.split('/').pop()!) : | ||||
| 					fromAcct(Acct.parse( | ||||
| 						resource.startsWith(`${this.config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : | ||||
| 						resource.startsWith('acct:') ? resource.slice('acct:'.length) : | ||||
| 						resource)); | ||||
|  | ||||
| 			const fromAcct = (acct: Acct.Acct): FindOptionsWhere<User> | number => | ||||
| 				!acct.host || acct.host === this.config.host.toLowerCase() ? { | ||||
| 					usernameLower: acct.username, | ||||
| 					host: IsNull(), | ||||
| 					isSuspended: false, | ||||
| 				} : 422; | ||||
|  | ||||
| 			if (typeof ctx.query.resource !== 'string') { | ||||
| 				ctx.status = 400; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const query = generateQuery(ctx.query.resource.toLowerCase()); | ||||
|  | ||||
| 			if (typeof query === 'number') { | ||||
| 				ctx.status = query; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const user = await this.usersRepository.findOneBy(query); | ||||
|  | ||||
| 			if (user == null) { | ||||
| 				ctx.status = 404; | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const subject = `acct:${user.username}@${this.config.host}`; | ||||
| 			const self = { | ||||
| 				rel: 'self', | ||||
| 				type: 'application/activity+json', | ||||
| 				href: `${this.config.url}/users/${user.id}`, | ||||
| 			}; | ||||
| 			const profilePage = { | ||||
| 				rel: 'http://webfinger.net/rel/profile-page', | ||||
| 				type: 'text/html', | ||||
| 				href: `${this.config.url}/@${user.username}`, | ||||
| 			}; | ||||
| 			const subscribe = { | ||||
| 				rel: 'http://ostatus.org/schema/1.0/subscribe', | ||||
| 				template: `${this.config.url}/authorize-follow?acct={uri}`, | ||||
| 			}; | ||||
|  | ||||
| 			if (ctx.accepts(jrd, xrd) === xrd) { | ||||
| 				ctx.body = XRD( | ||||
| 					{ element: 'Subject', value: subject }, | ||||
| 					{ element: 'Link', attributes: self }, | ||||
| 					{ element: 'Link', attributes: profilePage }, | ||||
| 					{ element: 'Link', attributes: subscribe }); | ||||
| 				ctx.type = xrd; | ||||
| 			} else { | ||||
| 				ctx.body = { | ||||
| 					subject, | ||||
| 					links: [self, profilePage, subscribe], | ||||
| 				}; | ||||
| 				ctx.type = jrd; | ||||
| 			} | ||||
|  | ||||
| 			ctx.vary('Accept'); | ||||
| 			ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		}); | ||||
|  | ||||
| 		// Return 404 for other .well-known | ||||
| 		router.all(allPath, async ctx => { | ||||
| 			ctx.status = 404; | ||||
| 		}); | ||||
|  | ||||
| 		return router; | ||||
| 	} | ||||
| } | ||||
| @@ -1,254 +0,0 @@ | ||||
| import Router from '@koa/router'; | ||||
| import json from 'koa-json-body'; | ||||
| import httpSignature from '@peertube/http-signature'; | ||||
|  | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import renderNote from '@/remote/activitypub/renderer/note.js'; | ||||
| import renderKey from '@/remote/activitypub/renderer/key.js'; | ||||
| import { renderPerson } from '@/remote/activitypub/renderer/person.js'; | ||||
| import renderEmoji from '@/remote/activitypub/renderer/emoji.js'; | ||||
| import Outbox, { packActivity } from './activitypub/outbox.js'; | ||||
| import Followers from './activitypub/followers.js'; | ||||
| import Following from './activitypub/following.js'; | ||||
| import Featured from './activitypub/featured.js'; | ||||
| import { inbox as processInbox } from '@/queue/index.js'; | ||||
| import { isSelfHost } from '@/misc/convert-host.js'; | ||||
| import { Notes, Users, Emojis, NoteReactions } from '@/models/index.js'; | ||||
| import { ILocalUser, User } from '@/models/entities/user.js'; | ||||
| import { In, IsNull, Not } from 'typeorm'; | ||||
| import { renderLike } from '@/remote/activitypub/renderer/like.js'; | ||||
| import { getUserKeypair } from '@/misc/keypair-store.js'; | ||||
| import renderFollow from '@/remote/activitypub/renderer/follow.js'; | ||||
|  | ||||
| // Init router | ||||
| const router = new Router(); | ||||
|  | ||||
| //#region Routing | ||||
|  | ||||
| function inbox(ctx: Router.RouterContext) { | ||||
| 	let signature; | ||||
|  | ||||
| 	try { | ||||
| 		signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); | ||||
| 	} catch (e) { | ||||
| 		ctx.status = 401; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	processInbox(ctx.request.body, signature); | ||||
|  | ||||
| 	ctx.status = 202; | ||||
| } | ||||
|  | ||||
| const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; | ||||
| const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; | ||||
|  | ||||
| function isActivityPubReq(ctx: Router.RouterContext) { | ||||
| 	ctx.response.vary('Accept'); | ||||
| 	const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); | ||||
| 	return typeof accepted === 'string' && !accepted.match(/html/); | ||||
| } | ||||
|  | ||||
| export function setResponseType(ctx: Router.RouterContext) { | ||||
| 	const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); | ||||
| 	if (accept === LD_JSON) { | ||||
| 		ctx.response.type = LD_JSON; | ||||
| 	} else { | ||||
| 		ctx.response.type = ACTIVITY_JSON; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // inbox | ||||
| router.post('/inbox', json(), inbox); | ||||
| router.post('/users/:user/inbox', json(), inbox); | ||||
|  | ||||
| // note | ||||
| router.get('/notes/:note', async (ctx, next) => { | ||||
| 	if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 	const note = await Notes.findOneBy({ | ||||
| 		id: ctx.params.note, | ||||
| 		visibility: In(['public' as const, 'home' as const]), | ||||
| 		localOnly: false, | ||||
| 	}); | ||||
|  | ||||
| 	if (note == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	// リモートだったらリダイレクト | ||||
| 	if (note.userHost != null) { | ||||
| 		if (note.uri == null || isSelfHost(note.userHost)) { | ||||
| 			ctx.status = 500; | ||||
| 			return; | ||||
| 		} | ||||
| 		ctx.redirect(note.uri); | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(await renderNote(note, false)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }); | ||||
|  | ||||
| // note activity | ||||
| router.get('/notes/:note/activity', async ctx => { | ||||
| 	const note = await Notes.findOneBy({ | ||||
| 		id: ctx.params.note, | ||||
| 		userHost: IsNull(), | ||||
| 		visibility: In(['public' as const, 'home' as const]), | ||||
| 		localOnly: false, | ||||
| 	}); | ||||
|  | ||||
| 	if (note == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(await packActivity(note)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }); | ||||
|  | ||||
| // outbox | ||||
| router.get('/users/:user/outbox', Outbox); | ||||
|  | ||||
| // followers | ||||
| router.get('/users/:user/followers', Followers); | ||||
|  | ||||
| // following | ||||
| router.get('/users/:user/following', Following); | ||||
|  | ||||
| // featured | ||||
| router.get('/users/:user/collections/featured', Featured); | ||||
|  | ||||
| // publickey | ||||
| router.get('/users/:user/publickey', async ctx => { | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 	}); | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const keypair = await getUserKeypair(user.id); | ||||
|  | ||||
| 	if (Users.isLocalUser(user)) { | ||||
| 		ctx.body = renderActivity(renderKey(user, keypair)); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		setResponseType(ctx); | ||||
| 	} else { | ||||
| 		ctx.status = 400; | ||||
| 	} | ||||
| }); | ||||
|  | ||||
| // user | ||||
| async function userInfo(ctx: Router.RouterContext, user: User | null) { | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(await renderPerson(user as ILocalUser)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| } | ||||
|  | ||||
| router.get('/users/:user', async (ctx, next) => { | ||||
| 	if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 		isSuspended: false, | ||||
| 	}); | ||||
|  | ||||
| 	await userInfo(ctx, user); | ||||
| }); | ||||
|  | ||||
| router.get('/@:user', async (ctx, next) => { | ||||
| 	if (!isActivityPubReq(ctx)) return await next(); | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		usernameLower: ctx.params.user.toLowerCase(), | ||||
| 		host: IsNull(), | ||||
| 		isSuspended: false, | ||||
| 	}); | ||||
|  | ||||
| 	await userInfo(ctx, user); | ||||
| }); | ||||
| //#endregion | ||||
|  | ||||
| // emoji | ||||
| router.get('/emojis/:emoji', async ctx => { | ||||
| 	const emoji = await Emojis.findOneBy({ | ||||
| 		host: IsNull(), | ||||
| 		name: ctx.params.emoji, | ||||
| 	}); | ||||
|  | ||||
| 	if (emoji == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(await renderEmoji(emoji)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }); | ||||
|  | ||||
| // like | ||||
| router.get('/likes/:like', async ctx => { | ||||
| 	const reaction = await NoteReactions.findOneBy({ id: ctx.params.like }); | ||||
|  | ||||
| 	if (reaction == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const note = await Notes.findOneBy({ id: reaction.noteId }); | ||||
|  | ||||
| 	if (note == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(await renderLike(reaction, note)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }); | ||||
|  | ||||
| // follow | ||||
| router.get('/follows/:follower/:followee', async ctx => { | ||||
| 	// This may be used before the follow is completed, so we do not | ||||
| 	// check if the following exists. | ||||
|  | ||||
| 	const [follower, followee] = await Promise.all([ | ||||
| 		Users.findOneBy({ | ||||
| 			id: ctx.params.follower, | ||||
| 			host: IsNull(), | ||||
| 		}), | ||||
| 		Users.findOneBy({ | ||||
| 			id: ctx.params.followee, | ||||
| 			host: Not(IsNull()), | ||||
| 		}), | ||||
| 	]); | ||||
|  | ||||
| 	if (follower == null || followee == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	ctx.body = renderActivity(renderFollow(follower, followee)); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }); | ||||
|  | ||||
| export default router; | ||||
| @@ -1,41 +0,0 @@ | ||||
| import Router from '@koa/router'; | ||||
| import config from '@/config/index.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; | ||||
| import { setResponseType } from '../activitypub.js'; | ||||
| import renderNote from '@/remote/activitypub/renderer/note.js'; | ||||
| import { Users, Notes, UserNotePinings } from '@/models/index.js'; | ||||
| import { IsNull } from 'typeorm'; | ||||
|  | ||||
| export default async (ctx: Router.RouterContext) => { | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 	}); | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const pinings = await UserNotePinings.find({ | ||||
| 		where: { userId: user.id }, | ||||
| 		order: { id: 'DESC' }, | ||||
| 	}); | ||||
|  | ||||
| 	const pinnedNotes = await Promise.all(pinings.map(pining => | ||||
| 		Notes.findOneByOrFail({ id: pining.noteId }))); | ||||
|  | ||||
| 	const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); | ||||
|  | ||||
| 	const rendered = renderOrderedCollection( | ||||
| 		`${config.url}/users/${userId}/collections/featured`, | ||||
| 		renderedNotes.length, undefined, undefined, renderedNotes, | ||||
| 	); | ||||
|  | ||||
| 	ctx.body = renderActivity(rendered); | ||||
| 	ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 	setResponseType(ctx); | ||||
| }; | ||||
| @@ -1,95 +0,0 @@ | ||||
| import Router from '@koa/router'; | ||||
| import { FindOptionsWhere, IsNull, LessThan } from 'typeorm'; | ||||
| import config from '@/config/index.js'; | ||||
| import * as url from '@/prelude/url.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; | ||||
| import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; | ||||
| import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; | ||||
| import { Users, Followings, UserProfiles } from '@/models/index.js'; | ||||
| import { Following } from '@/models/entities/following.js'; | ||||
| import { setResponseType } from '../activitypub.js'; | ||||
|  | ||||
| export default async (ctx: Router.RouterContext) => { | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const cursor = ctx.request.query.cursor; | ||||
| 	if (cursor != null && typeof cursor !== 'string') { | ||||
| 		ctx.status = 400; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const page = ctx.request.query.page === 'true'; | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 	}); | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	//#region Check ff visibility | ||||
| 	const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); | ||||
|  | ||||
| 	if (profile.ffVisibility === 'private') { | ||||
| 		ctx.status = 403; | ||||
| 		ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 		return; | ||||
| 	} else if (profile.ffVisibility === 'followers') { | ||||
| 		ctx.status = 403; | ||||
| 		ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 		return; | ||||
| 	} | ||||
| 	//#endregion | ||||
|  | ||||
| 	const limit = 10; | ||||
| 	const partOf = `${config.url}/users/${userId}/followers`; | ||||
|  | ||||
| 	if (page) { | ||||
| 		const query = { | ||||
| 			followeeId: user.id, | ||||
| 		} as FindOptionsWhere<Following>; | ||||
|  | ||||
| 		// カーソルが指定されている場合 | ||||
| 		if (cursor) { | ||||
| 			query.id = LessThan(cursor); | ||||
| 		} | ||||
|  | ||||
| 		// Get followers | ||||
| 		const followings = await Followings.find({ | ||||
| 			where: query, | ||||
| 			take: limit + 1, | ||||
| 			order: { id: -1 }, | ||||
| 		}); | ||||
|  | ||||
| 		// 「次のページ」があるかどうか | ||||
| 		const inStock = followings.length === limit + 1; | ||||
| 		if (inStock) followings.pop(); | ||||
|  | ||||
| 		const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); | ||||
| 		const rendered = renderOrderedCollectionPage( | ||||
| 			`${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				cursor, | ||||
| 			})}`, | ||||
| 			user.followersCount, renderedFollowers, partOf, | ||||
| 			undefined, | ||||
| 			inStock ? `${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				cursor: followings[followings.length - 1].id, | ||||
| 			})}` : undefined, | ||||
| 		); | ||||
|  | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		setResponseType(ctx); | ||||
| 	} else { | ||||
| 		// index page | ||||
| 		const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		setResponseType(ctx); | ||||
| 	} | ||||
| }; | ||||
| @@ -1,95 +0,0 @@ | ||||
| import Router from '@koa/router'; | ||||
| import { LessThan, IsNull, FindOptionsWhere } from 'typeorm'; | ||||
| import config from '@/config/index.js'; | ||||
| import * as url from '@/prelude/url.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; | ||||
| import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; | ||||
| import renderFollowUser from '@/remote/activitypub/renderer/follow-user.js'; | ||||
| import { Users, Followings, UserProfiles } from '@/models/index.js'; | ||||
| import { Following } from '@/models/entities/following.js'; | ||||
| import { setResponseType } from '../activitypub.js'; | ||||
|  | ||||
| export default async (ctx: Router.RouterContext) => { | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const cursor = ctx.request.query.cursor; | ||||
| 	if (cursor != null && typeof cursor !== 'string') { | ||||
| 		ctx.status = 400; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const page = ctx.request.query.page === 'true'; | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 	}); | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	//#region Check ff visibility | ||||
| 	const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); | ||||
|  | ||||
| 	if (profile.ffVisibility === 'private') { | ||||
| 		ctx.status = 403; | ||||
| 		ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 		return; | ||||
| 	} else if (profile.ffVisibility === 'followers') { | ||||
| 		ctx.status = 403; | ||||
| 		ctx.set('Cache-Control', 'public, max-age=30'); | ||||
| 		return; | ||||
| 	} | ||||
| 	//#endregion | ||||
|  | ||||
| 	const limit = 10; | ||||
| 	const partOf = `${config.url}/users/${userId}/following`; | ||||
|  | ||||
| 	if (page) { | ||||
| 		const query = { | ||||
| 			followerId: user.id, | ||||
| 		} as FindOptionsWhere<Following>; | ||||
|  | ||||
| 		// カーソルが指定されている場合 | ||||
| 		if (cursor) { | ||||
| 			query.id = LessThan(cursor); | ||||
| 		} | ||||
|  | ||||
| 		// Get followings | ||||
| 		const followings = await Followings.find({ | ||||
| 			where: query, | ||||
| 			take: limit + 1, | ||||
| 			order: { id: -1 }, | ||||
| 		}); | ||||
|  | ||||
| 		// 「次のページ」があるかどうか | ||||
| 		const inStock = followings.length === limit + 1; | ||||
| 		if (inStock) followings.pop(); | ||||
|  | ||||
| 		const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); | ||||
| 		const rendered = renderOrderedCollectionPage( | ||||
| 			`${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				cursor, | ||||
| 			})}`, | ||||
| 			user.followingCount, renderedFollowees, partOf, | ||||
| 			undefined, | ||||
| 			inStock ? `${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				cursor: followings[followings.length - 1].id, | ||||
| 			})}` : undefined, | ||||
| 		); | ||||
|  | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		setResponseType(ctx); | ||||
| 	} else { | ||||
| 		// index page | ||||
| 		const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		setResponseType(ctx); | ||||
| 	} | ||||
| }; | ||||
| @@ -1,108 +0,0 @@ | ||||
| import Router from '@koa/router'; | ||||
| import { Brackets, IsNull } from 'typeorm'; | ||||
| import config from '@/config/index.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; | ||||
| import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page.js'; | ||||
| import renderNote from '@/remote/activitypub/renderer/note.js'; | ||||
| import renderCreate from '@/remote/activitypub/renderer/create.js'; | ||||
| import renderAnnounce from '@/remote/activitypub/renderer/announce.js'; | ||||
| import { countIf } from '@/prelude/array.js'; | ||||
| import * as url from '@/prelude/url.js'; | ||||
| import { Users, Notes } from '@/models/index.js'; | ||||
| import { Note } from '@/models/entities/note.js'; | ||||
| import { makePaginationQuery } from '../api/common/make-pagination-query.js'; | ||||
| import { setResponseType } from '../activitypub.js'; | ||||
|  | ||||
| export default async (ctx: Router.RouterContext) => { | ||||
| 	const userId = ctx.params.user; | ||||
|  | ||||
| 	const sinceId = ctx.request.query.since_id; | ||||
| 	if (sinceId != null && typeof sinceId !== 'string') { | ||||
| 		ctx.status = 400; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const untilId = ctx.request.query.until_id; | ||||
| 	if (untilId != null && typeof untilId !== 'string') { | ||||
| 		ctx.status = 400; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const page = ctx.request.query.page === 'true'; | ||||
|  | ||||
| 	if (countIf(x => x != null, [sinceId, untilId]) > 1) { | ||||
| 		ctx.status = 400; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const user = await Users.findOneBy({ | ||||
| 		id: userId, | ||||
| 		host: IsNull(), | ||||
| 	}); | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		ctx.status = 404; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	const limit = 20; | ||||
| 	const partOf = `${config.url}/users/${userId}/outbox`; | ||||
|  | ||||
| 	if (page) { | ||||
| 		const query = makePaginationQuery(Notes.createQueryBuilder('note'), sinceId, untilId) | ||||
| 			.andWhere('note.userId = :userId', { userId: user.id }) | ||||
| 			.andWhere(new Brackets(qb => { qb | ||||
| 				.where('note.visibility = \'public\'') | ||||
| 				.orWhere('note.visibility = \'home\''); | ||||
| 			})) | ||||
| 			.andWhere('note.localOnly = FALSE'); | ||||
|  | ||||
| 		const notes = await query.take(limit).getMany(); | ||||
|  | ||||
| 		if (sinceId) notes.reverse(); | ||||
|  | ||||
| 		const activities = await Promise.all(notes.map(note => packActivity(note))); | ||||
| 		const rendered = renderOrderedCollectionPage( | ||||
| 			`${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				since_id: sinceId, | ||||
| 				until_id: untilId, | ||||
| 			})}`, | ||||
| 			user.notesCount, activities, partOf, | ||||
| 			notes.length ? `${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				since_id: notes[0].id, | ||||
| 			})}` : undefined, | ||||
| 			notes.length ? `${partOf}?${url.query({ | ||||
| 				page: 'true', | ||||
| 				until_id: notes[notes.length - 1].id, | ||||
| 			})}` : undefined, | ||||
| 		); | ||||
|  | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		setResponseType(ctx); | ||||
| 	} else { | ||||
| 		// index page | ||||
| 		const rendered = renderOrderedCollection(partOf, user.notesCount, | ||||
| 			`${partOf}?page=true`, | ||||
| 			`${partOf}?page=true&since_id=000000000000000000000000`, | ||||
| 		); | ||||
| 		ctx.body = renderActivity(rendered); | ||||
| 		ctx.set('Cache-Control', 'public, max-age=180'); | ||||
| 		setResponseType(ctx); | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Pack Create<Note> or Announce Activity | ||||
|  * @param note Note | ||||
|  */ | ||||
| export async function packActivity(note: Note): Promise<any> { | ||||
| 	if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { | ||||
| 		const renote = await Notes.findOneByOrFail({ id: note.renoteId }); | ||||
| 		return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note); | ||||
| 	} | ||||
|  | ||||
| 	return renderCreate(await renderNote(note, false), note); | ||||
| } | ||||
| @@ -1,422 +0,0 @@ | ||||
| import * as crypto from 'node:crypto'; | ||||
| import * as jsrsasign from 'jsrsasign'; | ||||
| import config from '@/config/index.js'; | ||||
|  | ||||
| const ECC_PRELUDE = Buffer.from([0x04]); | ||||
| const NULL_BYTE = Buffer.from([0]); | ||||
| const PEM_PRELUDE = Buffer.from( | ||||
| 	'3059301306072a8648ce3d020106082a8648ce3d030107034200', | ||||
| 	'hex', | ||||
| ); | ||||
|  | ||||
| // Android Safetynet attestations are signed with this cert: | ||||
| const GSR2 = `-----BEGIN CERTIFICATE----- | ||||
| MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G | ||||
| A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp | ||||
| Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 | ||||
| MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG | ||||
| A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI | ||||
| hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL | ||||
| v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 | ||||
| eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq | ||||
| tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd | ||||
| C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa | ||||
| zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB | ||||
| mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH | ||||
| V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n | ||||
| bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG | ||||
| 3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs | ||||
| J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO | ||||
| 291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS | ||||
| ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd | ||||
| AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 | ||||
| TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== | ||||
| -----END CERTIFICATE-----\n`; | ||||
|  | ||||
| function base64URLDecode(source: string) { | ||||
| 	return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); | ||||
| } | ||||
|  | ||||
| function getCertSubject(certificate: string) { | ||||
| 	const subjectCert = new jsrsasign.X509(); | ||||
| 	subjectCert.readCertPEM(certificate); | ||||
|  | ||||
| 	const subjectString = subjectCert.getSubjectString(); | ||||
| 	const subjectFields = subjectString.slice(1).split('/'); | ||||
|  | ||||
| 	const fields = {} as Record<string, string>; | ||||
| 	for (const field of subjectFields) { | ||||
| 		const eqIndex = field.indexOf('='); | ||||
| 		fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); | ||||
| 	} | ||||
|  | ||||
| 	return fields; | ||||
| } | ||||
|  | ||||
| function verifyCertificateChain(certificates: string[]) { | ||||
| 	let valid = true; | ||||
|  | ||||
| 	for (let i = 0; i < certificates.length; i++) { | ||||
| 		const Cert = certificates[i]; | ||||
| 		const certificate = new jsrsasign.X509(); | ||||
| 		certificate.readCertPEM(Cert); | ||||
|  | ||||
| 		const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; | ||||
|  | ||||
| 		const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); | ||||
| 		const algorithm = certificate.getSignatureAlgorithmField(); | ||||
| 		const signatureHex = certificate.getSignatureValueHex(); | ||||
|  | ||||
| 		// Verify against CA | ||||
| 		const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); | ||||
| 		Signature.init(CACert); | ||||
| 		Signature.updateHex(certStruct); | ||||
| 		valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate | ||||
| 	} | ||||
|  | ||||
| 	return valid; | ||||
| } | ||||
|  | ||||
| function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { | ||||
| 	if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { | ||||
| 		pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); | ||||
| 		type = 'PUBLIC KEY'; | ||||
| 	} | ||||
| 	const cert = pemBuffer.toString('base64'); | ||||
|  | ||||
| 	const keyParts = []; | ||||
| 	const max = Math.ceil(cert.length / 64); | ||||
| 	let start = 0; | ||||
| 	for (let i = 0; i < max; i++) { | ||||
| 		keyParts.push(cert.substring(start, start + 64)); | ||||
| 		start += 64; | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		`-----BEGIN ${type}-----\n` + | ||||
| 		keyParts.join('\n') + | ||||
| 		`\n-----END ${type}-----\n` | ||||
| 	); | ||||
| } | ||||
|  | ||||
| export function hash(data: Buffer) { | ||||
| 	return crypto | ||||
| 		.createHash('sha256') | ||||
| 		.update(data) | ||||
| 		.digest(); | ||||
| } | ||||
|  | ||||
| export function verifyLogin({ | ||||
| 	publicKey, | ||||
| 	authenticatorData, | ||||
| 	clientDataJSON, | ||||
| 	clientData, | ||||
| 	signature, | ||||
| 	challenge, | ||||
| }: { | ||||
| 	publicKey: Buffer, | ||||
| 	authenticatorData: Buffer, | ||||
| 	clientDataJSON: Buffer, | ||||
| 	clientData: any, | ||||
| 	signature: Buffer, | ||||
| 	challenge: string | ||||
| }) { | ||||
| 	if (clientData.type !== 'webauthn.get') { | ||||
| 		throw new Error('type is not webauthn.get'); | ||||
| 	} | ||||
|  | ||||
| 	if (hash(clientData.challenge).toString('hex') !== challenge) { | ||||
| 		throw new Error('challenge mismatch'); | ||||
| 	} | ||||
| 	if (clientData.origin !== config.scheme + '://' + config.host) { | ||||
| 		throw new Error('origin mismatch'); | ||||
| 	} | ||||
|  | ||||
| 	const verificationData = Buffer.concat( | ||||
| 		[authenticatorData, hash(clientDataJSON)], | ||||
| 		32 + authenticatorData.length, | ||||
| 	); | ||||
|  | ||||
| 	return crypto | ||||
| 		.createVerify('SHA256') | ||||
| 		.update(verificationData) | ||||
| 		.verify(PEMString(publicKey), signature); | ||||
| } | ||||
|  | ||||
| export const procedures = { | ||||
| 	none: { | ||||
| 		verify({ publicKey }: { publicKey: Map<number, Buffer> }) { | ||||
| 			const negTwo = publicKey.get(-2); | ||||
|  | ||||
| 			if (!negTwo || negTwo.length !== 32) { | ||||
| 				throw new Error('invalid or no -2 key given'); | ||||
| 			} | ||||
| 			const negThree = publicKey.get(-3); | ||||
| 			if (!negThree || negThree.length !== 32) { | ||||
| 				throw new Error('invalid or no -3 key given'); | ||||
| 			} | ||||
|  | ||||
| 			const publicKeyU2F = Buffer.concat( | ||||
| 				[ECC_PRELUDE, negTwo, negThree], | ||||
| 				1 + 32 + 32, | ||||
| 			); | ||||
|  | ||||
| 			return { | ||||
| 				publicKey: publicKeyU2F, | ||||
| 				valid: true, | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| 	'android-key': { | ||||
| 		verify({ | ||||
| 			attStmt, | ||||
| 			authenticatorData, | ||||
| 			clientDataHash, | ||||
| 			publicKey, | ||||
| 			rpIdHash, | ||||
| 			credentialId, | ||||
| 		}: { | ||||
| 			attStmt: any, | ||||
| 			authenticatorData: Buffer, | ||||
| 			clientDataHash: Buffer, | ||||
| 			publicKey: Map<number, any>; | ||||
| 			rpIdHash: Buffer, | ||||
| 			credentialId: Buffer, | ||||
| 		}) { | ||||
| 			if (attStmt.alg !== -7) { | ||||
| 				throw new Error('alg mismatch'); | ||||
| 			} | ||||
|  | ||||
| 			const verificationData = Buffer.concat([ | ||||
| 				authenticatorData, | ||||
| 				clientDataHash, | ||||
| 			]); | ||||
|  | ||||
| 			const attCert: Buffer = attStmt.x5c[0]; | ||||
|  | ||||
| 			const negTwo = publicKey.get(-2); | ||||
|  | ||||
| 			if (!negTwo || negTwo.length !== 32) { | ||||
| 				throw new Error('invalid or no -2 key given'); | ||||
| 			} | ||||
| 			const negThree = publicKey.get(-3); | ||||
| 			if (!negThree || negThree.length !== 32) { | ||||
| 				throw new Error('invalid or no -3 key given'); | ||||
| 			} | ||||
|  | ||||
| 			const publicKeyData = Buffer.concat( | ||||
| 				[ECC_PRELUDE, negTwo, negThree], | ||||
| 				1 + 32 + 32, | ||||
| 			); | ||||
|  | ||||
| 			if (!attCert.equals(publicKeyData)) { | ||||
| 				throw new Error('public key mismatch'); | ||||
| 			} | ||||
|  | ||||
| 			const isValid = crypto | ||||
| 				.createVerify('SHA256') | ||||
| 				.update(verificationData) | ||||
| 				.verify(PEMString(attCert), attStmt.sig); | ||||
|  | ||||
| 			// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) | ||||
|  | ||||
| 			return { | ||||
| 				valid: isValid, | ||||
| 				publicKey: publicKeyData, | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| 	// what a stupid attestation | ||||
| 	'android-safetynet': { | ||||
| 		verify({ | ||||
| 			attStmt, | ||||
| 			authenticatorData, | ||||
| 			clientDataHash, | ||||
| 			publicKey, | ||||
| 			rpIdHash, | ||||
| 			credentialId, | ||||
| 		}: { | ||||
| 			attStmt: any, | ||||
| 			authenticatorData: Buffer, | ||||
| 			clientDataHash: Buffer, | ||||
| 			publicKey: Map<number, any>; | ||||
| 			rpIdHash: Buffer, | ||||
| 			credentialId: Buffer, | ||||
| 		}) { | ||||
| 			const verificationData = hash( | ||||
| 				Buffer.concat([authenticatorData, clientDataHash]), | ||||
| 			); | ||||
|  | ||||
| 			const jwsParts = attStmt.response.toString('utf-8').split('.'); | ||||
|  | ||||
| 			const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); | ||||
| 			const response = JSON.parse( | ||||
| 				base64URLDecode(jwsParts[1]).toString('utf-8'), | ||||
| 			); | ||||
| 			const signature = jwsParts[2]; | ||||
|  | ||||
| 			if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { | ||||
| 				throw new Error('invalid nonce'); | ||||
| 			} | ||||
|  | ||||
| 			const certificateChain = header.x5c | ||||
| 				.map((key: any) => PEMString(key)) | ||||
| 				.concat([GSR2]); | ||||
|  | ||||
| 			if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') { | ||||
| 				throw new Error('invalid common name'); | ||||
| 			} | ||||
|  | ||||
| 			if (!verifyCertificateChain(certificateChain)) { | ||||
| 				throw new Error('Invalid certificate chain!'); | ||||
| 			} | ||||
|  | ||||
| 			const signatureBase = Buffer.from( | ||||
| 				jwsParts[0] + '.' + jwsParts[1], | ||||
| 				'utf-8', | ||||
| 			); | ||||
|  | ||||
| 			const valid = crypto | ||||
| 				.createVerify('sha256') | ||||
| 				.update(signatureBase) | ||||
| 				.verify(certificateChain[0], base64URLDecode(signature)); | ||||
|  | ||||
| 			const negTwo = publicKey.get(-2); | ||||
|  | ||||
| 			if (!negTwo || negTwo.length !== 32) { | ||||
| 				throw new Error('invalid or no -2 key given'); | ||||
| 			} | ||||
| 			const negThree = publicKey.get(-3); | ||||
| 			if (!negThree || negThree.length !== 32) { | ||||
| 				throw new Error('invalid or no -3 key given'); | ||||
| 			} | ||||
|  | ||||
| 			const publicKeyData = Buffer.concat( | ||||
| 				[ECC_PRELUDE, negTwo, negThree], | ||||
| 				1 + 32 + 32, | ||||
| 			); | ||||
| 			return { | ||||
| 				valid, | ||||
| 				publicKey: publicKeyData, | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| 	packed: { | ||||
| 		verify({ | ||||
| 			attStmt, | ||||
| 			authenticatorData, | ||||
| 			clientDataHash, | ||||
| 			publicKey, | ||||
| 			rpIdHash, | ||||
| 			credentialId, | ||||
| 		}: { | ||||
| 			attStmt: any, | ||||
| 			authenticatorData: Buffer, | ||||
| 			clientDataHash: Buffer, | ||||
| 			publicKey: Map<number, any>; | ||||
| 			rpIdHash: Buffer, | ||||
| 			credentialId: Buffer, | ||||
| 		}) { | ||||
| 			const verificationData = Buffer.concat([ | ||||
| 				authenticatorData, | ||||
| 				clientDataHash, | ||||
| 			]); | ||||
|  | ||||
| 			if (attStmt.x5c) { | ||||
| 				const attCert = attStmt.x5c[0]; | ||||
|  | ||||
| 				const validSignature = crypto | ||||
| 					.createVerify('SHA256') | ||||
| 					.update(verificationData) | ||||
| 					.verify(PEMString(attCert), attStmt.sig); | ||||
|  | ||||
| 				const negTwo = publicKey.get(-2); | ||||
|  | ||||
| 				if (!negTwo || negTwo.length !== 32) { | ||||
| 					throw new Error('invalid or no -2 key given'); | ||||
| 				} | ||||
| 				const negThree = publicKey.get(-3); | ||||
| 				if (!negThree || negThree.length !== 32) { | ||||
| 					throw new Error('invalid or no -3 key given'); | ||||
| 				} | ||||
|  | ||||
| 				const publicKeyData = Buffer.concat( | ||||
| 					[ECC_PRELUDE, negTwo, negThree], | ||||
| 					1 + 32 + 32, | ||||
| 				); | ||||
|  | ||||
| 				return { | ||||
| 					valid: validSignature, | ||||
| 					publicKey: publicKeyData, | ||||
| 				}; | ||||
| 			} else if (attStmt.ecdaaKeyId) { | ||||
| 				// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation | ||||
| 				throw new Error('ECDAA-Verify is not supported'); | ||||
| 			} else { | ||||
| 				if (attStmt.alg !== -7) throw new Error('alg mismatch'); | ||||
|  | ||||
| 				throw new Error('self attestation is not supported'); | ||||
| 			} | ||||
| 		}, | ||||
| 	}, | ||||
|  | ||||
| 	'fido-u2f': { | ||||
| 		verify({ | ||||
| 			attStmt, | ||||
| 			authenticatorData, | ||||
| 			clientDataHash, | ||||
| 			publicKey, | ||||
| 			rpIdHash, | ||||
| 			credentialId, | ||||
| 		}: { | ||||
| 			attStmt: any, | ||||
| 			authenticatorData: Buffer, | ||||
| 			clientDataHash: Buffer, | ||||
| 			publicKey: Map<number, any>, | ||||
| 			rpIdHash: Buffer, | ||||
| 			credentialId: Buffer | ||||
| 		}) { | ||||
| 			const x5c: Buffer[] = attStmt.x5c; | ||||
| 			if (x5c.length !== 1) { | ||||
| 				throw new Error('x5c length does not match expectation'); | ||||
| 			} | ||||
|  | ||||
| 			const attCert = x5c[0]; | ||||
|  | ||||
| 			// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve | ||||
|  | ||||
| 			const negTwo: Buffer = publicKey.get(-2); | ||||
|  | ||||
| 			if (!negTwo || negTwo.length !== 32) { | ||||
| 				throw new Error('invalid or no -2 key given'); | ||||
| 			} | ||||
| 			const negThree: Buffer = publicKey.get(-3); | ||||
| 			if (!negThree || negThree.length !== 32) { | ||||
| 				throw new Error('invalid or no -3 key given'); | ||||
| 			} | ||||
|  | ||||
| 			const publicKeyU2F = Buffer.concat( | ||||
| 				[ECC_PRELUDE, negTwo, negThree], | ||||
| 				1 + 32 + 32, | ||||
| 			); | ||||
|  | ||||
| 			const verificationData = Buffer.concat([ | ||||
| 				NULL_BYTE, | ||||
| 				rpIdHash, | ||||
| 				clientDataHash, | ||||
| 				credentialId, | ||||
| 				publicKeyU2F, | ||||
| 			]); | ||||
|  | ||||
| 			const validSignature = crypto | ||||
| 				.createVerify('SHA256') | ||||
| 				.update(verificationData) | ||||
| 				.verify(PEMString(attCert), attStmt.sig); | ||||
|  | ||||
| 			return { | ||||
| 				valid: validSignature, | ||||
| 				publicKey: publicKeyU2F, | ||||
| 			}; | ||||
| 		}, | ||||
| 	}, | ||||
| }; | ||||
							
								
								
									
										258
									
								
								packages/backend/src/server/api/ApiCallService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								packages/backend/src/server/api/ApiCallService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| import { performance } from 'perf_hooks'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { getIpHash } from '@/misc/get-ip-hash.js'; | ||||
| import type { CacheableLocalUser, User } from '@/models/entities/User.js'; | ||||
| import type { AccessToken } from '@/models/entities/AccessToken.js'; | ||||
| import type Logger from '@/logger.js'; | ||||
| import { UserIpsRepository } from '@/models/index.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { ApiError } from './error.js'; | ||||
| import { RateLimiterService } from './RateLimiterService.js'; | ||||
| import { ApiLoggerService } from './ApiLoggerService.js'; | ||||
| import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; | ||||
| import type { OnApplicationShutdown } from '@nestjs/common'; | ||||
| import type { IEndpointMeta, IEndpoint } from './endpoints.js'; | ||||
| import type Koa from 'koa'; | ||||
|  | ||||
| const accessDenied = { | ||||
| 	message: 'Access denied.', | ||||
| 	code: 'ACCESS_DENIED', | ||||
| 	id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', | ||||
| }; | ||||
|  | ||||
| @Injectable() | ||||
| export class ApiCallService implements OnApplicationShutdown { | ||||
| 	#logger: Logger; | ||||
| 	#userIpHistories: Map<User['id'], Set<string>>; | ||||
| 	#userIpHistoriesClearIntervalId: NodeJS.Timer; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.userIpsRepository) | ||||
| 		private userIpsRepository: UserIpsRepository, | ||||
|  | ||||
| 		private metaService: MetaService, | ||||
| 		private authenticateService: AuthenticateService, | ||||
| 		private rateLimiterService: RateLimiterService, | ||||
| 		private apiLoggerService: ApiLoggerService, | ||||
| 	) { | ||||
| 		this.#logger = this.apiLoggerService.logger; | ||||
| 		this.#userIpHistories = new Map<User['id'], Set<string>>(); | ||||
|  | ||||
| 		this.#userIpHistoriesClearIntervalId = setInterval(() => { | ||||
| 			this.#userIpHistories.clear(); | ||||
| 		}, 1000 * 60 * 60); | ||||
| 	} | ||||
|  | ||||
| 	public handleRequest(endpoint: IEndpoint, exec: any, ctx: Koa.Context) { | ||||
| 		return new Promise<void>((res) => { | ||||
| 			const body = ctx.is('multipart/form-data') | ||||
| 				? (ctx.request as any).body | ||||
| 				: ctx.method === 'GET' | ||||
| 					? ctx.query | ||||
| 					: ctx.request.body; | ||||
| 		 | ||||
| 			const reply = (x?: any, y?: ApiError) => { | ||||
| 				if (x == null) { | ||||
| 					ctx.status = 204; | ||||
| 				} else if (typeof x === 'number' && y) { | ||||
| 					ctx.status = x; | ||||
| 					ctx.body = { | ||||
| 						error: { | ||||
| 							message: y!.message, | ||||
| 							code: y!.code, | ||||
| 							id: y!.id, | ||||
| 							kind: y!.kind, | ||||
| 							...(y!.info ? { info: y!.info } : {}), | ||||
| 						}, | ||||
| 					}; | ||||
| 				} else { | ||||
| 					// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない | ||||
| 					ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; | ||||
| 				} | ||||
| 				res(); | ||||
| 			}; | ||||
| 		 | ||||
| 			// Authentication | ||||
| 			this.authenticateService.authenticate(body['i']).then(([user, app]) => { | ||||
| 				// API invoking | ||||
| 				this.#call(endpoint, exec, user, app, body, ctx).then((res: any) => { | ||||
| 					if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { | ||||
| 						ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); | ||||
| 					} | ||||
| 					reply(res); | ||||
| 				}).catch((e: ApiError) => { | ||||
| 					reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); | ||||
| 				}); | ||||
| 		 | ||||
| 				// Log IP | ||||
| 				if (user) { | ||||
| 					this.metaService.fetch().then(meta => { | ||||
| 						if (!meta.enableIpLogging) return; | ||||
| 						const ip = ctx.ip; | ||||
| 						const ips = this.#userIpHistories.get(user.id); | ||||
| 						if (ips == null || !ips.has(ip)) { | ||||
| 							if (ips == null) { | ||||
| 								this.#userIpHistories.set(user.id, new Set([ip])); | ||||
| 							} else { | ||||
| 								ips.add(ip); | ||||
| 							} | ||||
| 		 | ||||
| 							try { | ||||
| 								this.userIpsRepository.createQueryBuilder().insert().values({ | ||||
| 									createdAt: new Date(), | ||||
| 									userId: user.id, | ||||
| 									ip: ip, | ||||
| 								}).orIgnore(true).execute(); | ||||
| 							} catch { | ||||
| 							} | ||||
| 						} | ||||
| 					}); | ||||
| 				} | ||||
| 			}).catch(e => { | ||||
| 				if (e instanceof AuthenticationError) { | ||||
| 					reply(403, new ApiError({ | ||||
| 						message: 'Authentication failed. Please ensure your token is correct.', | ||||
| 						code: 'AUTHENTICATION_FAILED', | ||||
| 						id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', | ||||
| 					})); | ||||
| 				} else { | ||||
| 					reply(500, new ApiError()); | ||||
| 				} | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	async #call( | ||||
| 		ep: IEndpoint, | ||||
| 		exec: any, | ||||
| 		user: CacheableLocalUser | null | undefined, | ||||
| 		token: AccessToken | null | undefined, | ||||
| 		data: any, | ||||
| 		ctx?: Koa.Context, | ||||
| 	) { | ||||
| 		const isSecure = user != null && token == null; | ||||
| 		const isModerator = user != null && (user.isModerator || user.isAdmin); | ||||
|  | ||||
| 		if (ep.meta.secure && !isSecure) { | ||||
| 			throw new ApiError(accessDenied); | ||||
| 		} | ||||
|  | ||||
| 		if (ep.meta.limit) { | ||||
| 		// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. | ||||
| 			let limitActor: string; | ||||
| 			if (user) { | ||||
| 				limitActor = user.id; | ||||
| 			} else { | ||||
| 				limitActor = getIpHash(ctx!.ip); | ||||
| 			} | ||||
|  | ||||
| 			const limit = Object.assign({}, ep.meta.limit); | ||||
|  | ||||
| 			if (!limit.key) { | ||||
| 				limit.key = ep.name; | ||||
| 			} | ||||
|  | ||||
| 			// Rate limit | ||||
| 			await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => { | ||||
| 				throw new ApiError({ | ||||
| 					message: 'Rate limit exceeded. Please try again later.', | ||||
| 					code: 'RATE_LIMIT_EXCEEDED', | ||||
| 					id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', | ||||
| 					httpStatusCode: 429, | ||||
| 				}); | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (ep.meta.requireCredential && user == null) { | ||||
| 			throw new ApiError({ | ||||
| 				message: 'Credential required.', | ||||
| 				code: 'CREDENTIAL_REQUIRED', | ||||
| 				id: '1384574d-a912-4b81-8601-c7b1c4085df1', | ||||
| 				httpStatusCode: 401, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (ep.meta.requireCredential && user!.isSuspended) { | ||||
| 			throw new ApiError({ | ||||
| 				message: 'Your account has been suspended.', | ||||
| 				code: 'YOUR_ACCOUNT_SUSPENDED', | ||||
| 				id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', | ||||
| 				httpStatusCode: 403, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		if (ep.meta.requireAdmin && !user!.isAdmin) { | ||||
| 			throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); | ||||
| 		} | ||||
|  | ||||
| 		if (ep.meta.requireModerator && !isModerator) { | ||||
| 			throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); | ||||
| 		} | ||||
|  | ||||
| 		if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { | ||||
| 			throw new ApiError({ | ||||
| 				message: 'Your app does not have the necessary permissions to use this endpoint.', | ||||
| 				code: 'PERMISSION_DENIED', | ||||
| 				id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Cast non JSON input | ||||
| 		if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { | ||||
| 			for (const k of Object.keys(ep.params.properties)) { | ||||
| 				const param = ep.params.properties![k]; | ||||
| 				if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { | ||||
| 					try { | ||||
| 						data[k] = JSON.parse(data[k]); | ||||
| 					} catch (e) { | ||||
| 						throw	new ApiError({ | ||||
| 							message: 'Invalid param.', | ||||
| 							code: 'INVALID_PARAM', | ||||
| 							id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', | ||||
| 						}, { | ||||
| 							param: k, | ||||
| 							reason: `cannot cast to ${param.type}`, | ||||
| 						}); | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// API invoking | ||||
| 		const before = performance.now(); | ||||
| 		return await exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((err: Error) => { | ||||
| 			if (err instanceof ApiError) { | ||||
| 				throw err; | ||||
| 			} else { | ||||
| 				this.#logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { | ||||
| 					ep: ep.name, | ||||
| 					ps: data, | ||||
| 					e: { | ||||
| 						message: err.message, | ||||
| 						code: err.name, | ||||
| 						stack: err.stack, | ||||
| 					}, | ||||
| 				}); | ||||
| 				console.error(err); | ||||
| 				throw new ApiError(null, { | ||||
| 					e: { | ||||
| 						message: err.message, | ||||
| 						code: err.name, | ||||
| 						stack: err.stack, | ||||
| 					}, | ||||
| 				}); | ||||
| 			} | ||||
| 		}).finally(() => { | ||||
| 			const after = performance.now(); | ||||
| 			const time = after - before; | ||||
| 			if (time > 1000) { | ||||
| 				this.#logger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public onApplicationShutdown(signal?: string | undefined) { | ||||
| 		clearInterval(this.#userIpHistoriesClearIntervalId); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										12
									
								
								packages/backend/src/server/api/ApiLoggerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/backend/src/server/api/ApiLoggerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Logger from '@/logger.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ApiLoggerService { | ||||
| 	public logger: Logger; | ||||
|  | ||||
| 	constructor( | ||||
| 	) { | ||||
| 		this.logger = new Logger('api'); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										160
									
								
								packages/backend/src/server/api/ApiServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								packages/backend/src/server/api/ApiServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Koa from 'koa'; | ||||
| import Router from '@koa/router'; | ||||
| import multer from '@koa/multer'; | ||||
| import bodyParser from 'koa-bodyparser'; | ||||
| import cors from '@koa/cors'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import endpoints from './endpoints.js'; | ||||
| import { ApiCallService } from './ApiCallService.js'; | ||||
| import { SignupApiService } from './SignupApiService.js'; | ||||
| import { SigninApiService } from './SigninApiService.js'; | ||||
| import { GithubServerService } from './integration/GithubServerService.js'; | ||||
| import { DiscordServerService } from './integration/DiscordServerService.js'; | ||||
| import { TwitterServerService } from './integration/TwitterServerService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class ApiServerService { | ||||
| 	constructor( | ||||
| 		private moduleRef: ModuleRef, | ||||
|  | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
|  | ||||
| 		@Inject(DI.accessTokensRepository) | ||||
| 		private accessTokensRepository: AccessTokensRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private apiCallService: ApiCallService, | ||||
| 		private signupApiServiceService: SignupApiService, | ||||
| 		private signinApiServiceService: SigninApiService, | ||||
| 		private githubServerService: GithubServerService, | ||||
| 		private discordServerService: DiscordServerService, | ||||
| 		private twitterServerService: TwitterServerService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public createApiServer() { | ||||
| 		const handlers: Record<string, any> = {}; | ||||
|  | ||||
| 		for (const endpoint of endpoints) { | ||||
| 			handlers[endpoint.name] = this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec; | ||||
| 		} | ||||
|  | ||||
| 		// Init app | ||||
| 		const apiServer = new Koa(); | ||||
|  | ||||
| 		apiServer.use(cors({ | ||||
| 			origin: '*', | ||||
| 		})); | ||||
|  | ||||
| 		// No caching | ||||
| 		apiServer.use(async (ctx, next) => { | ||||
| 			ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); | ||||
| 			await next(); | ||||
| 		}); | ||||
|  | ||||
| 		apiServer.use(bodyParser({ | ||||
| 			// リクエストが multipart/form-data でない限りはJSONだと見なす | ||||
| 			detectJSON: ctx => !ctx.is('multipart/form-data'), | ||||
| 		})); | ||||
|  | ||||
| 		// Init multer instance | ||||
| 		const upload = multer({ | ||||
| 			storage: multer.diskStorage({}), | ||||
| 			limits: { | ||||
| 				fileSize: this.config.maxFileSize ?? 262144000, | ||||
| 				files: 1, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		// Init router | ||||
| 		const router = new Router(); | ||||
|  | ||||
| 		/** | ||||
| 		 * Register endpoint handlers | ||||
| 		 */ | ||||
| 		for (const endpoint of endpoints) { | ||||
| 			if (endpoint.meta.requireFile) { | ||||
| 				router.post(`/${endpoint.name}`, upload.single('file'), this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); | ||||
| 			} else { | ||||
| 				// 後方互換性のため | ||||
| 				if (endpoint.name.includes('-')) { | ||||
| 					router.post(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); | ||||
|  | ||||
| 					if (endpoint.meta.allowGet) { | ||||
| 						router.get(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); | ||||
| 					} else { | ||||
| 						router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); | ||||
| 					} | ||||
| 				} | ||||
|  | ||||
| 				router.post(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); | ||||
|  | ||||
| 				if (endpoint.meta.allowGet) { | ||||
| 					router.get(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name])); | ||||
| 				} else { | ||||
| 					router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		router.post('/signup', ctx => this.signupApiServiceService.signup(ctx)); | ||||
| 		router.post('/signin', ctx => this.signinApiServiceService.signin(ctx)); | ||||
| 		router.post('/signup-pending', ctx => this.signupApiServiceService.signupPending(ctx)); | ||||
|  | ||||
| 		router.use(this.discordServerService.create().routes()); | ||||
| 		router.use(this.githubServerService.create().routes()); | ||||
| 		router.use(this.twitterServerService.create().routes()); | ||||
|  | ||||
| 		router.get('/v1/instance/peers', async ctx => { | ||||
| 			const instances = await this.instancesRepository.find({ | ||||
| 				select: ['host'], | ||||
| 			}); | ||||
|  | ||||
| 			ctx.body = instances.map(instance => instance.host); | ||||
| 		}); | ||||
|  | ||||
| 		router.post('/miauth/:session/check', async ctx => { | ||||
| 			const token = await this.accessTokensRepository.findOneBy({ | ||||
| 				session: ctx.params.session, | ||||
| 			}); | ||||
|  | ||||
| 			if (token && token.session != null && !token.fetched) { | ||||
| 				this.accessTokensRepository.update(token.id, { | ||||
| 					fetched: true, | ||||
| 				}); | ||||
|  | ||||
| 				ctx.body = { | ||||
| 					ok: true, | ||||
| 					token: token.token, | ||||
| 					user: await this.userEntityService.pack(token.userId, null, { detail: true }), | ||||
| 				}; | ||||
| 			} else { | ||||
| 				ctx.body = { | ||||
| 					ok: false, | ||||
| 				}; | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		// Return 404 for unknown API | ||||
| 		router.all('(.*)', async ctx => { | ||||
| 			ctx.status = 404; | ||||
| 		}); | ||||
|  | ||||
| 		// Register router | ||||
| 		apiServer.use(router.routes()); | ||||
|  | ||||
| 		return apiServer; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										86
									
								
								packages/backend/src/server/api/AuthenticateService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								packages/backend/src/server/api/AuthenticateService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; | ||||
| import type { CacheableLocalUser, ILocalUser } from '@/models/entities/User.js'; | ||||
| import type { AccessToken } from '@/models/entities/AccessToken.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import type { App } from '@/models/entities/App.js'; | ||||
| import { UserCacheService } from '@/core/UserCacheService.js'; | ||||
| import isNativeToken from '@/misc/is-native-token.js'; | ||||
|  | ||||
| export class AuthenticationError extends Error { | ||||
| 	constructor(message: string) { | ||||
| 		super(message); | ||||
| 		this.name = 'AuthenticationError'; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthenticateService { | ||||
| 	#appCache: Cache<App>; | ||||
|  | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.accessTokensRepository) | ||||
| 		private accessTokensRepository: AccessTokensRepository, | ||||
|  | ||||
| 		@Inject(DI.appsRepository) | ||||
| 		private appsRepository: AppsRepository, | ||||
|  | ||||
| 		private userCacheService: UserCacheService, | ||||
| 	) { | ||||
| 		this.#appCache = new Cache<App>(Infinity); | ||||
| 	} | ||||
|  | ||||
| 	public async authenticate(token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> { | ||||
| 		if (token == null) { | ||||
| 			return [null, null]; | ||||
| 		} | ||||
| 	 | ||||
| 		if (isNativeToken(token)) { | ||||
| 			const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, | ||||
| 				() => this.usersRepository.findOneBy({ token }) as Promise<ILocalUser | null>); | ||||
| 	 | ||||
| 			if (user == null) { | ||||
| 				throw new AuthenticationError('user not found'); | ||||
| 			} | ||||
| 	 | ||||
| 			return [user, null]; | ||||
| 		} else { | ||||
| 			const accessToken = await this.accessTokensRepository.findOne({ | ||||
| 				where: [{ | ||||
| 					hash: token.toLowerCase(), // app | ||||
| 				}, { | ||||
| 					token: token, // miauth | ||||
| 				}], | ||||
| 			}); | ||||
| 	 | ||||
| 			if (accessToken == null) { | ||||
| 				throw new AuthenticationError('invalid signature'); | ||||
| 			} | ||||
| 	 | ||||
| 			this.accessTokensRepository.update(accessToken.id, { | ||||
| 				lastUsedAt: new Date(), | ||||
| 			}); | ||||
| 	 | ||||
| 			const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, | ||||
| 				() => this.usersRepository.findOneBy({ | ||||
| 					id: accessToken.userId, | ||||
| 				}) as Promise<ILocalUser>); | ||||
| 	 | ||||
| 			if (accessToken.appId) { | ||||
| 				const app = await this.#appCache.fetch(accessToken.appId, | ||||
| 					() => this.appsRepository.findOneByOrFail({ id: accessToken.appId! })); | ||||
| 	 | ||||
| 				return [user, { | ||||
| 					id: accessToken.id, | ||||
| 					permission: app.permission, | ||||
| 				} as AccessToken]; | ||||
| 			} else { | ||||
| 				return [user, accessToken]; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										1268
									
								
								packages/backend/src/server/api/EndpointsModule.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1268
									
								
								packages/backend/src/server/api/EndpointsModule.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										89
									
								
								packages/backend/src/server/api/RateLimiterService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								packages/backend/src/server/api/RateLimiterService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Limiter from 'ratelimiter'; | ||||
| import Redis from 'ioredis'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import type { IEndpointMeta } from './endpoints.js'; | ||||
|  | ||||
| const logger = new Logger('limiter'); | ||||
|  | ||||
| @Injectable() | ||||
| export class RateLimiterService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) { | ||||
| 		return new Promise<void>((ok, reject) => { | ||||
| 			if (process.env.NODE_ENV === 'test') ok(); | ||||
| 			 | ||||
| 			// Short-term limit | ||||
| 			const min = (): void => { | ||||
| 				const minIntervalLimiter = new Limiter({ | ||||
| 					id: `${actor}:${limitation.key}:min`, | ||||
| 					duration: limitation.minInterval, | ||||
| 					max: 1, | ||||
| 					db: this.redisClient, | ||||
| 				}); | ||||
| 		 | ||||
| 				minIntervalLimiter.get((err, info) => { | ||||
| 					if (err) { | ||||
| 						return reject('ERR'); | ||||
| 					} | ||||
| 		 | ||||
| 					logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); | ||||
| 		 | ||||
| 					if (info.remaining === 0) { | ||||
| 						reject('BRIEF_REQUEST_INTERVAL'); | ||||
| 					} else { | ||||
| 						if (hasLongTermLimit) { | ||||
| 							max(); | ||||
| 						} else { | ||||
| 							ok(); | ||||
| 						} | ||||
| 					} | ||||
| 				}); | ||||
| 			}; | ||||
| 		 | ||||
| 			// Long term limit | ||||
| 			const max = (): void => { | ||||
| 				const limiter = new Limiter({ | ||||
| 					id: `${actor}:${limitation.key}`, | ||||
| 					duration: limitation.duration, | ||||
| 					max: limitation.max, | ||||
| 					db: this.redisClient, | ||||
| 				}); | ||||
| 		 | ||||
| 				limiter.get((err, info) => { | ||||
| 					if (err) { | ||||
| 						return reject('ERR'); | ||||
| 					} | ||||
| 		 | ||||
| 					logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); | ||||
| 		 | ||||
| 					if (info.remaining === 0) { | ||||
| 						reject('RATE_LIMIT_EXCEEDED'); | ||||
| 					} else { | ||||
| 						ok(); | ||||
| 					} | ||||
| 				}); | ||||
| 			}; | ||||
| 		 | ||||
| 			const hasShortTermLimit = typeof limitation.minInterval === 'number'; | ||||
| 		 | ||||
| 			const hasLongTermLimit = | ||||
| 				typeof limitation.duration === 'number' && | ||||
| 				typeof limitation.max === 'number'; | ||||
| 		 | ||||
| 			if (hasShortTermLimit) { | ||||
| 				min(); | ||||
| 			} else if (hasLongTermLimit) { | ||||
| 				max(); | ||||
| 			} else { | ||||
| 				ok(); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										282
									
								
								packages/backend/src/server/api/SigninApiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								packages/backend/src/server/api/SigninApiService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,282 @@ | ||||
| import { randomBytes } from 'node:crypto'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import * as speakeasy from 'speakeasy'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { getIpHash } from '@/misc/get-ip-hash.js'; | ||||
| import type { ILocalUser } from '@/models/entities/User.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js'; | ||||
| import { RateLimiterService } from './RateLimiterService.js'; | ||||
| import { SigninService } from './SigninService.js'; | ||||
| import type Koa from 'koa'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SigninApiService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userSecurityKeysRepository) | ||||
| 		private userSecurityKeysRepository: UserSecurityKeysRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.attestationChallengesRepository) | ||||
| 		private attestationChallengesRepository: AttestationChallengesRepository, | ||||
|  | ||||
| 		@Inject(DI.signinsRepository) | ||||
| 		private signinsRepository: SigninsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private rateLimiterService: RateLimiterService, | ||||
| 		private signinService: SigninService, | ||||
| 		private twoFactorAuthenticationService: TwoFactorAuthenticationService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public async signin(ctx: Koa.Context) { | ||||
| 		ctx.set('Access-Control-Allow-Origin', this.config.url); | ||||
| 		ctx.set('Access-Control-Allow-Credentials', 'true'); | ||||
|  | ||||
| 		const body = ctx.request.body as any; | ||||
| 		const username = body['username']; | ||||
| 		const password = body['password']; | ||||
| 		const token = body['token']; | ||||
|  | ||||
| 		function error(status: number, error: { id: string }) { | ||||
| 			ctx.status = status; | ||||
| 			ctx.body = { error }; | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 		// not more than 1 attempt per second and not more than 10 attempts per hour | ||||
| 			await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip)); | ||||
| 		} catch (err) { | ||||
| 			ctx.status = 429; | ||||
| 			ctx.body = { | ||||
| 				error: { | ||||
| 					message: 'Too many failed attempts to sign in. Try again later.', | ||||
| 					code: 'TOO_MANY_AUTHENTICATION_FAILURES', | ||||
| 					id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', | ||||
| 				}, | ||||
| 			}; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (typeof username !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (typeof password !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (token != null && typeof token !== 'string') { | ||||
| 			ctx.status = 400; | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		// Fetch user | ||||
| 		const user = await this.usersRepository.findOneBy({ | ||||
| 			usernameLower: username.toLowerCase(), | ||||
| 			host: IsNull(), | ||||
| 		}) as ILocalUser; | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			error(404, { | ||||
| 				id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if (user.isSuspended) { | ||||
| 			error(403, { | ||||
| 				id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', | ||||
| 			}); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); | ||||
|  | ||||
| 		// Compare password | ||||
| 		const same = await bcrypt.compare(password, profile.password!); | ||||
|  | ||||
| 		const fail = async (status?: number, failure?: { id: string }) => { | ||||
| 		// Append signin history | ||||
| 			await this.signinsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				userId: user.id, | ||||
| 				ip: ctx.ip, | ||||
| 				headers: ctx.headers, | ||||
| 				success: false, | ||||
| 			}); | ||||
|  | ||||
| 			error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); | ||||
| 		}; | ||||
|  | ||||
| 		if (!profile.twoFactorEnabled) { | ||||
| 			if (same) { | ||||
| 				this.signinService.signin(ctx, user); | ||||
| 				return; | ||||
| 			} else { | ||||
| 				await fail(403, { | ||||
| 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (token) { | ||||
| 			if (!same) { | ||||
| 				await fail(403, { | ||||
| 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const verified = (speakeasy as any).totp.verify({ | ||||
| 				secret: profile.twoFactorSecret, | ||||
| 				encoding: 'base32', | ||||
| 				token: token, | ||||
| 				window: 2, | ||||
| 			}); | ||||
|  | ||||
| 			if (verified) { | ||||
| 				this.signinService.signin(ctx, user); | ||||
| 				return; | ||||
| 			} else { | ||||
| 				await fail(403, { | ||||
| 					id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
| 		} else if (body.credentialId) { | ||||
| 			if (!same && !profile.usePasswordLessLogin) { | ||||
| 				await fail(403, { | ||||
| 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); | ||||
| 			const clientData = JSON.parse(clientDataJSON.toString('utf-8')); | ||||
| 			const challenge = await this.attestationChallengesRepository.findOneBy({ | ||||
| 				userId: user.id, | ||||
| 				id: body.challengeId, | ||||
| 				registrationChallenge: false, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'), | ||||
| 			}); | ||||
|  | ||||
| 			if (!challenge) { | ||||
| 				await fail(403, { | ||||
| 					id: '2715a88a-2125-4013-932f-aa6fe72792da', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			await this.attestationChallengesRepository.delete({ | ||||
| 				userId: user.id, | ||||
| 				id: body.challengeId, | ||||
| 			}); | ||||
|  | ||||
| 			if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { | ||||
| 				await fail(403, { | ||||
| 					id: '2715a88a-2125-4013-932f-aa6fe72792da', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const securityKey = await this.userSecurityKeysRepository.findOneBy({ | ||||
| 				id: Buffer.from( | ||||
| 					body.credentialId | ||||
| 						.replace(/-/g, '+') | ||||
| 						.replace(/_/g, '/'), | ||||
| 					'base64', | ||||
| 				).toString('hex'), | ||||
| 			}); | ||||
|  | ||||
| 			if (!securityKey) { | ||||
| 				await fail(403, { | ||||
| 					id: '66269679-aeaf-4474-862b-eb761197e046', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const isValid = this.twoFactorAuthenticationService.verifySignin({ | ||||
| 				publicKey: Buffer.from(securityKey.publicKey, 'hex'), | ||||
| 				authenticatorData: Buffer.from(body.authenticatorData, 'hex'), | ||||
| 				clientDataJSON, | ||||
| 				clientData, | ||||
| 				signature: Buffer.from(body.signature, 'hex'), | ||||
| 				challenge: challenge.challenge, | ||||
| 			}); | ||||
|  | ||||
| 			if (isValid) { | ||||
| 				this.signinService.signin(ctx, user); | ||||
| 				return; | ||||
| 			} else { | ||||
| 				await fail(403, { | ||||
| 					id: '93b86c4b-72f9-40eb-9815-798928603d1e', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (!same && !profile.usePasswordLessLogin) { | ||||
| 				await fail(403, { | ||||
| 					id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const keys = await this.userSecurityKeysRepository.findBy({ | ||||
| 				userId: user.id, | ||||
| 			}); | ||||
|  | ||||
| 			if (keys.length === 0) { | ||||
| 				await fail(403, { | ||||
| 					id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4', | ||||
| 				}); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// 32 byte challenge | ||||
| 			const challenge = randomBytes(32).toString('base64') | ||||
| 				.replace(/=/g, '') | ||||
| 				.replace(/\+/g, '-') | ||||
| 				.replace(/\//g, '_'); | ||||
|  | ||||
| 			const challengeId = this.idService.genId(); | ||||
|  | ||||
| 			await this.attestationChallengesRepository.insert({ | ||||
| 				userId: user.id, | ||||
| 				id: challengeId, | ||||
| 				challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'), | ||||
| 				createdAt: new Date(), | ||||
| 				registrationChallenge: false, | ||||
| 			}); | ||||
|  | ||||
| 			ctx.body = { | ||||
| 				challenge, | ||||
| 				challengeId, | ||||
| 				securityKeys: keys.map(key => ({ | ||||
| 					id: key.id, | ||||
| 				})), | ||||
| 			}; | ||||
| 			ctx.status = 200; | ||||
| 			return; | ||||
| 		} | ||||
| 	// never get here | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										64
									
								
								packages/backend/src/server/api/SigninService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/backend/src/server/api/SigninService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { SigninsRepository } from '@/models/index.js'; | ||||
| import type { UsersRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { ILocalUser } from '@/models/entities/User.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; | ||||
| import type Koa from 'koa'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SigninService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.signinsRepository) | ||||
| 		private signinsRepository: SigninsRepository, | ||||
|  | ||||
| 		private signinEntityService: SigninEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public signin(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: this.config.url.startsWith('https'), | ||||
| 				httpOnly: false, | ||||
| 			}); | ||||
| 			//#endregion | ||||
| 	 | ||||
| 			ctx.redirect(this.config.url); | ||||
| 		} else { | ||||
| 			ctx.body = { | ||||
| 				id: user.id, | ||||
| 				i: user.token, | ||||
| 			}; | ||||
| 			ctx.status = 200; | ||||
| 		} | ||||
| 	 | ||||
| 		(async () => { | ||||
| 			// Append signin history | ||||
| 			const record = await this.signinsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				userId: user.id, | ||||
| 				ip: ctx.ip, | ||||
| 				headers: ctx.headers, | ||||
| 				success: true, | ||||
| 			}).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	 | ||||
| 			// Publish signin event | ||||
| 			this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); | ||||
| 		})(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										175
									
								
								packages/backend/src/server/api/SignupApiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								packages/backend/src/server/api/SignupApiService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import rndstr from 'rndstr'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { CaptchaService } from '@/core/CaptchaService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { SignupService } from '@/core/SignupService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { EmailService } from '@/core/EmailService.js'; | ||||
| import { SigninService } from './SigninService.js'; | ||||
| import type Koa from 'koa'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SignupApiService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
|  | ||||
| 		@Inject(DI.userPendingsRepository) | ||||
| 		private userPendingsRepository: UserPendingsRepository, | ||||
|  | ||||
| 		@Inject(DI.registrationTicketsRepository) | ||||
| 		private registrationTicketsRepository: RegistrationTicketsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private metaService: MetaService, | ||||
| 		private captchaService: CaptchaService, | ||||
| 		private signupService: SignupService, | ||||
| 		private signinService: SigninService, | ||||
| 		private emailService: EmailService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public async signup(ctx: Koa.Context) { | ||||
| 		const body = ctx.request.body; | ||||
|  | ||||
| 		const instance = await this.metaService.fetch(true); | ||||
| 	 | ||||
| 		// Verify *Captcha | ||||
| 		// ただしテスト時はこの機構は障害となるため無効にする | ||||
| 		if (process.env.NODE_ENV !== 'test') { | ||||
| 			if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { | ||||
| 				await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { | ||||
| 					ctx.throw(400, e); | ||||
| 				}); | ||||
| 			} | ||||
| 	 | ||||
| 			if (instance.enableRecaptcha && instance.recaptchaSecretKey) { | ||||
| 				await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { | ||||
| 					ctx.throw(400, e); | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
| 		const username = body['username']; | ||||
| 		const password = body['password']; | ||||
| 		const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null; | ||||
| 		const invitationCode = body['invitationCode']; | ||||
| 		const emailAddress = body['emailAddress']; | ||||
| 	 | ||||
| 		if (instance.emailRequiredForSignup) { | ||||
| 			if (emailAddress == null || typeof emailAddress !== 'string') { | ||||
| 				ctx.status = 400; | ||||
| 				return; | ||||
| 			} | ||||
| 	 | ||||
| 			const available = await this.emailService.validateEmailForAccount(emailAddress); | ||||
| 			if (!available) { | ||||
| 				ctx.status = 400; | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
| 	 | ||||
| 		if (instance.disableRegistration) { | ||||
| 			if (invitationCode == null || typeof invitationCode !== 'string') { | ||||
| 				ctx.status = 400; | ||||
| 				return; | ||||
| 			} | ||||
| 	 | ||||
| 			const ticket = await this.registrationTicketsRepository.findOneBy({ | ||||
| 				code: invitationCode, | ||||
| 			}); | ||||
| 	 | ||||
| 			if (ticket == null) { | ||||
| 				ctx.status = 400; | ||||
| 				return; | ||||
| 			} | ||||
| 	 | ||||
| 			this.registrationTicketsRepository.delete(ticket.id); | ||||
| 		} | ||||
| 	 | ||||
| 		if (instance.emailRequiredForSignup) { | ||||
| 			const code = rndstr('a-z0-9', 16); | ||||
| 	 | ||||
| 			// Generate hash of password | ||||
| 			const salt = await bcrypt.genSalt(8); | ||||
| 			const hash = await bcrypt.hash(password, salt); | ||||
| 	 | ||||
| 			await this.userPendingsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				code, | ||||
| 				email: emailAddress, | ||||
| 				username: username, | ||||
| 				password: hash, | ||||
| 			}); | ||||
| 	 | ||||
| 			const link = `${this.config.url}/signup-complete/${code}`; | ||||
| 	 | ||||
| 			sendEmail(emailAddress, 'Signup', | ||||
| 				`To complete signup, please click this link:<br><a href="${link}">${link}</a>`, | ||||
| 				`To complete signup, please click this link: ${link}`); | ||||
| 	 | ||||
| 			ctx.status = 204; | ||||
| 		} else { | ||||
| 			try { | ||||
| 				const { account, secret } = await this.signupService.signup({ | ||||
| 					username, password, host, | ||||
| 				}); | ||||
| 	 | ||||
| 				const res = await this.userEntityService.pack(account, account, { | ||||
| 					detail: true, | ||||
| 					includeSecrets: true, | ||||
| 				}); | ||||
| 	 | ||||
| 				(res as any).token = secret; | ||||
| 	 | ||||
| 				ctx.body = res; | ||||
| 			} catch (e) { | ||||
| 				ctx.throw(400, e); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async signupPending(ctx: Koa.Context) { | ||||
| 		const body = ctx.request.body; | ||||
|  | ||||
| 		const code = body['code']; | ||||
|  | ||||
| 		try { | ||||
| 			const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); | ||||
|  | ||||
| 			const { account, secret } = await this.signupService.signup({ | ||||
| 				username: pendingUser.username, | ||||
| 				passwordHash: pendingUser.password, | ||||
| 			}); | ||||
|  | ||||
| 			this.userPendingsRepository.delete({ | ||||
| 				id: pendingUser.id, | ||||
| 			}); | ||||
|  | ||||
| 			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: account.id }); | ||||
|  | ||||
| 			await this.userProfilesRepository.update({ userId: profile.userId }, { | ||||
| 				email: pendingUser.email, | ||||
| 				emailVerified: true, | ||||
| 				emailVerifyCode: null, | ||||
| 			}); | ||||
|  | ||||
| 			this.signinService.signin(ctx, account); | ||||
| 		} catch (e) { | ||||
| 			ctx.throw(400, e); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										120
									
								
								packages/backend/src/server/api/StreamingApiServerService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								packages/backend/src/server/api/StreamingApiServerService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import { EventEmitter } from 'events'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import Redis from 'ioredis'; | ||||
| import * as websocket from 'websocket'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { NoteReadService } from '@/core/NoteReadService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { NotificationService } from '@/core/NotificationService.js'; | ||||
| import { AuthenticateService } from './AuthenticateService.js'; | ||||
| import MainStreamConnection from './stream/index.js'; | ||||
| import { ChannelsService } from './stream/ChannelsService.js'; | ||||
| import type { ParsedUrlQuery } from 'querystring'; | ||||
| import type * as http from 'node:http'; | ||||
|  | ||||
| @Injectable() | ||||
| export class StreamingApiServerService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 		@Inject(DI.redisSubscriber) | ||||
| 		private redisSubscriber: Redis.Redis, | ||||
|  | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.followingsRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.mutingsRepository) | ||||
| 		private mutingsRepository: MutingsRepository, | ||||
|  | ||||
| 		@Inject(DI.blockingsRepository) | ||||
| 		private blockingsRepository: BlockingsRepository, | ||||
|  | ||||
| 		@Inject(DI.channelFollowingsRepository) | ||||
| 		private channelFollowingsRepository: ChannelFollowingsRepository, | ||||
|  | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
| 	 | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private noteReadService: NoteReadService, | ||||
| 		private authenticateService: AuthenticateService, | ||||
| 		private channelsService: ChannelsService, | ||||
| 		private notificationService: NotificationService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	public attachStreamingApi(server: http.Server) { | ||||
| 		// Init websocket server | ||||
| 		const ws = new websocket.server({ | ||||
| 			httpServer: server, | ||||
| 		}); | ||||
|  | ||||
| 		ws.on('request', async (request) => { | ||||
| 			const q = request.resourceURL.query as ParsedUrlQuery; | ||||
|  | ||||
| 			// TODO: トークンが間違ってるなどしてauthenticateに失敗したら | ||||
| 			// コネクション切断するなりエラーメッセージ返すなりする | ||||
| 			// (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) | ||||
| 			const [user, miapp] = await this.authenticateService.authenticate(q.i as string); | ||||
|  | ||||
| 			if (user?.isSuspended) { | ||||
| 				request.reject(400); | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			const connection = request.accept(); | ||||
|  | ||||
| 			const ev = new EventEmitter(); | ||||
|  | ||||
| 			async function onRedisMessage(_: string, data: string) { | ||||
| 				const parsed = JSON.parse(data); | ||||
| 				ev.emit(parsed.channel, parsed.message); | ||||
| 			} | ||||
|  | ||||
| 			this.redisSubscriber.on('message', onRedisMessage); | ||||
|  | ||||
| 			const main = new MainStreamConnection( | ||||
| 				this.followingsRepository, | ||||
| 				this.mutingsRepository, | ||||
| 				this.blockingsRepository, | ||||
| 				this.channelFollowingsRepository, | ||||
| 				this.userProfilesRepository, | ||||
| 				this.channelsService, | ||||
| 				this.globalEventService, | ||||
| 				this.noteReadService, | ||||
| 				this.notificationService, | ||||
| 				connection, ev, user, miapp, | ||||
| 			); | ||||
|  | ||||
| 			const intervalId = user ? setInterval(() => { | ||||
| 				this.usersRepository.update(user.id, { | ||||
| 					lastActiveDate: new Date(), | ||||
| 				}); | ||||
| 			}, 1000 * 60 * 5) : null; | ||||
| 			if (user) { | ||||
| 				this.usersRepository.update(user.id, { | ||||
| 					lastActiveDate: new Date(), | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			connection.once('close', () => { | ||||
| 				ev.removeAllListeners(); | ||||
| 				main.dispose(); | ||||
| 				this.redisSubscriber.off('message', onRedisMessage); | ||||
| 				if (intervalId) clearInterval(intervalId); | ||||
| 			}); | ||||
|  | ||||
| 			connection.on('message', async (data) => { | ||||
| 				if (data.type === 'utf8' && data.utf8Data === 'ping') { | ||||
| 					connection.send('pong'); | ||||
| 				} | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
| @@ -1,92 +0,0 @@ | ||||
| import Koa from 'koa'; | ||||
|  | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { UserIps } from '@/models/index.js'; | ||||
| import { fetchMeta } from '@/misc/fetch-meta.js'; | ||||
| import { IEndpoint } from './endpoints.js'; | ||||
| import authenticate, { AuthenticationError } from './authenticate.js'; | ||||
| import call from './call.js'; | ||||
| import { ApiError } from './error.js'; | ||||
|  | ||||
| const userIpHistories = new Map<User['id'], Set<string>>(); | ||||
|  | ||||
| setInterval(() => { | ||||
| 	userIpHistories.clear(); | ||||
| }, 1000 * 60 * 60); | ||||
|  | ||||
| export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res) => { | ||||
| 	const body = ctx.is('multipart/form-data') | ||||
| 		? (ctx.request as any).body | ||||
| 		: ctx.method === 'GET' | ||||
| 			? ctx.query | ||||
| 			: ctx.request.body; | ||||
|  | ||||
| 	const reply = (x?: any, y?: ApiError) => { | ||||
| 		if (x == null) { | ||||
| 			ctx.status = 204; | ||||
| 		} else if (typeof x === 'number' && y) { | ||||
| 			ctx.status = x; | ||||
| 			ctx.body = { | ||||
| 				error: { | ||||
| 					message: y!.message, | ||||
| 					code: y!.code, | ||||
| 					id: y!.id, | ||||
| 					kind: y!.kind, | ||||
| 					...(y!.info ? { info: y!.info } : {}), | ||||
| 				}, | ||||
| 			}; | ||||
| 		} else { | ||||
| 			// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない | ||||
| 			ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; | ||||
| 		} | ||||
| 		res(); | ||||
| 	}; | ||||
|  | ||||
| 	// Authentication | ||||
| 	authenticate(body['i']).then(([user, app]) => { | ||||
| 		// API invoking | ||||
| 		call(endpoint.name, user, app, body, ctx).then((res: any) => { | ||||
| 			if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { | ||||
| 				ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); | ||||
| 			} | ||||
| 			reply(res); | ||||
| 		}).catch((e: ApiError) => { | ||||
| 			reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); | ||||
| 		}); | ||||
|  | ||||
| 		// Log IP | ||||
| 		if (user) { | ||||
| 			fetchMeta().then(meta => { | ||||
| 				if (!meta.enableIpLogging) return; | ||||
| 				const ip = ctx.ip; | ||||
| 				const ips = userIpHistories.get(user.id); | ||||
| 				if (ips == null || !ips.has(ip)) { | ||||
| 					if (ips == null) { | ||||
| 						userIpHistories.set(user.id, new Set([ip])); | ||||
| 					} else { | ||||
| 						ips.add(ip); | ||||
| 					} | ||||
|  | ||||
| 					try { | ||||
| 						UserIps.createQueryBuilder().insert().values({ | ||||
| 							createdAt: new Date(), | ||||
| 							userId: user.id, | ||||
| 							ip: ip, | ||||
| 						}).orIgnore(true).execute(); | ||||
| 					} catch { | ||||
| 					} | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	}).catch(e => { | ||||
| 		if (e instanceof AuthenticationError) { | ||||
| 			reply(403, new ApiError({ | ||||
| 				message: 'Authentication failed. Please ensure your token is correct.', | ||||
| 				code: 'AUTHENTICATION_FAILED', | ||||
| 				id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', | ||||
| 			})); | ||||
| 		} else { | ||||
| 			reply(500, new ApiError()); | ||||
| 		} | ||||
| 	}); | ||||
| }); | ||||
| @@ -1,66 +0,0 @@ | ||||
| import isNativeToken from './common/is-native-token.js'; | ||||
| import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; | ||||
| import { Users, AccessTokens, Apps } from '@/models/index.js'; | ||||
| import { AccessToken } from '@/models/entities/access-token.js'; | ||||
| import { Cache } from '@/misc/cache.js'; | ||||
| import { App } from '@/models/entities/app.js'; | ||||
| import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; | ||||
|  | ||||
| const appCache = new Cache<App>(Infinity); | ||||
|  | ||||
| export class AuthenticationError extends Error { | ||||
| 	constructor(message: string) { | ||||
| 		super(message); | ||||
| 		this.name = 'AuthenticationError'; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export default async (token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => { | ||||
| 	if (token == null) { | ||||
| 		return [null, null]; | ||||
| 	} | ||||
|  | ||||
| 	if (isNativeToken(token)) { | ||||
| 		const user = await localUserByNativeTokenCache.fetch(token, | ||||
| 			() => Users.findOneBy({ token }) as Promise<ILocalUser | null>); | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			throw new AuthenticationError('user not found'); | ||||
| 		} | ||||
|  | ||||
| 		return [user, null]; | ||||
| 	} else { | ||||
| 		const accessToken = await AccessTokens.findOne({ | ||||
| 			where: [{ | ||||
| 				hash: token.toLowerCase(), // app | ||||
| 			}, { | ||||
| 				token: token, // miauth | ||||
| 			}], | ||||
| 		}); | ||||
|  | ||||
| 		if (accessToken == null) { | ||||
| 			throw new AuthenticationError('invalid signature'); | ||||
| 		} | ||||
|  | ||||
| 		AccessTokens.update(accessToken.id, { | ||||
| 			lastUsedAt: new Date(), | ||||
| 		}); | ||||
|  | ||||
| 		const user = await localUserByIdCache.fetch(accessToken.userId, | ||||
| 			() => Users.findOneBy({ | ||||
| 				id: accessToken.userId, | ||||
| 			}) as Promise<ILocalUser>); | ||||
|  | ||||
| 		if (accessToken.appId) { | ||||
| 			const app = await appCache.fetch(accessToken.appId, | ||||
| 				() => Apps.findOneByOrFail({ id: accessToken.appId! })); | ||||
|  | ||||
| 			return [user, { | ||||
| 				id: accessToken.id, | ||||
| 				permission: app.permission, | ||||
| 			} as AccessToken]; | ||||
| 		} else { | ||||
| 			return [user, accessToken]; | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
| @@ -1,147 +0,0 @@ | ||||
| import { performance } from 'perf_hooks'; | ||||
| import Koa from 'koa'; | ||||
| import { CacheableLocalUser, User } from '@/models/entities/user.js'; | ||||
| import { AccessToken } from '@/models/entities/access-token.js'; | ||||
| import { getIpHash } from '@/misc/get-ip-hash.js'; | ||||
| import { limiter } from './limiter.js'; | ||||
| import endpoints, { IEndpointMeta } from './endpoints.js'; | ||||
| import { ApiError } from './error.js'; | ||||
| import { apiLogger } from './logger.js'; | ||||
|  | ||||
| const accessDenied = { | ||||
| 	message: 'Access denied.', | ||||
| 	code: 'ACCESS_DENIED', | ||||
| 	id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', | ||||
| }; | ||||
|  | ||||
| export default async (endpoint: string, user: CacheableLocalUser | null | undefined, token: AccessToken | null | undefined, data: any, ctx?: Koa.Context) => { | ||||
| 	const isSecure = user != null && token == null; | ||||
| 	const isModerator = user != null && (user.isModerator || user.isAdmin); | ||||
|  | ||||
| 	const ep = endpoints.find(e => e.name === endpoint); | ||||
|  | ||||
| 	if (ep == null) { | ||||
| 		throw new ApiError({ | ||||
| 			message: 'No such endpoint.', | ||||
| 			code: 'NO_SUCH_ENDPOINT', | ||||
| 			id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709', | ||||
| 			httpStatusCode: 404, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.secure && !isSecure) { | ||||
| 		throw new ApiError(accessDenied); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.limit) { | ||||
| 		// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. | ||||
| 		let limitActor: string; | ||||
| 		if (user) { | ||||
| 			limitActor = user.id; | ||||
| 		} else { | ||||
| 			limitActor = getIpHash(ctx!.ip); | ||||
| 		} | ||||
|  | ||||
| 		const limit = Object.assign({}, ep.meta.limit); | ||||
|  | ||||
| 		if (!limit.key) { | ||||
| 			limit.key = ep.name; | ||||
| 		} | ||||
|  | ||||
| 		// Rate limit | ||||
| 		await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => { | ||||
| 			throw new ApiError({ | ||||
| 				message: 'Rate limit exceeded. Please try again later.', | ||||
| 				code: 'RATE_LIMIT_EXCEEDED', | ||||
| 				id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', | ||||
| 				httpStatusCode: 429, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.requireCredential && user == null) { | ||||
| 		throw new ApiError({ | ||||
| 			message: 'Credential required.', | ||||
| 			code: 'CREDENTIAL_REQUIRED', | ||||
| 			id: '1384574d-a912-4b81-8601-c7b1c4085df1', | ||||
| 			httpStatusCode: 401, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.requireCredential && user!.isSuspended) { | ||||
| 		throw new ApiError({ | ||||
| 			message: 'Your account has been suspended.', | ||||
| 			code: 'YOUR_ACCOUNT_SUSPENDED', | ||||
| 			id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', | ||||
| 			httpStatusCode: 403, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.requireAdmin && !user!.isAdmin) { | ||||
| 		throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); | ||||
| 	} | ||||
|  | ||||
| 	if (ep.meta.requireModerator && !isModerator) { | ||||
| 		throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); | ||||
| 	} | ||||
|  | ||||
| 	if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { | ||||
| 		throw new ApiError({ | ||||
| 			message: 'Your app does not have the necessary permissions to use this endpoint.', | ||||
| 			code: 'PERMISSION_DENIED', | ||||
| 			id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	// Cast non JSON input | ||||
| 	if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { | ||||
| 		for (const k of Object.keys(ep.params.properties)) { | ||||
| 			const param = ep.params.properties![k]; | ||||
| 			if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { | ||||
| 				try { | ||||
| 					data[k] = JSON.parse(data[k]); | ||||
| 				} catch (e) { | ||||
| 					throw	new ApiError({ | ||||
| 						message: 'Invalid param.', | ||||
| 						code: 'INVALID_PARAM', | ||||
| 						id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', | ||||
| 					}, { | ||||
| 						param: k, | ||||
| 						reason: `cannot cast to ${param.type}`, | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// API invoking | ||||
| 	const before = performance.now(); | ||||
| 	return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => { | ||||
| 		if (e instanceof ApiError) { | ||||
| 			throw e; | ||||
| 		} else { | ||||
| 			apiLogger.error(`Internal error occurred in ${ep.name}: ${e.message}`, { | ||||
| 				ep: ep.name, | ||||
| 				ps: data, | ||||
| 				e: { | ||||
| 					message: e.message, | ||||
| 					code: e.name, | ||||
| 					stack: e.stack, | ||||
| 				}, | ||||
| 			}); | ||||
| 			throw new ApiError(null, { | ||||
| 				e: { | ||||
| 					message: e.message, | ||||
| 					code: e.name, | ||||
| 					stack: e.stack, | ||||
| 				}, | ||||
| 			}); | ||||
| 		} | ||||
| 	}).finally(() => { | ||||
| 		const after = performance.now(); | ||||
| 		const time = after - before; | ||||
| 		if (time > 1000) { | ||||
| 			apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); | ||||
| 		} | ||||
| 	}); | ||||
| }; | ||||
							
								
								
									
										71
									
								
								packages/backend/src/server/api/common/GetterService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								packages/backend/src/server/api/common/GetterService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { NotesRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { Note } from '@/models/entities/Note.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class GetterService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private notesRepository: NotesRepository, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Get note for API processing | ||||
| 	 */ | ||||
| 	public async getNote(noteId: Note['id']) { | ||||
| 		const note = await this.notesRepository.findOneBy({ id: noteId }); | ||||
|  | ||||
| 		if (note == null) { | ||||
| 			throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); | ||||
| 		} | ||||
|  | ||||
| 		return note; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Get user for API processing | ||||
| 	 */ | ||||
| 	public async getUser(userId: User['id']) { | ||||
| 		const user = await this.usersRepository.findOneBy({ id: userId }); | ||||
|  | ||||
| 		if (user == null) { | ||||
| 			throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); | ||||
| 		} | ||||
|  | ||||
| 		return user; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Get remote user for API processing | ||||
| 	 */ | ||||
| 	public async getRemoteUser(userId: User['id']) { | ||||
| 		const user = await this.getUser(userId); | ||||
|  | ||||
| 		if (!this.userEntityService.isRemoteUser(user)) { | ||||
| 			throw new Error('user is not a remote user'); | ||||
| 		} | ||||
|  | ||||
| 		return user; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Get local user for API processing | ||||
| 	 */ | ||||
| 	public async getLocalUser(userId: User['id']) { | ||||
| 		const user = await this.getUser(userId); | ||||
|  | ||||
| 		if (!this.userEntityService.isLocalUser(user)) { | ||||
| 			throw new Error('user is not a local user'); | ||||
| 		} | ||||
|  | ||||
| 		return user; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -1,42 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Blockings } from '@/models/index.js'; | ||||
| 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()); | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { ChannelFollowings } from '@/models/index.js'; | ||||
| 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()); | ||||
| 	} | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { MutedNotes } from '@/models/index.js'; | ||||
| 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()); | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { NoteThreadMutings } from '@/models/index.js'; | ||||
| 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()); | ||||
| } | ||||
| @@ -1,57 +0,0 @@ | ||||
| import { SelectQueryBuilder, Brackets } from 'typeorm'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Mutings, UserProfiles } from '@/models/index.js'; | ||||
|  | ||||
| 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 }); | ||||
| 	} | ||||
|  | ||||
| 	const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') | ||||
| 		.select('user_profile.mutedInstances') | ||||
| 		.where('user_profile.userId = :muterId', { muterId: me.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() })`); | ||||
| 		})) | ||||
| 		// mute instances | ||||
| 		.andWhere(new Brackets(qb => { qb | ||||
| 			.andWhere('note.userHost IS NULL') | ||||
| 			.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); | ||||
| 		})) | ||||
| 		.andWhere(new Brackets(qb => { qb | ||||
| 			.where('note.replyUserHost IS NULL') | ||||
| 			.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); | ||||
| 		})) | ||||
| 		.andWhere(new Brackets(qb => { qb | ||||
| 			.where('note.renoteUserHost IS NULL') | ||||
| 			.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); | ||||
| 		})); | ||||
|  | ||||
| 	q.setParameters(mutingQuery.getParameters()); | ||||
| 	q.setParameters(mutingInstanceQuery.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()); | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| import { secureRndstr } from '@/misc/secure-rndstr.js'; | ||||
|  | ||||
| export default () => secureRndstr(16, true); | ||||
| @@ -1,27 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Brackets, SelectQueryBuilder } from 'typeorm'; | ||||
|  | ||||
| export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | 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 if (!me.showTimelineReplies) { | ||||
| 		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'); | ||||
| 			})); | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
| @@ -1,42 +0,0 @@ | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Followings } from '@/models/index.js'; | ||||
| import { Brackets, SelectQueryBuilder } from 'typeorm'; | ||||
|  | ||||
| export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { | ||||
| 	// This code must always be synchronized with the checks in Notes.isVisibleForMe. | ||||
| 	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 = :meId'); | ||||
|  | ||||
| 		q.andWhere(new Brackets(qb => { qb | ||||
| 			// 公開投稿である | ||||
| 			.where(new Brackets(qb => { qb | ||||
| 				.where(`note.visibility = 'public'`) | ||||
| 				.orWhere(`note.visibility = 'home'`); | ||||
| 			})) | ||||
| 			// または 自分自身 | ||||
| 			.orWhere('note.userId = :meId') | ||||
| 			// または 自分宛て | ||||
| 			.orWhere(':meId = ANY(note.visibleUserIds)') | ||||
| 			.orWhere(':meId = ANY(note.mentions)') | ||||
| 			.orWhere(new Brackets(qb => { qb | ||||
| 				// または フォロワー宛ての投稿であり、 | ||||
| 				.where(`note.visibility = 'followers'`) | ||||
| 				.andWhere(new Brackets(qb => { qb | ||||
| 					// 自分がフォロワーである | ||||
| 					.where(`note.userId IN (${ followingQuery.getQuery() })`) | ||||
| 					// または 自分の投稿へのリプライ | ||||
| 					.orWhere('note.replyUserId = :meId'); | ||||
| 				})); | ||||
| 			})); | ||||
| 		})); | ||||
|  | ||||
| 		q.setParameters({ meId: me.id }); | ||||
| 	} | ||||
| } | ||||
| @@ -1,56 +0,0 @@ | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Note } from '@/models/entities/note.js'; | ||||
| import { Notes, Users } from '@/models/index.js'; | ||||
|  | ||||
| /** | ||||
|  * Get note for API processing | ||||
|  */ | ||||
| export async function getNote(noteId: Note['id']) { | ||||
| 	const note = await Notes.findOneBy({ id: 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.findOneBy({ id: 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; | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| import rndstr from 'rndstr'; | ||||
| import { Note } from '@/models/entities/note.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Note } from '@/models/entities/Note.js'; | ||||
| import { User } from '@/models/entities/User.js'; | ||||
| import { Notes, UserProfiles, NoteReactions } from '@/models/index.js'; | ||||
| import { generateMutedUserQuery } from './generate-muted-user-query.js'; | ||||
| import { generateBlockedUserQuery } from './generate-block-query.js'; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import rndstr from 'rndstr'; | ||||
| import { Note } from '@/models/entities/note.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Note } from '@/models/entities/Note.js'; | ||||
| import { User } from '@/models/entities/User.js'; | ||||
| import { PromoReads, PromoNotes, Notes, Users } from '@/models/index.js'; | ||||
|  | ||||
| export async function injectPromo(timeline: Note[], user?: User | null) { | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
| export default (token: string) => token.length === 16; | ||||
| @@ -1,28 +0,0 @@ | ||||
| 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; | ||||
| } | ||||
| @@ -1,151 +0,0 @@ | ||||
| import { publishMainStream, publishGroupMessagingStream } from '@/services/stream.js'; | ||||
| import { publishMessagingStream } from '@/services/stream.js'; | ||||
| import { publishMessagingIndexStream } from '@/services/stream.js'; | ||||
| import { pushNotification } from '@/services/push-notification.js'; | ||||
| import { User, IRemoteUser } from '@/models/entities/user.js'; | ||||
| import { MessagingMessage } from '@/models/entities/messaging-message.js'; | ||||
| import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import { UserGroup } from '@/models/entities/user-group.js'; | ||||
| import { toArray } from '@/prelude/array.js'; | ||||
| import { renderReadActivity } from '@/remote/activitypub/renderer/read.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import { deliver } from '@/queue/index.js'; | ||||
| import orderedCollection from '@/remote/activitypub/renderer/ordered-collection.js'; | ||||
|  | ||||
| /** | ||||
|  * 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.findBy({ | ||||
| 		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'); | ||||
| 		pushNotification(userId, 'readAllMessagingMessages', undefined); | ||||
| 	} else { | ||||
| 		// そのユーザーとのメッセージで未読がなければイベント発行 | ||||
| 		const count = await MessagingMessages.count({ | ||||
| 			where: { | ||||
| 				userId: otherpartyId, | ||||
| 				recipientId: userId, | ||||
| 				isRead: false, | ||||
| 			}, | ||||
| 			take: 1 | ||||
| 		}); | ||||
|  | ||||
| 		if (!count) { | ||||
| 			pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * 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.findOneBy({ | ||||
| 		userId: userId, | ||||
| 		userGroupId: groupId, | ||||
| 	}); | ||||
|  | ||||
| 	if (joining == null) { | ||||
| 		throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); | ||||
| 	} | ||||
|  | ||||
| 	const messages = await MessagingMessages.findBy({ | ||||
| 		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'); | ||||
| 		pushNotification(userId, 'readAllMessagingMessages', undefined); | ||||
| 	} else { | ||||
| 		// そのグループにおいて未読がなければイベント発行 | ||||
| 		const unreadExist = await MessagingMessages.createQueryBuilder('message') | ||||
| 			.where(`message.groupId = :groupId`, { groupId: groupId }) | ||||
| 			.andWhere('message.userId != :userId', { userId: userId }) | ||||
| 			.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) | ||||
| 			.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない | ||||
| 			.getOne().then(x => x != null); | ||||
|  | ||||
| 		if (!unreadExist) { | ||||
| 			pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId }); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -1,50 +0,0 @@ | ||||
| import { In } from 'typeorm'; | ||||
| import { publishMainStream } from '@/services/stream.js'; | ||||
| import { pushNotification } from '@/services/push-notification.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Notification } from '@/models/entities/notification.js'; | ||||
| import { Notifications, Users } from '@/models/index.js'; | ||||
|  | ||||
| export async function readNotification( | ||||
| 	userId: User['id'], | ||||
| 	notificationIds: Notification['id'][], | ||||
| ) { | ||||
| 	if (notificationIds.length === 0) return; | ||||
|  | ||||
| 	// Update documents | ||||
| 	const result = await Notifications.update({ | ||||
| 		notifieeId: userId, | ||||
| 		id: In(notificationIds), | ||||
| 		isRead: false, | ||||
| 	}, { | ||||
| 		isRead: true, | ||||
| 	}); | ||||
|  | ||||
| 	if (result.affected === 0) return; | ||||
|  | ||||
| 	if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId); | ||||
| 	else return postReadNotifications(userId, notificationIds); | ||||
| } | ||||
|  | ||||
| export async function readNotificationByQuery( | ||||
| 	userId: User['id'], | ||||
| 	query: Record<string, any>, | ||||
| ) { | ||||
| 	const notificationIds = await Notifications.findBy({ | ||||
| 		...query, | ||||
| 		notifieeId: userId, | ||||
| 		isRead: false, | ||||
| 	}).then(notifications => notifications.map(notification => notification.id)); | ||||
|  | ||||
| 	return readNotification(userId, notificationIds); | ||||
| } | ||||
|  | ||||
| function postReadAllNotifications(userId: User['id']) { | ||||
| 	publishMainStream(userId, 'readAllNotifications'); | ||||
| 	return pushNotification(userId, 'readAllNotifications', undefined); | ||||
| } | ||||
|  | ||||
| function postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { | ||||
| 	publishMainStream(userId, 'readNotifications', notificationIds); | ||||
| 	return pushNotification(userId, 'readNotifications', { notificationIds }); | ||||
| } | ||||
| @@ -1,44 +0,0 @@ | ||||
| import Koa from 'koa'; | ||||
|  | ||||
| import config from '@/config/index.js'; | ||||
| import { ILocalUser } from '@/models/entities/user.js'; | ||||
| import { Signins } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { publishMainStream } from '@/services/stream.js'; | ||||
|  | ||||
| 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.insert({ | ||||
| 			id: genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			userId: user.id, | ||||
| 			ip: ctx.ip, | ||||
| 			headers: ctx.headers, | ||||
| 			success: true, | ||||
| 		}).then(x => Signins.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 		// Publish signin event | ||||
| 		publishMainStream(user.id, 'signin', await Signins.pack(record)); | ||||
| 	})(); | ||||
| } | ||||
| @@ -1,114 +0,0 @@ | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import { generateKeyPair } from 'node:crypto'; | ||||
| import generateUserToken from './generate-native-user-token.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { Users, UsedUsernames } from '@/models/index.js'; | ||||
| import { UserProfile } from '@/models/entities/user-profile.js'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { toPunyNullable } from '@/misc/convert-host.js'; | ||||
| import { UserKeypair } from '@/models/entities/user-keypair.js'; | ||||
| import { usersChart } from '@/services/chart/index.js'; | ||||
| import { UsedUsername } from '@/models/entities/used-username.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
|  | ||||
| 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(username)) { | ||||
| 		throw new Error('INVALID_USERNAME'); | ||||
| 	} | ||||
|  | ||||
| 	if (password != null && passwordHash == null) { | ||||
| 		// Validate password | ||||
| 		if (!Users.validatePassword(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.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { | ||||
| 		throw new Error('DUPLICATED_USERNAME'); | ||||
| 	} | ||||
|  | ||||
| 	// Check deleted username duplication | ||||
| 	if (await UsedUsernames.findOneBy({ 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 db.transaction(async transactionalEntityManager => { | ||||
| 		const exist = await transactionalEntityManager.findOneBy(User, { | ||||
| 			usernameLower: username.toLowerCase(), | ||||
| 			host: IsNull(), | ||||
| 		}); | ||||
|  | ||||
| 		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.countBy({ | ||||
| 				host: IsNull(), | ||||
| 			})) === 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 }; | ||||
| } | ||||
| @@ -1,59 +0,0 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import Ajv from 'ajv'; | ||||
| import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; | ||||
| import { Schema, SchemaType } from '@/misc/schema.js'; | ||||
| import { AccessToken } from '@/models/entities/access-token.js'; | ||||
| import { IEndpointMeta } from './endpoints.js'; | ||||
| import { ApiError } from './error.js'; | ||||
|  | ||||
| export type Response = Record<string, any> | void; | ||||
|  | ||||
| // TODO: paramsの型をT['params']のスキーマ定義から推論する | ||||
| type executor<T extends IEndpointMeta, Ps extends Schema> = | ||||
| 	(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => | ||||
| 		Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; | ||||
|  | ||||
| const ajv = new Ajv({ | ||||
| 	useDefaults: true, | ||||
| }); | ||||
|  | ||||
| ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); | ||||
|  | ||||
| export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, paramDef: Ps, cb: executor<T, Ps>) | ||||
| 		: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => Promise<any> { | ||||
| 	const validate = ajv.compile(paramDef); | ||||
|  | ||||
| 	return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => { | ||||
| 		let cleanup: undefined | (() => void) = undefined; | ||||
|  | ||||
| 		if (meta.requireFile) { | ||||
| 			cleanup = () => { | ||||
| 				fs.unlink(file.path, () => {}); | ||||
| 			}; | ||||
|  | ||||
| 			if (file == null) return Promise.reject(new ApiError({ | ||||
| 				message: 'File required.', | ||||
| 				code: 'FILE_REQUIRED', | ||||
| 				id: '4267801e-70d1-416a-b011-4ee502885d8b', | ||||
| 			})); | ||||
| 		} | ||||
|  | ||||
| 		const valid = validate(params); | ||||
| 		if (!valid) { | ||||
| 			if (file) cleanup!(); | ||||
|  | ||||
| 			const errors = validate.errors!; | ||||
| 			const err = new ApiError({ | ||||
| 				message: 'Invalid param.', | ||||
| 				code: 'INVALID_PARAM', | ||||
| 				id: '3d81ceae-475f-4600-b2a8-2bc116157532', | ||||
| 			}, { | ||||
| 				param: errors[0].schemaPath, | ||||
| 				reason: errors[0].message, | ||||
| 			}); | ||||
| 			return Promise.reject(err); | ||||
| 		} | ||||
|  | ||||
| 		return cb(params as SchemaType<Ps>, user, token, file, cleanup, ip, headers); | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										62
									
								
								packages/backend/src/server/api/endpoint-base.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								packages/backend/src/server/api/endpoint-base.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import Ajv from 'ajv'; | ||||
| import type { Schema, SchemaType } from '@/misc/schema.js'; | ||||
| import type { CacheableLocalUser } from '@/models/entities/User.js'; | ||||
| import type { AccessToken } from '@/models/entities/AccessToken.js'; | ||||
| import { ApiError } from './error.js'; | ||||
| import type { IEndpointMeta } from './endpoints.js'; | ||||
|  | ||||
| const ajv = new Ajv({ | ||||
| 	useDefaults: true, | ||||
| }); | ||||
|  | ||||
| ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); | ||||
|  | ||||
| export type Response = Record<string, any> | void; | ||||
|  | ||||
| // TODO: paramsの型をT['params']のスキーマ定義から推論する | ||||
| type executor<T extends IEndpointMeta, Ps extends Schema> = | ||||
| 	(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) => | ||||
| 		Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; | ||||
|  | ||||
| export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> { | ||||
| 	public exec: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>; | ||||
|  | ||||
| 	constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) { | ||||
| 		const validate = ajv.compile(paramDef); | ||||
|  | ||||
| 		this.exec = (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => { | ||||
| 			let cleanup: undefined | (() => void) = undefined; | ||||
| 	 | ||||
| 			if (meta.requireFile) { | ||||
| 				cleanup = () => { | ||||
| 					fs.unlink(file.path, () => {}); | ||||
| 				}; | ||||
| 	 | ||||
| 				if (file == null) return Promise.reject(new ApiError({ | ||||
| 					message: 'File required.', | ||||
| 					code: 'FILE_REQUIRED', | ||||
| 					id: '4267801e-70d1-416a-b011-4ee502885d8b', | ||||
| 				})); | ||||
| 			} | ||||
| 	 | ||||
| 			const valid = validate(params); | ||||
| 			if (!valid) { | ||||
| 				if (file) cleanup!(); | ||||
| 	 | ||||
| 				const errors = validate.errors!; | ||||
| 				const err = new ApiError({ | ||||
| 					message: 'Invalid param.', | ||||
| 					code: 'INVALID_PARAM', | ||||
| 					id: '3d81ceae-475f-4600-b2a8-2bc116157532', | ||||
| 				}, { | ||||
| 					param: errors[0].schemaPath, | ||||
| 					reason: errors[0].message, | ||||
| 				}); | ||||
| 				return Promise.reject(err); | ||||
| 			} | ||||
| 	 | ||||
| 			return cb(params as SchemaType<Ps>, user, token, file, cleanup, ip, headers); | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Schema } from '@/misc/schema.js'; | ||||
| import type { Schema } from '@/misc/schema.js'; | ||||
|  | ||||
| import * as ep___admin_meta from './endpoints/admin/meta.js'; | ||||
| import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; | ||||
| @@ -59,7 +59,6 @@ import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; | ||||
| import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; | ||||
| import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; | ||||
| import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; | ||||
| import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; | ||||
| import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; | ||||
| import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; | ||||
| import * as ep___announcements from './endpoints/announcements.js'; | ||||
| @@ -253,8 +252,6 @@ import * as ep___notes_timeline from './endpoints/notes/timeline.js'; | ||||
| import * as ep___notes_translate from './endpoints/notes/translate.js'; | ||||
| import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; | ||||
| import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; | ||||
| import * as ep___notes_watching_create from './endpoints/notes/watching/create.js'; | ||||
| import * as ep___notes_watching_delete from './endpoints/notes/watching/delete.js'; | ||||
| import * as ep___notifications_create from './endpoints/notifications/create.js'; | ||||
| import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; | ||||
| import * as ep___notifications_read from './endpoints/notifications/read.js'; | ||||
| @@ -376,7 +373,6 @@ const eps = [ | ||||
| 	['admin/unsilence-user', ep___admin_unsilenceUser], | ||||
| 	['admin/unsuspend-user', ep___admin_unsuspendUser], | ||||
| 	['admin/update-meta', ep___admin_updateMeta], | ||||
| 	['admin/vacuum', ep___admin_vacuum], | ||||
| 	['admin/delete-account', ep___admin_deleteAccount], | ||||
| 	['admin/update-user-note', ep___admin_updateUserNote], | ||||
| 	['announcements', ep___announcements], | ||||
| @@ -570,8 +566,6 @@ const eps = [ | ||||
| 	['notes/translate', ep___notes_translate], | ||||
| 	['notes/unrenote', ep___notes_unrenote], | ||||
| 	['notes/user-list-timeline', ep___notes_userListTimeline], | ||||
| 	['notes/watching/create', ep___notes_watching_create], | ||||
| 	['notes/watching/delete', ep___notes_watching_delete], | ||||
| 	['notifications/create', ep___notifications_create], | ||||
| 	['notifications/mark-all-as-read', ep___notifications_markAllAsRead], | ||||
| 	['notifications/read', ep___notifications_read], | ||||
| @@ -727,7 +721,6 @@ export interface IEndpointMeta { | ||||
|  | ||||
| export interface IEndpoint { | ||||
| 	name: string; | ||||
| 	exec: any; | ||||
| 	meta: IEndpointMeta; | ||||
| 	params: Schema; | ||||
| } | ||||
| @@ -735,7 +728,6 @@ export interface IEndpoint { | ||||
| const endpoints: IEndpoint[] = eps.map(([name, ep]) => { | ||||
| 	return { | ||||
| 		name: name, | ||||
| 		exec: ep.default, | ||||
| 		meta: ep.meta || {}, | ||||
| 		params: ep.paramDef, | ||||
| 	}; | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../define.js'; | ||||
| import { AbuseUserReports } from '@/models/index.js'; | ||||
| import { makePaginationQuery } from '../../common/make-pagination-query.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AbuseUserReportsRepository } from '@/models/index.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -77,33 +79,43 @@ export const paramDef = { | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		state: { type: 'string', nullable: true, default: null }, | ||||
| 		reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, | ||||
| 		targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "combined" }, | ||||
| 		reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, | ||||
| 		targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, | ||||
| 		forwarded: { type: 'boolean', default: false }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.abuseUserReportsRepository) | ||||
| 		private abuseUserReportsRepository: AbuseUserReportsRepository, | ||||
|  | ||||
| 	switch (ps.state) { | ||||
| 		case 'resolved': query.andWhere('report.resolved = TRUE'); break; | ||||
| 		case 'unresolved': query.andWhere('report.resolved = FALSE'); break; | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); | ||||
|  | ||||
| 			switch (ps.state) { | ||||
| 				case 'resolved': query.andWhere('report.resolved = TRUE'); break; | ||||
| 				case 'unresolved': query.andWhere('report.resolved = FALSE'); break; | ||||
| 			} | ||||
|  | ||||
| 			switch (ps.reporterOrigin) { | ||||
| 				case 'local': query.andWhere('report.reporterHost IS NULL'); break; | ||||
| 				case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; | ||||
| 			} | ||||
|  | ||||
| 			switch (ps.targetUserOrigin) { | ||||
| 				case 'local': query.andWhere('report.targetUserHost IS NULL'); break; | ||||
| 				case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; | ||||
| 			} | ||||
|  | ||||
| 			const reports = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 			return await this.abuseUserReportEntityService.packMany(reports); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	switch (ps.reporterOrigin) { | ||||
| 		case 'local': query.andWhere('report.reporterHost IS NULL'); break; | ||||
| 		case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; | ||||
| 	} | ||||
|  | ||||
| 	switch (ps.targetUserOrigin) { | ||||
| 		case 'local': query.andWhere('report.targetUserHost IS NULL'); break; | ||||
| 		case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; | ||||
| 	} | ||||
|  | ||||
| 	const reports = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 	return await AbuseUserReports.packMany(reports); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { signup } from '../../../common/signup.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { SignupService } from '@/core/SignupService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { localUsernameSchema, passwordSchema } from '@/models/entities/User.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -22,31 +26,42 @@ export const meta = { | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		username: Users.localUsernameSchema, | ||||
| 		password: Users.passwordSchema, | ||||
| 		username: localUsernameSchema, | ||||
| 		password: passwordSchema, | ||||
| 	}, | ||||
| 	required: ['username', 'password'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, _me) => { | ||||
| 	const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; | ||||
| 	const noUsers = (await Users.countBy({ | ||||
| 		host: IsNull(), | ||||
| 	})) === 0; | ||||
| 	if (!noUsers && !me?.isAdmin) throw new Error('access denied'); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	const { account, secret } = await signup({ | ||||
| 		username: ps.username, | ||||
| 		password: ps.password, | ||||
| 	}); | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private signupService: SignupService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, _me) => { | ||||
| 			const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; | ||||
| 			const noUsers = (await this.usersRepository.countBy({ | ||||
| 				host: IsNull(), | ||||
| 			})) === 0; | ||||
| 			if (!noUsers && !me?.isAdmin) throw new Error('access denied'); | ||||
|  | ||||
| 	const res = await Users.pack(account, account, { | ||||
| 		detail: true, | ||||
| 		includeSecrets: true, | ||||
| 	}); | ||||
| 			const { account, secret } = await this.signupService.signup({ | ||||
| 				username: ps.username, | ||||
| 				password: ps.password, | ||||
| 			}); | ||||
|  | ||||
| 	(res as any).token = secret; | ||||
| 			const res = await this.userEntityService.pack(account, account, { | ||||
| 				detail: true, | ||||
| 				includeSecrets: true, | ||||
| 			}); | ||||
|  | ||||
| 	return res; | ||||
| }); | ||||
| 			(res as any).token = secret; | ||||
|  | ||||
| 			return res; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { doPostSuspend } from '@/services/suspend-user.js'; | ||||
| import { publishUserEvent } from '@/services/stream.js'; | ||||
| import { createDeleteAccountJob } from '@/queue/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { UserSuspendService } from '@/core/UserSuspendService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -20,40 +22,52 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const user = await Users.findOneBy({ id: ps.userId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		throw new Error('user not found'); | ||||
| 	} | ||||
| 		private queueService: QueueService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private userSuspendService: UserSuspendService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const user = await this.usersRepository.findOneBy({ id: ps.userId }); | ||||
|  | ||||
| 	if (user.isAdmin) { | ||||
| 		throw new Error('cannot suspend admin'); | ||||
| 	} | ||||
| 			if (user == null) { | ||||
| 				throw new Error('user not found'); | ||||
| 			} | ||||
|  | ||||
| 	if (user.isModerator) { | ||||
| 		throw new Error('cannot suspend moderator'); | ||||
| 	} | ||||
| 			if (user.isAdmin) { | ||||
| 				throw new Error('cannot suspend admin'); | ||||
| 			} | ||||
|  | ||||
| 	if (Users.isLocalUser(user)) { | ||||
| 		// 物理削除する前にDelete activityを送信する | ||||
| 		await doPostSuspend(user).catch(e => {}); | ||||
| 			if (user.isModerator) { | ||||
| 				throw new Error('cannot suspend moderator'); | ||||
| 			} | ||||
|  | ||||
| 		createDeleteAccountJob(user, { | ||||
| 			soft: false, | ||||
| 		}); | ||||
| 	} else { | ||||
| 		createDeleteAccountJob(user, { | ||||
| 			soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する | ||||
| 			if (this.userEntityService.isLocalUser(user)) { | ||||
| 				// 物理削除する前にDelete activityを送信する | ||||
| 				await this.userSuspendService.doPostSuspend(user).catch(err => {}); | ||||
|  | ||||
| 				this.queueService.createDeleteAccountJob(user, { | ||||
| 					soft: false, | ||||
| 				}); | ||||
| 			} else { | ||||
| 				this.queueService.createDeleteAccountJob(user, { | ||||
| 					soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			await this.usersRepository.update(user.id, { | ||||
| 				isDeleted: true, | ||||
| 			}); | ||||
|  | ||||
| 			if (this.userEntityService.isLocalUser(user)) { | ||||
| 				// Terminate streaming | ||||
| 				this.globalEventService.publishUserEvent(user.id, 'terminate', {}); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	await Users.update(user.id, { | ||||
| 		isDeleted: true, | ||||
| 	}); | ||||
|  | ||||
| 	if (Users.isLocalUser(user)) { | ||||
| 		// Terminate streaming | ||||
| 		publishUserEvent(user.id, 'terminate', {}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Ads } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AdsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -24,16 +26,26 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	await Ads.insert({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		expiresAt: new Date(ps.expiresAt), | ||||
| 		url: ps.url, | ||||
| 		imageUrl: ps.imageUrl, | ||||
| 		priority: ps.priority, | ||||
| 		ratio: ps.ratio, | ||||
| 		place: ps.place, | ||||
| 		memo: ps.memo, | ||||
| 	}); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.adsRepository) | ||||
| 		private adsRepository: AdsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			await this.adsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				expiresAt: new Date(ps.expiresAt), | ||||
| 				url: ps.url, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 				priority: ps.priority, | ||||
| 				ratio: ps.ratio, | ||||
| 				place: ps.place, | ||||
| 				memo: ps.memo, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Ads } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AdsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -26,10 +28,18 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const ad = await Ads.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.adsRepository) | ||||
| 		private adsRepository: AdsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const ad = await this.adsRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	if (ad == null) throw new ApiError(meta.errors.noSuchAd); | ||||
| 			if (ad == null) throw new ApiError(meta.errors.noSuchAd); | ||||
|  | ||||
| 	await Ads.delete(ad.id); | ||||
| }); | ||||
| 			await this.adsRepository.delete(ad.id); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Ads } from '@/models/index.js'; | ||||
| import { makePaginationQuery } from '../../../common/make-pagination-query.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AdsRepository } from '@/models/index.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -20,11 +22,21 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) | ||||
| 		.andWhere('ad.expiresAt > :now', { now: new Date() }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.adsRepository) | ||||
| 		private adsRepository: AdsRepository, | ||||
|  | ||||
| 	const ads = await query.take(ps.limit).getMany(); | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId) | ||||
| 				.andWhere('ad.expiresAt > :now', { now: new Date() }); | ||||
|  | ||||
| 	return ads; | ||||
| }); | ||||
| 			const ads = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 			return ads; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Ads } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AdsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -33,18 +35,26 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const ad = await Ads.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private adsRepository: AdsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const ad = await this.adsRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	if (ad == null) throw new ApiError(meta.errors.noSuchAd); | ||||
| 			if (ad == null) throw new ApiError(meta.errors.noSuchAd); | ||||
|  | ||||
| 	await Ads.update(ad.id, { | ||||
| 		url: ps.url, | ||||
| 		place: ps.place, | ||||
| 		priority: ps.priority, | ||||
| 		ratio: ps.ratio, | ||||
| 		memo: ps.memo, | ||||
| 		imageUrl: ps.imageUrl, | ||||
| 		expiresAt: new Date(ps.expiresAt), | ||||
| 	}); | ||||
| }); | ||||
| 			await this.adsRepository.update(ad.id, { | ||||
| 				url: ps.url, | ||||
| 				place: ps.place, | ||||
| 				priority: ps.priority, | ||||
| 				ratio: ps.ratio, | ||||
| 				memo: ps.memo, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 				expiresAt: new Date(ps.expiresAt), | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Announcements } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -55,15 +57,25 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const announcement = await Announcements.insert({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		updatedAt: null, | ||||
| 		title: ps.title, | ||||
| 		text: ps.text, | ||||
| 		imageUrl: ps.imageUrl, | ||||
| 	}).then(x => Announcements.findOneByOrFail(x.identifiers[0])); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 	return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); | ||||
| }); | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				updatedAt: null, | ||||
| 				title: ps.title, | ||||
| 				text: ps.text, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 			}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 			return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Announcements } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -26,10 +28,18 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const announcement = await Announcements.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
| 			if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
|  | ||||
| 	await Announcements.delete(announcement.id); | ||||
| }); | ||||
| 			await this.announcementsRepository.delete(announcement.id); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import { Announcements, AnnouncementReads } from '@/models/index.js'; | ||||
| import { Announcement } from '@/models/entities/announcement.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { makePaginationQuery } from '../../../common/make-pagination-query.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js'; | ||||
| import type { Announcement } from '@/models/entities/Announcement.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -64,26 +66,39 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 	const announcements = await query.take(ps.limit).getMany(); | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 	const reads = new Map<Announcement, number>(); | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
|  | ||||
| 	for (const announcement of announcements) { | ||||
| 		reads.set(announcement, await AnnouncementReads.countBy({ | ||||
| 			announcementId: announcement.id, | ||||
| 		})); | ||||
| 			const announcements = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 			const reads = new Map<Announcement, number>(); | ||||
|  | ||||
| 			for (const announcement of announcements) { | ||||
| 				reads.set(announcement, await this.announcementReadsRepository.countBy({ | ||||
| 					announcementId: announcement.id, | ||||
| 				})); | ||||
| 			} | ||||
|  | ||||
| 			return announcements.map(announcement => ({ | ||||
| 				id: announcement.id, | ||||
| 				createdAt: announcement.createdAt.toISOString(), | ||||
| 				updatedAt: announcement.updatedAt?.toISOString() ?? null, | ||||
| 				title: announcement.title, | ||||
| 				text: announcement.text, | ||||
| 				imageUrl: announcement.imageUrl, | ||||
| 				reads: reads.get(announcement)!, | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	return announcements.map(announcement => ({ | ||||
| 		id: announcement.id, | ||||
| 		createdAt: announcement.createdAt.toISOString(), | ||||
| 		updatedAt: announcement.updatedAt?.toISOString() ?? null, | ||||
| 		title: announcement.title, | ||||
| 		text: announcement.text, | ||||
| 		imageUrl: announcement.imageUrl, | ||||
| 		reads: reads.get(announcement)!, | ||||
| 	})); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Announcements } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -29,15 +31,23 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const announcement = await Announcements.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
| 			if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
|  | ||||
| 	await Announcements.update(announcement.id, { | ||||
| 		updatedAt: new Date(), | ||||
| 		title: ps.title, | ||||
| 		text: ps.text, | ||||
| 		imageUrl: ps.imageUrl, | ||||
| 	}); | ||||
| }); | ||||
| 			await this.announcementsRepository.update(announcement.id, { | ||||
| 				updatedAt: new Date(), | ||||
| 				title: ps.title, | ||||
| 				text: ps.text, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { deleteAccount } from '@/services/delete-account.js'; | ||||
| import define from '../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DeleteAccountService } from '@/core/DeleteAccountService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -21,11 +23,21 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const user = await Users.findOneByOrFail({ id: ps.userId }); | ||||
| 	if (user.isDeleted) { | ||||
| 		return; | ||||
| 	} | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	await deleteAccount(user); | ||||
| }); | ||||
| 		private deleteAccountService: DeleteAccountService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps) => { | ||||
| 			const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); | ||||
| 			if (user.isDeleted) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			await this.deleteAccountService.deleteAccount(user); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../define.js'; | ||||
| import { deleteFile } from '@/services/drive/delete-file.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { DriveService } from '@/core/DriveService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -18,12 +20,22 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const files = await DriveFiles.findBy({ | ||||
| 		userId: ps.userId, | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 	for (const file of files) { | ||||
| 		deleteFile(file); | ||||
| 		private driveService: DriveService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const files = await this.driveFilesRepository.findBy({ | ||||
| 				userId: ps.userId, | ||||
| 			}); | ||||
|  | ||||
| 			for (const file of files) { | ||||
| 				this.driveService.deleteFile(file); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../define.js'; | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
|  | ||||
| @@ -19,29 +21,39 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const user = await Users.findOneBy({ id: ps.userId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		throw new Error('user not found'); | ||||
| 	} | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const user = await this.usersRepository.findOneBy({ id: ps.userId }); | ||||
|  | ||||
| 	if (!Users.isLocalUser(user)) { | ||||
| 		throw new Error('user is not local user'); | ||||
| 	}  | ||||
| 			if (user == null) { | ||||
| 				throw new Error('user not found'); | ||||
| 			} | ||||
|  | ||||
| 	/*if (user.isAdmin) { | ||||
| 			if (!this.userEntityService.isLocalUser(user)) { | ||||
| 				throw new Error('user is not local user'); | ||||
| 			}  | ||||
|  | ||||
| 			/*if (user.isAdmin) { | ||||
| 		throw new Error('cannot suspend admin'); | ||||
| 	} | ||||
| 	if (user.isModerator) { | ||||
| 		throw new Error('cannot suspend moderator'); | ||||
| 	}*/ | ||||
|  | ||||
| 	await Users.update(user.id, { | ||||
| 		driveCapacityOverrideMb: ps.overrideMb, | ||||
| 	}); | ||||
| 			await this.usersRepository.update(user.id, { | ||||
| 				driveCapacityOverrideMb: ps.overrideMb, | ||||
| 			}); | ||||
|  | ||||
| 	insertModerationLog(me, 'change-drive-capacity-override', { | ||||
| 		targetId: user.id, | ||||
| 	}); | ||||
| }); | ||||
| 			this.moderationLogService.insertModerationLog(me, 'change-drive-capacity-override', { | ||||
| 				targetId: user.id, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { createCleanRemoteFilesJob } from '@/queue/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -15,6 +16,13 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	createCleanRemoteFilesJob(); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private queueService: QueueService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			this.queueService.createCleanRemoteFilesJob(); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import { IsNull } from 'typeorm'; | ||||
| import define from '../../../define.js'; | ||||
| import { deleteFile } from '@/services/drive/delete-file.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { DriveService } from '@/core/DriveService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -17,12 +19,22 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const files = await DriveFiles.findBy({ | ||||
| 		userId: IsNull(), | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 	for (const file of files) { | ||||
| 		deleteFile(file); | ||||
| 		private driveService: DriveService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const files = await this.driveFilesRepository.findBy({ | ||||
| 				userId: IsNull(), | ||||
| 			}); | ||||
|  | ||||
| 			for (const file of files) { | ||||
| 				this.driveService.deleteFile(file); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { makePaginationQuery } from '../../../common/make-pagination-query.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -39,32 +41,42 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 	if (ps.userId) { | ||||
| 		query.andWhere('file.userId = :userId', { userId: ps.userId }); | ||||
| 	} else { | ||||
| 		if (ps.origin === 'local') { | ||||
| 			query.andWhere('file.userHost IS NULL'); | ||||
| 		} else if (ps.origin === 'remote') { | ||||
| 			query.andWhere('file.userHost IS NOT NULL'); | ||||
| 		} | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId); | ||||
|  | ||||
| 		if (ps.hostname) { | ||||
| 			query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); | ||||
| 		} | ||||
| 			if (ps.userId) { | ||||
| 				query.andWhere('file.userId = :userId', { userId: ps.userId }); | ||||
| 			} else { | ||||
| 				if (ps.origin === 'local') { | ||||
| 					query.andWhere('file.userHost IS NULL'); | ||||
| 				} else if (ps.origin === 'remote') { | ||||
| 					query.andWhere('file.userHost IS NOT NULL'); | ||||
| 				} | ||||
|  | ||||
| 				if (ps.hostname) { | ||||
| 					query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (ps.type) { | ||||
| 				if (ps.type.endsWith('/*')) { | ||||
| 					query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); | ||||
| 				} else { | ||||
| 					query.andWhere('file.type = :type', { type: ps.type }); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			const files = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 			return await this.driveFileEntityService.packMany(files, { detail: true, withUser: true, self: true }); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ps.type) { | ||||
| 		if (ps.type.endsWith('/*')) { | ||||
| 			query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); | ||||
| 		} else { | ||||
| 			query.andWhere('file.type = :type', { type: ps.type }); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const files = await query.take(ps.limit).getMany(); | ||||
|  | ||||
| 	return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -169,25 +171,33 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const file = ps.fileId ? await DriveFiles.findOneBy({ id: ps.fileId }) : await DriveFiles.findOne({ | ||||
| 		where: [{ | ||||
| 			url: ps.url, | ||||
| 		}, { | ||||
| 			thumbnailUrl: ps.url, | ||||
| 		}, { | ||||
| 			webpublicUrl: ps.url, | ||||
| 		}], | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ | ||||
| 				where: [{ | ||||
| 					url: ps.url, | ||||
| 				}, { | ||||
| 					thumbnailUrl: ps.url, | ||||
| 				}, { | ||||
| 					webpublicUrl: ps.url, | ||||
| 				}], | ||||
| 			}); | ||||
|  | ||||
| 	if (file == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchFile); | ||||
| 			if (file == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchFile); | ||||
| 			} | ||||
|  | ||||
| 			if (!me.isAdmin) { | ||||
| 				delete file.requestIp; | ||||
| 				delete file.requestHeaders; | ||||
| 			} | ||||
|  | ||||
| 			return file; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (!me.isAdmin) { | ||||
| 		delete file.requestIp; | ||||
| 		delete file.requestHeaders; | ||||
| 	} | ||||
|  | ||||
| 	return file; | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -24,18 +24,31 @@ export const paramDef = { | ||||
| 	required: ['ids', 'aliases'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const emojis = await Emojis.findBy({ | ||||
| 		id: In(ps.ids), | ||||
| 	}); | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| 	for (const emoji of emojis) { | ||||
| 		await Emojis.update(emoji.id, { | ||||
| 			updatedAt: new Date(), | ||||
| 			aliases: [...new Set(emoji.aliases.concat(ps.aliases))], | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emojis = await this.emojisRepository.findBy({ | ||||
| 				id: In(ps.ids), | ||||
| 			}); | ||||
|  | ||||
| 			for (const emoji of emojis) { | ||||
| 				await this.emojisRepository.update(emoji.id, { | ||||
| 					updatedAt: new Date(), | ||||
| 					aliases: [...new Set(emoji.aliases.concat(ps.aliases))], | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,14 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis, DriveFiles } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import rndstr from 'rndstr'; | ||||
| import { publishBroadcastStream } from '@/services/stream.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DriveFilesRepository, EmojisRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -30,37 +33,58 @@ export const paramDef = { | ||||
| 	required: ['fileId'], | ||||
| } as const; | ||||
|  | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const file = await DriveFiles.findOneBy({ id: ps.fileId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 	if (file == null) throw new ApiError(meta.errors.noSuchFile); | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 	const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 	const emoji = await Emojis.insert({ | ||||
| 		id: genId(), | ||||
| 		updatedAt: new Date(), | ||||
| 		name: name, | ||||
| 		category: null, | ||||
| 		host: null, | ||||
| 		aliases: [], | ||||
| 		originalUrl: file.url, | ||||
| 		publicUrl: file.webpublicUrl ?? file.url, | ||||
| 		type: file.webpublicType ?? file.type, | ||||
| 	}).then(x => Emojis.findOneByOrFail(x.identifiers[0])); | ||||
| 		private emojiEntityService: EmojiEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| 			if (file == null) throw new ApiError(meta.errors.noSuchFile); | ||||
|  | ||||
| 	publishBroadcastStream('emojiAdded', { | ||||
| 		emoji: await Emojis.pack(emoji.id), | ||||
| 	}); | ||||
| 			const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; | ||||
|  | ||||
| 	insertModerationLog(me, 'addEmoji', { | ||||
| 		emojiId: emoji.id, | ||||
| 	}); | ||||
| 			const emoji = await this.emojisRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				updatedAt: new Date(), | ||||
| 				name: name, | ||||
| 				category: null, | ||||
| 				host: null, | ||||
| 				aliases: [], | ||||
| 				originalUrl: file.url, | ||||
| 				publicUrl: file.webpublicUrl ?? file.url, | ||||
| 				type: file.webpublicType ?? file.type, | ||||
| 			}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 	return { | ||||
| 		id: emoji.id, | ||||
| 	}; | ||||
| }); | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
|  | ||||
| 			this.globalEventService.publishBroadcastStream('emojiAdded', { | ||||
| 				emoji: await this.emojiEntityService.pack(emoji.id), | ||||
| 			}); | ||||
|  | ||||
| 			this.moderationLogService.insertModerationLog(me, 'addEmoji', { | ||||
| 				emojiId: emoji.id, | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				id: emoji.id, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,14 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { DriveFile } from '@/models/entities/DriveFile.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { DriveService } from '@/core/DriveService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { DriveFile } from '@/models/entities/drive-file.js'; | ||||
| import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; | ||||
| import { publishBroadcastStream } from '@/services/stream.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -42,41 +45,59 @@ export const paramDef = { | ||||
| 	required: ['emojiId'], | ||||
| } as const; | ||||
|  | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const emoji = await Emojis.findOneBy({ id: ps.emojiId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 	if (emoji == null) { | ||||
| 		throw new ApiError(meta.errors.noSuchEmoji); | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 		private emojiEntityService: EmojiEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private driveService: DriveService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ id: ps.emojiId }); | ||||
|  | ||||
| 			if (emoji == null) { | ||||
| 				throw new ApiError(meta.errors.noSuchEmoji); | ||||
| 			} | ||||
|  | ||||
| 			let driveFile: DriveFile; | ||||
|  | ||||
| 			try { | ||||
| 				// Create file | ||||
| 				driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); | ||||
| 			} catch (e) { | ||||
| 				throw new ApiError(); | ||||
| 			} | ||||
|  | ||||
| 			const copied = await this.emojisRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				updatedAt: new Date(), | ||||
| 				name: emoji.name, | ||||
| 				host: null, | ||||
| 				aliases: [], | ||||
| 				originalUrl: driveFile.url, | ||||
| 				publicUrl: driveFile.webpublicUrl ?? driveFile.url, | ||||
| 				type: driveFile.webpublicType ?? driveFile.type, | ||||
| 			}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
|  | ||||
| 			this.globalEventService.publishBroadcastStream('emojiAdded', { | ||||
| 				emoji: await this.emojiEntityService.pack(copied.id), | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				id: copied.id, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	let driveFile: DriveFile; | ||||
|  | ||||
| 	try { | ||||
| 		// Create file | ||||
| 		driveFile = await uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); | ||||
| 	} catch (e) { | ||||
| 		throw new ApiError(); | ||||
| 	} | ||||
|  | ||||
| 	const copied = await Emojis.insert({ | ||||
| 		id: genId(), | ||||
| 		updatedAt: new Date(), | ||||
| 		name: emoji.name, | ||||
| 		host: null, | ||||
| 		aliases: [], | ||||
| 		originalUrl: driveFile.url, | ||||
| 		publicUrl: driveFile.webpublicUrl ?? driveFile.url, | ||||
| 		type: driveFile.webpublicType ?? driveFile.type, | ||||
| 	}).then(x => Emojis.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
|  | ||||
| 	publishBroadcastStream('emojiAdded', { | ||||
| 		emoji: await Emojis.pack(copied.id), | ||||
| 	}); | ||||
|  | ||||
| 	return { | ||||
| 		id: copied.id, | ||||
| 	}; | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -22,19 +22,34 @@ export const paramDef = { | ||||
| 	required: ['ids'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const emojis = await Emojis.findBy({ | ||||
| 		id: In(ps.ids), | ||||
| 	}); | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| 	for (const emoji of emojis) { | ||||
| 		await Emojis.delete(emoji.id); | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emojis = await this.emojisRepository.findBy({ | ||||
| 				id: In(ps.ids), | ||||
| 			}); | ||||
|  | ||||
| 			for (const emoji of emojis) { | ||||
| 				await this.emojisRepository.delete(emoji.id); | ||||
| 	 | ||||
| 		await db.queryResultCache!.remove(['meta_emojis']); | ||||
| 				await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 	 | ||||
| 		insertModerationLog(me, 'deleteEmoji', { | ||||
| 			emoji: emoji, | ||||
| 				this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { | ||||
| 					emoji: emoji, | ||||
| 				}); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -27,17 +29,32 @@ export const paramDef = { | ||||
| 	required: ['id'], | ||||
| } as const; | ||||
|  | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const emoji = await Emojis.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 	if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 	await Emojis.delete(emoji.id); | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| 			if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); | ||||
|  | ||||
| 	insertModerationLog(me, 'deleteEmoji', { | ||||
| 		emoji: emoji, | ||||
| 	}); | ||||
| }); | ||||
| 			await this.emojisRepository.delete(emoji.id); | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
|  | ||||
| 			this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { | ||||
| 				emoji: emoji, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { createImportCustomEmojisJob } from '@/queue/index.js'; | ||||
| import ms from 'ms'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	secure: true, | ||||
| @@ -17,6 +17,13 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	createImportCustomEmojisJob(user, ps.fileId); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private queueService: QueueService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			this.queueService.createImportCustomEmojisJob(me, ps.fileId); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { toPuny } from '@/misc/convert-host.js'; | ||||
| import { makePaginationQuery } from '../../../common/make-pagination-query.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -69,23 +72,35 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 	if (ps.host == null) { | ||||
| 		q.andWhere(`emoji.host IS NOT NULL`); | ||||
| 	} else { | ||||
| 		q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); | ||||
| 		private utilityService: UtilityService, | ||||
| 		private queryService: QueryService, | ||||
| 		private emojiEntityService: EmojiEntityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); | ||||
|  | ||||
| 			if (ps.host == null) { | ||||
| 				q.andWhere('emoji.host IS NOT NULL'); | ||||
| 			} else { | ||||
| 				q.andWhere('emoji.host = :host', { host: this.utilityService.toPuny(ps.host) }); | ||||
| 			} | ||||
|  | ||||
| 			if (ps.query) { | ||||
| 				q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); | ||||
| 			} | ||||
|  | ||||
| 			const emojis = await q | ||||
| 				.orderBy('emoji.id', 'DESC') | ||||
| 				.take(ps.limit) | ||||
| 				.getMany(); | ||||
|  | ||||
| 			return this.emojiEntityService.packMany(emojis); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ps.query) { | ||||
| 		q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); | ||||
| 	} | ||||
|  | ||||
| 	const emojis = await q | ||||
| 		.orderBy('emoji.id', 'DESC') | ||||
| 		.take(ps.limit) | ||||
| 		.getMany(); | ||||
|  | ||||
| 	return Emojis.packMany(emojis); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { makePaginationQuery } from '../../../common/make-pagination-query.js'; | ||||
| import { Emoji } from '@/models/entities/emoji.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import type { Emoji } from '@/models/entities/Emoji.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -63,27 +65,37 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) | ||||
| 		.andWhere(`emoji.host IS NULL`); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
|  | ||||
| 	let emojis: Emoji[]; | ||||
| 		private queryService: QueryService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) | ||||
| 				.andWhere('emoji.host IS NULL'); | ||||
|  | ||||
| 	if (ps.query) { | ||||
| 		//q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); | ||||
| 		//const emojis = await q.take(ps.limit).getMany(); | ||||
| 			let emojis: Emoji[]; | ||||
|  | ||||
| 		emojis = await q.getMany(); | ||||
| 			if (ps.query) { | ||||
| 				//q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); | ||||
| 				//const emojis = await q.take(ps.limit).getMany(); | ||||
|  | ||||
| 		emojis = emojis.filter(emoji => | ||||
| 			emoji.name.includes(ps.query!) || | ||||
| 			emoji.aliases.some(a => a.includes(ps.query!)) || | ||||
| 			emoji.category?.includes(ps.query!)); | ||||
| 				emojis = await q.getMany(); | ||||
|  | ||||
| 		emojis.splice(ps.limit + 1); | ||||
| 	} else { | ||||
| 		emojis = await q.take(ps.limit).getMany(); | ||||
| 				emojis = emojis.filter(emoji => | ||||
| 					emoji.name.includes(ps.query!) || | ||||
| 					emoji.aliases.some(a => a.includes(ps.query!)) || | ||||
| 					emoji.category?.includes(ps.query!)); | ||||
|  | ||||
| 				emojis.splice(ps.limit + 1); | ||||
| 			} else { | ||||
| 				emojis = await q.take(ps.limit).getMany(); | ||||
| 			} | ||||
|  | ||||
| 			return this.emojiEntityService.packMany(emojis); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	return Emojis.packMany(emojis); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -24,18 +24,31 @@ export const paramDef = { | ||||
| 	required: ['ids', 'aliases'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const emojis = await Emojis.findBy({ | ||||
| 		id: In(ps.ids), | ||||
| 	}); | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| 	for (const emoji of emojis) { | ||||
| 		await Emojis.update(emoji.id, { | ||||
| 			updatedAt: new Date(), | ||||
| 			aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emojis = await this.emojisRepository.findBy({ | ||||
| 				id: In(ps.ids), | ||||
| 			}); | ||||
|  | ||||
| 			for (const emoji of emojis) { | ||||
| 				await this.emojisRepository.update(emoji.id, { | ||||
| 					updatedAt: new Date(), | ||||
| 					aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), | ||||
| 				}); | ||||
| 			} | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -24,14 +24,27 @@ export const paramDef = { | ||||
| 	required: ['ids', 'aliases'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	await Emojis.update({ | ||||
| 		id: In(ps.ids), | ||||
| 	}, { | ||||
| 		updatedAt: new Date(), | ||||
| 		aliases: ps.aliases, | ||||
| 	}); | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| }); | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			await this.emojisRepository.update({ | ||||
| 				id: In(ps.ids), | ||||
| 			}, { | ||||
| 				updatedAt: new Date(), | ||||
| 				aliases: ps.aliases, | ||||
| 			}); | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { In } from 'typeorm'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource, In } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -26,14 +26,27 @@ export const paramDef = { | ||||
| 	required: ['ids'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	await Emojis.update({ | ||||
| 		id: In(ps.ids), | ||||
| 	}, { | ||||
| 		updatedAt: new Date(), | ||||
| 		category: ps.category, | ||||
| 	}); | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| }); | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			await this.emojisRepository.update({ | ||||
| 				id: In(ps.ids), | ||||
| 			}, { | ||||
| 				updatedAt: new Date(), | ||||
| 				category: ps.category, | ||||
| 			}); | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Emojis } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmojisRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -35,18 +37,31 @@ export const paramDef = { | ||||
| 	required: ['id', 'name', 'aliases'], | ||||
| } as const; | ||||
|  | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const emoji = await Emojis.findOneBy({ id: ps.id }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 	if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); | ||||
| 		@Inject(DI.emojisRepository) | ||||
| 		private emojisRepository: EmojisRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); | ||||
|  | ||||
| 	await Emojis.update(emoji.id, { | ||||
| 		updatedAt: new Date(), | ||||
| 		name: ps.name, | ||||
| 		category: ps.category, | ||||
| 		aliases: ps.aliases, | ||||
| 	}); | ||||
| 			if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); | ||||
|  | ||||
| 	await db.queryResultCache!.remove(['meta_emojis']); | ||||
| }); | ||||
| 			await this.emojisRepository.update(emoji.id, { | ||||
| 				updatedAt: new Date(), | ||||
| 				name: ps.name, | ||||
| 				category: ps.category, | ||||
| 				aliases: ps.aliases, | ||||
| 			}); | ||||
|  | ||||
| 			await this.db.queryResultCache!.remove(['meta_emojis']); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { deleteFile } from '@/services/drive/delete-file.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DriveFilesRepository } from '@/models/index.js'; | ||||
| import { DriveService } from '@/core/DriveService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -18,12 +20,22 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const files = await DriveFiles.findBy({ | ||||
| 		userHost: ps.host, | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.driveFilesRepository) | ||||
| 		private driveFilesRepository: DriveFilesRepository, | ||||
|  | ||||
| 	for (const file of files) { | ||||
| 		deleteFile(file); | ||||
| 		private driveService: DriveService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const files = await this.driveFilesRepository.findBy({ | ||||
| 				userHost: ps.host, | ||||
| 			}); | ||||
|  | ||||
| 			for (const file of files) { | ||||
| 				this.driveService.deleteFile(file); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Instances } from '@/models/index.js'; | ||||
| import { toPuny } from '@/misc/convert-host.js'; | ||||
| import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { InstancesRepository } from '@/models/index.js'; | ||||
| import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -19,12 +21,23 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
|  | ||||
| 	if (instance == null) { | ||||
| 		throw new Error('instance not found'); | ||||
| 		private utilityService: UtilityService, | ||||
| 		private fetchInstanceMetadataService: FetchInstanceMetadataService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); | ||||
|  | ||||
| 			if (instance == null) { | ||||
| 				throw new Error('instance not found'); | ||||
| 			} | ||||
|  | ||||
| 			this.fetchInstanceMetadataService.fetchInstanceMetadata(instance, true); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	fetchInstanceMetadata(instance, true); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import deleteFollowing from '@/services/following/delete.js'; | ||||
| import { Followings, Users } from '@/models/index.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { FollowingsRepository, UsersRepository } from '@/models/index.js'; | ||||
| import { UserFollowingService } from '@/core/UserFollowingService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -18,17 +20,30 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const followings = await Followings.findBy({ | ||||
| 		followerHost: ps.host, | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	const pairs = await Promise.all(followings.map(f => Promise.all([ | ||||
| 		Users.findOneByOrFail({ id: f.followerId }), | ||||
| 		Users.findOneByOrFail({ id: f.followeeId }), | ||||
| 	]))); | ||||
| 		@Inject(DI.notesRepository) | ||||
| 		private followingsRepository: FollowingsRepository, | ||||
|  | ||||
| 	for (const pair of pairs) { | ||||
| 		deleteFollowing(pair[0], pair[1]); | ||||
| 		private userFollowingService: UserFollowingService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const followings = await this.followingsRepository.findBy({ | ||||
| 				followerHost: ps.host, | ||||
| 			}); | ||||
|  | ||||
| 			const pairs = await Promise.all(followings.map(f => Promise.all([ | ||||
| 				this.usersRepository.findOneByOrFail({ id: f.followerId }), | ||||
| 				this.usersRepository.findOneByOrFail({ id: f.followeeId }), | ||||
| 			]))); | ||||
|  | ||||
| 			for (const pair of pairs) { | ||||
| 				this.userFollowingService.unfollow(pair[0], pair[1]); | ||||
| 			} | ||||
| 		}); | ||||
| 	} | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Instances } from '@/models/index.js'; | ||||
| import { toPuny } from '@/misc/convert-host.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { InstancesRepository } from '@/models/index.js'; | ||||
| import { UtilityService } from '@/core/UtilityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -19,14 +21,24 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.instancesRepository) | ||||
| 		private instancesRepository: InstancesRepository, | ||||
|  | ||||
| 	if (instance == null) { | ||||
| 		throw new Error('instance not found'); | ||||
| 		private utilityService: UtilityService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); | ||||
|  | ||||
| 			if (instance == null) { | ||||
| 				throw new Error('instance not found'); | ||||
| 			} | ||||
|  | ||||
| 			this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, { | ||||
| 				isSuspended: ps.isSuspended, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	Instances.update({ host: toPuny(ps.host) }, { | ||||
| 		isSuspended: ps.isSuspended, | ||||
| 	}); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import define from '../../define.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| @@ -15,14 +17,22 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async () => { | ||||
| 	const stats = await db.query(`SELECT * FROM pg_indexes;`).then(recs => { | ||||
| 		const res = [] as { tablename: string; indexname: string; }[]; | ||||
| 		for (const rec of recs) { | ||||
| 			res.push(rec); | ||||
| 		} | ||||
| 		return res; | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async () => { | ||||
| 			const stats = await this.db.query('SELECT * FROM pg_indexes;').then(recs => { | ||||
| 				const res = [] as { tablename: string; indexname: string; }[]; | ||||
| 				for (const rec of recs) { | ||||
| 					res.push(rec); | ||||
| 				} | ||||
| 				return res; | ||||
| 			}); | ||||
|  | ||||
| 	return stats; | ||||
| }); | ||||
| 			return stats; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import define from '../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| @@ -26,24 +28,31 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async () => { | ||||
| 	const sizes = await | ||||
| 		db.query(` | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async () => { | ||||
| 			const sizes = await this.db.query(` | ||||
| 			SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" | ||||
| 			FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) | ||||
| 			WHERE nspname NOT IN ('pg_catalog', 'information_schema') | ||||
| 				AND C.relkind <> 'i' | ||||
| 				AND nspname !~ '^pg_toast';`) | ||||
| 		.then(recs => { | ||||
| 			const res = {} as Record<string, { count: number; size: number; }>; | ||||
| 			for (const rec of recs) { | ||||
| 				res[rec.table] = { | ||||
| 					count: parseInt(rec.count, 10), | ||||
| 					size: parseInt(rec.size, 10), | ||||
| 				}; | ||||
| 			} | ||||
| 			return res; | ||||
| 		}); | ||||
| 				.then(recs => { | ||||
| 					const res = {} as Record<string, { count: number; size: number; }>; | ||||
| 					for (const rec of recs) { | ||||
| 						res[rec.table] = { | ||||
| 							count: parseInt(rec.count, 10), | ||||
| 							size: parseInt(rec.size, 10), | ||||
| 						}; | ||||
| 					} | ||||
| 					return res; | ||||
| 				}); | ||||
|  | ||||
| 	return sizes; | ||||
| }); | ||||
| 			return sizes; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import { UserIps } from '@/models/index.js'; | ||||
| import define from '../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { UserIpsRepository } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -17,15 +19,23 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const ips = await UserIps.find({ | ||||
| 		where: { userId: ps.userId }, | ||||
| 		order: { createdAt: 'DESC' }, | ||||
| 		take: 30, | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.userIpsRepository) | ||||
| 		private userIpsRepository: UserIpsRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const ips = await this.userIpsRepository.find({ | ||||
| 				where: { userId: ps.userId }, | ||||
| 				order: { createdAt: 'DESC' }, | ||||
| 				take: 30, | ||||
| 			}); | ||||
|  | ||||
| 	return ips.map(x => ({ | ||||
| 		ip: x.ip, | ||||
| 		createdAt: x.createdAt.toISOString(), | ||||
| 	})); | ||||
| }); | ||||
| 			return ips.map(x => ({ | ||||
| 				ip: x.ip, | ||||
| 				createdAt: x.createdAt.toISOString(), | ||||
| 			})); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import rndstr from 'rndstr'; | ||||
| import define from '../../define.js'; | ||||
| import { RegistrationTickets } from '@/models/index.js'; | ||||
| import { genId } from '@/misc/gen-id.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { RegistrationTicketsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -31,19 +33,29 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async () => { | ||||
| 	const code = rndstr({ | ||||
| 		length: 8, | ||||
| 		chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.registrationTicketsRepository) | ||||
| 		private registrationTicketsRepository: RegistrationTicketsRepository, | ||||
|  | ||||
| 	await RegistrationTickets.insert({ | ||||
| 		id: genId(), | ||||
| 		createdAt: new Date(), | ||||
| 		code, | ||||
| 	}); | ||||
| 		private idService: IdService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async () => { | ||||
| 			const code = rndstr({ | ||||
| 				length: 8, | ||||
| 				chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) | ||||
| 			}); | ||||
|  | ||||
| 	return { | ||||
| 		code, | ||||
| 	}; | ||||
| }); | ||||
| 			await this.registrationTicketsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				code, | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				code, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import config from '@/config/index.js'; | ||||
| import { fetchMeta } from '@/misc/fetch-meta.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; | ||||
| import define from '../../define.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { MetaService } from '@/core/MetaService.js'; | ||||
| import { Config } from '@/config.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['meta'], | ||||
| @@ -340,91 +342,101 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const instance = await fetchMeta(true); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
|  | ||||
| 	return { | ||||
| 		maintainerName: instance.maintainerName, | ||||
| 		maintainerEmail: instance.maintainerEmail, | ||||
| 		version: config.version, | ||||
| 		name: instance.name, | ||||
| 		uri: config.url, | ||||
| 		description: instance.description, | ||||
| 		langs: instance.langs, | ||||
| 		tosUrl: instance.ToSUrl, | ||||
| 		repositoryUrl: instance.repositoryUrl, | ||||
| 		feedbackUrl: instance.feedbackUrl, | ||||
| 		disableRegistration: instance.disableRegistration, | ||||
| 		disableLocalTimeline: instance.disableLocalTimeline, | ||||
| 		disableGlobalTimeline: instance.disableGlobalTimeline, | ||||
| 		driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, | ||||
| 		driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, | ||||
| 		emailRequiredForSignup: instance.emailRequiredForSignup, | ||||
| 		enableHcaptcha: instance.enableHcaptcha, | ||||
| 		hcaptchaSiteKey: instance.hcaptchaSiteKey, | ||||
| 		enableRecaptcha: instance.enableRecaptcha, | ||||
| 		recaptchaSiteKey: instance.recaptchaSiteKey, | ||||
| 		swPublickey: instance.swPublicKey, | ||||
| 		themeColor: instance.themeColor, | ||||
| 		mascotImageUrl: instance.mascotImageUrl, | ||||
| 		bannerUrl: instance.bannerUrl, | ||||
| 		errorImageUrl: instance.errorImageUrl, | ||||
| 		iconUrl: instance.iconUrl, | ||||
| 		backgroundImageUrl: instance.backgroundImageUrl, | ||||
| 		logoImageUrl: instance.logoImageUrl, | ||||
| 		maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため | ||||
| 		defaultLightTheme: instance.defaultLightTheme, | ||||
| 		defaultDarkTheme: instance.defaultDarkTheme, | ||||
| 		enableEmail: instance.enableEmail, | ||||
| 		enableTwitterIntegration: instance.enableTwitterIntegration, | ||||
| 		enableGithubIntegration: instance.enableGithubIntegration, | ||||
| 		enableDiscordIntegration: instance.enableDiscordIntegration, | ||||
| 		enableServiceWorker: instance.enableServiceWorker, | ||||
| 		translatorAvailable: instance.deeplAuthKey != null, | ||||
| 		pinnedPages: instance.pinnedPages, | ||||
| 		pinnedClipId: instance.pinnedClipId, | ||||
| 		cacheRemoteFiles: instance.cacheRemoteFiles, | ||||
| 		useStarForReactionFallback: instance.useStarForReactionFallback, | ||||
| 		pinnedUsers: instance.pinnedUsers, | ||||
| 		hiddenTags: instance.hiddenTags, | ||||
| 		blockedHosts: instance.blockedHosts, | ||||
| 		hcaptchaSecretKey: instance.hcaptchaSecretKey, | ||||
| 		recaptchaSecretKey: instance.recaptchaSecretKey, | ||||
| 		sensitiveMediaDetection: instance.sensitiveMediaDetection, | ||||
| 		sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, | ||||
| 		setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, | ||||
| 		enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, | ||||
| 		proxyAccountId: instance.proxyAccountId, | ||||
| 		twitterConsumerKey: instance.twitterConsumerKey, | ||||
| 		twitterConsumerSecret: instance.twitterConsumerSecret, | ||||
| 		githubClientId: instance.githubClientId, | ||||
| 		githubClientSecret: instance.githubClientSecret, | ||||
| 		discordClientId: instance.discordClientId, | ||||
| 		discordClientSecret: instance.discordClientSecret, | ||||
| 		summalyProxy: instance.summalyProxy, | ||||
| 		email: instance.email, | ||||
| 		smtpSecure: instance.smtpSecure, | ||||
| 		smtpHost: instance.smtpHost, | ||||
| 		smtpPort: instance.smtpPort, | ||||
| 		smtpUser: instance.smtpUser, | ||||
| 		smtpPass: instance.smtpPass, | ||||
| 		swPrivateKey: instance.swPrivateKey, | ||||
| 		useObjectStorage: instance.useObjectStorage, | ||||
| 		objectStorageBaseUrl: instance.objectStorageBaseUrl, | ||||
| 		objectStorageBucket: instance.objectStorageBucket, | ||||
| 		objectStoragePrefix: instance.objectStoragePrefix, | ||||
| 		objectStorageEndpoint: instance.objectStorageEndpoint, | ||||
| 		objectStorageRegion: instance.objectStorageRegion, | ||||
| 		objectStoragePort: instance.objectStoragePort, | ||||
| 		objectStorageAccessKey: instance.objectStorageAccessKey, | ||||
| 		objectStorageSecretKey: instance.objectStorageSecretKey, | ||||
| 		objectStorageUseSSL: instance.objectStorageUseSSL, | ||||
| 		objectStorageUseProxy: instance.objectStorageUseProxy, | ||||
| 		objectStorageSetPublicRead: instance.objectStorageSetPublicRead, | ||||
| 		objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, | ||||
| 		deeplAuthKey: instance.deeplAuthKey, | ||||
| 		deeplIsPro: instance.deeplIsPro, | ||||
| 		enableIpLogging: instance.enableIpLogging, | ||||
| 		enableActiveEmailValidation: instance.enableActiveEmailValidation, | ||||
| 	}; | ||||
| }); | ||||
| 		private metaService: MetaService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const instance = await this.metaService.fetch(true); | ||||
|  | ||||
| 			return { | ||||
| 				maintainerName: instance.maintainerName, | ||||
| 				maintainerEmail: instance.maintainerEmail, | ||||
| 				version: this.config.version, | ||||
| 				name: instance.name, | ||||
| 				uri: this.config.url, | ||||
| 				description: instance.description, | ||||
| 				langs: instance.langs, | ||||
| 				tosUrl: instance.ToSUrl, | ||||
| 				repositoryUrl: instance.repositoryUrl, | ||||
| 				feedbackUrl: instance.feedbackUrl, | ||||
| 				disableRegistration: instance.disableRegistration, | ||||
| 				disableLocalTimeline: instance.disableLocalTimeline, | ||||
| 				disableGlobalTimeline: instance.disableGlobalTimeline, | ||||
| 				driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, | ||||
| 				driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, | ||||
| 				emailRequiredForSignup: instance.emailRequiredForSignup, | ||||
| 				enableHcaptcha: instance.enableHcaptcha, | ||||
| 				hcaptchaSiteKey: instance.hcaptchaSiteKey, | ||||
| 				enableRecaptcha: instance.enableRecaptcha, | ||||
| 				recaptchaSiteKey: instance.recaptchaSiteKey, | ||||
| 				swPublickey: instance.swPublicKey, | ||||
| 				themeColor: instance.themeColor, | ||||
| 				mascotImageUrl: instance.mascotImageUrl, | ||||
| 				bannerUrl: instance.bannerUrl, | ||||
| 				errorImageUrl: instance.errorImageUrl, | ||||
| 				iconUrl: instance.iconUrl, | ||||
| 				backgroundImageUrl: instance.backgroundImageUrl, | ||||
| 				logoImageUrl: instance.logoImageUrl, | ||||
| 				maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため | ||||
| 				defaultLightTheme: instance.defaultLightTheme, | ||||
| 				defaultDarkTheme: instance.defaultDarkTheme, | ||||
| 				enableEmail: instance.enableEmail, | ||||
| 				enableTwitterIntegration: instance.enableTwitterIntegration, | ||||
| 				enableGithubIntegration: instance.enableGithubIntegration, | ||||
| 				enableDiscordIntegration: instance.enableDiscordIntegration, | ||||
| 				enableServiceWorker: instance.enableServiceWorker, | ||||
| 				translatorAvailable: instance.deeplAuthKey != null, | ||||
| 				pinnedPages: instance.pinnedPages, | ||||
| 				pinnedClipId: instance.pinnedClipId, | ||||
| 				cacheRemoteFiles: instance.cacheRemoteFiles, | ||||
| 				useStarForReactionFallback: instance.useStarForReactionFallback, | ||||
| 				pinnedUsers: instance.pinnedUsers, | ||||
| 				hiddenTags: instance.hiddenTags, | ||||
| 				blockedHosts: instance.blockedHosts, | ||||
| 				hcaptchaSecretKey: instance.hcaptchaSecretKey, | ||||
| 				recaptchaSecretKey: instance.recaptchaSecretKey, | ||||
| 				sensitiveMediaDetection: instance.sensitiveMediaDetection, | ||||
| 				sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, | ||||
| 				setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, | ||||
| 				enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, | ||||
| 				proxyAccountId: instance.proxyAccountId, | ||||
| 				twitterConsumerKey: instance.twitterConsumerKey, | ||||
| 				twitterConsumerSecret: instance.twitterConsumerSecret, | ||||
| 				githubClientId: instance.githubClientId, | ||||
| 				githubClientSecret: instance.githubClientSecret, | ||||
| 				discordClientId: instance.discordClientId, | ||||
| 				discordClientSecret: instance.discordClientSecret, | ||||
| 				summalyProxy: instance.summalyProxy, | ||||
| 				email: instance.email, | ||||
| 				smtpSecure: instance.smtpSecure, | ||||
| 				smtpHost: instance.smtpHost, | ||||
| 				smtpPort: instance.smtpPort, | ||||
| 				smtpUser: instance.smtpUser, | ||||
| 				smtpPass: instance.smtpPass, | ||||
| 				swPrivateKey: instance.swPrivateKey, | ||||
| 				useObjectStorage: instance.useObjectStorage, | ||||
| 				objectStorageBaseUrl: instance.objectStorageBaseUrl, | ||||
| 				objectStorageBucket: instance.objectStorageBucket, | ||||
| 				objectStoragePrefix: instance.objectStoragePrefix, | ||||
| 				objectStorageEndpoint: instance.objectStorageEndpoint, | ||||
| 				objectStorageRegion: instance.objectStorageRegion, | ||||
| 				objectStoragePort: instance.objectStoragePort, | ||||
| 				objectStorageAccessKey: instance.objectStorageAccessKey, | ||||
| 				objectStorageSecretKey: instance.objectStorageSecretKey, | ||||
| 				objectStorageUseSSL: instance.objectStorageUseSSL, | ||||
| 				objectStorageUseProxy: instance.objectStorageUseProxy, | ||||
| 				objectStorageSetPublicRead: instance.objectStorageSetPublicRead, | ||||
| 				objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, | ||||
| 				deeplAuthKey: instance.deeplAuthKey, | ||||
| 				deeplIsPro: instance.deeplIsPro, | ||||
| 				enableIpLogging: instance.enableIpLogging, | ||||
| 				enableActiveEmailValidation: instance.enableActiveEmailValidation, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { publishInternalEvent } from '@/services/stream.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -18,20 +20,30 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const user = await Users.findOneBy({ id: ps.userId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		throw new Error('user not found'); | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps) => { | ||||
| 			const user = await this.usersRepository.findOneBy({ id: ps.userId }); | ||||
|  | ||||
| 			if (user == null) { | ||||
| 				throw new Error('user not found'); | ||||
| 			} | ||||
|  | ||||
| 			if (user.isAdmin) { | ||||
| 				throw new Error('cannot mark as moderator if admin user'); | ||||
| 			} | ||||
|  | ||||
| 			await this.usersRepository.update(user.id, { | ||||
| 				isModerator: true, | ||||
| 			}); | ||||
|  | ||||
| 			this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (user.isAdmin) { | ||||
| 		throw new Error('cannot mark as moderator if admin user'); | ||||
| 	} | ||||
|  | ||||
| 	await Users.update(user.id, { | ||||
| 		isModerator: true, | ||||
| 	}); | ||||
|  | ||||
| 	publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Users } from '@/models/index.js'; | ||||
| import { publishInternalEvent } from '@/services/stream.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -18,16 +20,26 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const user = await Users.findOneBy({ id: ps.userId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		throw new Error('user not found'); | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps) => { | ||||
| 			const user = await this.usersRepository.findOneBy({ id: ps.userId }); | ||||
|  | ||||
| 			if (user == null) { | ||||
| 				throw new Error('user not found'); | ||||
| 			} | ||||
|  | ||||
| 			await this.usersRepository.update(user.id, { | ||||
| 				isModerator: false, | ||||
| 			}); | ||||
|  | ||||
| 			this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	await Users.update(user.id, { | ||||
| 		isModerator: false, | ||||
| 	}); | ||||
|  | ||||
| 	publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { PromoNotesRepository } from '@/models/index.js'; | ||||
| import { GetterService } from '@/server/api/common/GetterService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { getNote } from '../../../common/getters.js'; | ||||
| import { PromoNotes } from '@/models/index.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -34,21 +36,31 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	const note = await getNote(ps.noteId).catch(e => { | ||||
| 		if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); | ||||
| 		throw e; | ||||
| 	}); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.promoNotesRepository) | ||||
| 		private promoNotesRepository: PromoNotesRepository, | ||||
|  | ||||
| 	const exist = await PromoNotes.findOneBy({ noteId: note.id }); | ||||
| 		private getterService: GetterService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const note = await this.getterService.getNote(ps.noteId).catch(e => { | ||||
| 				if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); | ||||
| 				throw e; | ||||
| 			}); | ||||
|  | ||||
| 	if (exist != null) { | ||||
| 		throw new ApiError(meta.errors.alreadyPromoted); | ||||
| 			const exist = await this.promoNotesRepository.findOneBy({ noteId: note.id }); | ||||
|  | ||||
| 			if (exist != null) { | ||||
| 				throw new ApiError(meta.errors.alreadyPromoted); | ||||
| 			} | ||||
|  | ||||
| 			await this.promoNotesRepository.insert({ | ||||
| 				noteId: note.id, | ||||
| 				expiresAt: new Date(ps.expiresAt), | ||||
| 				userId: note.userId, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	await PromoNotes.insert({ | ||||
| 		noteId: note.id, | ||||
| 		expiresAt: new Date(ps.expiresAt), | ||||
| 		userId: note.userId, | ||||
| 	}); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { destroy } from '@/queue/index.js'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { ModerationLogService } from '@/core/ModerationLogService.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -16,8 +17,16 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	destroy(); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private moderationLogService: ModerationLogService, | ||||
| 		private queueService: QueueService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			this.queueService.destroy(); | ||||
|  | ||||
| 	insertModerationLog(me, 'clearQueue'); | ||||
| }); | ||||
| 			this.moderationLogService.insertModerationLog(me, 'clearQueue'); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { deliverQueue } from '@/queue/queues.js'; | ||||
| import { URL } from 'node:url'; | ||||
| import define from '../../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DeliverQueue } from '@/core/queue/QueueModule.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -39,21 +40,28 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const jobs = await deliverQueue.getJobs(['delayed']); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject('queue:deliver') public deliverQueue: DeliverQueue, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const jobs = await this.deliverQueue.getJobs(['delayed']); | ||||
|  | ||||
| 	const res = [] as [string, number][]; | ||||
| 			const res = [] as [string, number][]; | ||||
|  | ||||
| 	for (const job of jobs) { | ||||
| 		const host = new URL(job.data.to).host; | ||||
| 		if (res.find(x => x[0] === host)) { | ||||
| 			res.find(x => x[0] === host)![1]++; | ||||
| 		} else { | ||||
| 			res.push([host, 1]); | ||||
| 		} | ||||
| 			for (const job of jobs) { | ||||
| 				const host = new URL(job.data.to).host; | ||||
| 				if (res.find(x => x[0] === host)) { | ||||
| 					res.find(x => x[0] === host)![1]++; | ||||
| 				} else { | ||||
| 					res.push([host, 1]); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			res.sort((a, b) => b[1] - a[1]); | ||||
|  | ||||
| 			return res; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	res.sort((a, b) => b[1] - a[1]); | ||||
|  | ||||
| 	return res; | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { URL } from 'node:url'; | ||||
| import define from '../../../define.js'; | ||||
| import { inboxQueue } from '@/queue/queues.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { InboxQueue } from '@/core/queue/QueueModule.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -39,21 +40,28 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const jobs = await inboxQueue.getJobs(['delayed']); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject('queue:inbox') public inboxQueue: InboxQueue, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const jobs = await this.inboxQueue.getJobs(['delayed']); | ||||
|  | ||||
| 	const res = [] as [string, number][]; | ||||
| 			const res = [] as [string, number][]; | ||||
|  | ||||
| 	for (const job of jobs) { | ||||
| 		const host = new URL(job.data.signature.keyId).host; | ||||
| 		if (res.find(x => x[0] === host)) { | ||||
| 			res.find(x => x[0] === host)![1]++; | ||||
| 		} else { | ||||
| 			res.push([host, 1]); | ||||
| 		} | ||||
| 			for (const job of jobs) { | ||||
| 				const host = new URL(job.data.signature.keyId).host; | ||||
| 				if (res.find(x => x[0] === host)) { | ||||
| 					res.find(x => x[0] === host)![1]++; | ||||
| 				} else { | ||||
| 					res.push([host, 1]); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			res.sort((a, b) => b[1] - a[1]); | ||||
|  | ||||
| 			return res; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	res.sort((a, b) => b[1] - a[1]); | ||||
|  | ||||
| 	return res; | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/queue/QueueModule.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -38,16 +39,29 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const deliverJobCounts = await deliverQueue.getJobCounts(); | ||||
| 	const inboxJobCounts = await inboxQueue.getJobCounts(); | ||||
| 	const dbJobCounts = await dbQueue.getJobCounts(); | ||||
| 	const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject('queue:system') public systemQueue: SystemQueue, | ||||
| 		@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, | ||||
| 		@Inject('queue:deliver') public deliverQueue: DeliverQueue, | ||||
| 		@Inject('queue:inbox') public inboxQueue: InboxQueue, | ||||
| 		@Inject('queue:db') public dbQueue: DbQueue, | ||||
| 		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, | ||||
| 		@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const deliverJobCounts = await this.deliverQueue.getJobCounts(); | ||||
| 			const inboxJobCounts = await this.inboxQueue.getJobCounts(); | ||||
| 			const dbJobCounts = await this.dbQueue.getJobCounts(); | ||||
| 			const objectStorageJobCounts = await this.objectStorageQueue.getJobCounts(); | ||||
|  | ||||
| 	return { | ||||
| 		deliver: deliverJobCounts, | ||||
| 		inbox: inboxJobCounts, | ||||
| 		db: dbJobCounts, | ||||
| 		objectStorage: objectStorageJobCounts, | ||||
| 	}; | ||||
| }); | ||||
| 			return { | ||||
| 				deliver: deliverJobCounts, | ||||
| 				inbox: inboxJobCounts, | ||||
| 				db: dbJobCounts, | ||||
| 				objectStorage: objectStorageJobCounts, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { URL } from 'node:url'; | ||||
| import define from '../../../define.js'; | ||||
| import { addRelay } from '@/services/relay.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { RelayService } from '@/core/RelayService.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
|  | ||||
| export const meta = { | ||||
| @@ -54,12 +55,19 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	try { | ||||
| 		if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; | ||||
| 	} catch { | ||||
| 		throw new ApiError(meta.errors.invalidUrl); | ||||
| 	} | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private relayService: RelayService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			try { | ||||
| 				if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; | ||||
| 			} catch { | ||||
| 				throw new ApiError(meta.errors.invalidUrl); | ||||
| 			} | ||||
|  | ||||
| 	return await addRelay(ps.inbox); | ||||
| }); | ||||
| 			return await this.relayService.addRelay(ps.inbox); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { listRelay } from '@/services/relay.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { RelayService } from '@/core/RelayService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -46,6 +47,13 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	return await listRelay(); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private relayService: RelayService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			return await this.relayService.listRelay(); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import define from '../../../define.js'; | ||||
| import { removeRelay } from '@/services/relay.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { RelayService } from '@/core/RelayService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -17,6 +18,13 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	return await removeRelay(ps.inbox); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private relayService: RelayService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			return await this.relayService.removeRelay(ps.inbox); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import define from '../../define.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import bcrypt from 'bcryptjs'; | ||||
| import rndstr from 'rndstr'; | ||||
| import { Users, UserProfiles } from '@/models/index.js'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository, UserProfilesRepository } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -32,29 +34,40 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	const user = await Users.findOneBy({ id: ps.userId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (user == null) { | ||||
| 		throw new Error('user not found'); | ||||
| 		@Inject(DI.userProfilesRepository) | ||||
| 		private userProfilesRepository: UserProfilesRepository, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps) => { | ||||
| 			const user = await this.usersRepository.findOneBy({ id: ps.userId }); | ||||
|  | ||||
| 			if (user == null) { | ||||
| 				throw new Error('user not found'); | ||||
| 			} | ||||
|  | ||||
| 			if (user.isAdmin) { | ||||
| 				throw new Error('cannot reset password of admin'); | ||||
| 			} | ||||
|  | ||||
| 			const passwd = rndstr('a-zA-Z0-9', 8); | ||||
|  | ||||
| 			// Generate hash of password | ||||
| 			const hash = bcrypt.hashSync(passwd); | ||||
|  | ||||
| 			await this.userProfilesRepository.update({ | ||||
| 				userId: user.id, | ||||
| 			}, { | ||||
| 				password: hash, | ||||
| 			}); | ||||
|  | ||||
| 			return { | ||||
| 				password: passwd, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (user.isAdmin) { | ||||
| 		throw new Error('cannot reset password of admin'); | ||||
| 	} | ||||
|  | ||||
| 	const passwd = rndstr('a-zA-Z0-9', 8); | ||||
|  | ||||
| 	// Generate hash of password | ||||
| 	const hash = bcrypt.hashSync(passwd); | ||||
|  | ||||
| 	await UserProfiles.update({ | ||||
| 		userId: user.id, | ||||
| 	}, { | ||||
| 		password: hash, | ||||
| 	}); | ||||
|  | ||||
| 	return { | ||||
| 		password: passwd, | ||||
| 	}; | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import define from '../../define.js'; | ||||
| import { AbuseUserReports, Users } from '@/models/index.js'; | ||||
| import { getInstanceActor } from '@/services/instance-actor.js'; | ||||
| import { deliver } from '@/queue/index.js'; | ||||
| import { renderActivity } from '@/remote/activitypub/renderer/index.js'; | ||||
| import { renderFlag } from '@/remote/activitypub/renderer/flag.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { UsersRepository, AbuseUserReportsRepository } from '@/models/index.js'; | ||||
| import { InstanceActorService } from '@/core/InstanceActorService.js'; | ||||
| import { QueueService } from '@/core/QueueService.js'; | ||||
| import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -21,24 +22,41 @@ export const paramDef = { | ||||
| 	required: ['reportId'], | ||||
| } as const; | ||||
|  | ||||
| // TODO: ロジックをサービスに切り出す | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, me) => { | ||||
| 	const report = await AbuseUserReports.findOneByOrFail({ id: ps.reportId }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
|  | ||||
| 	if (report == null) { | ||||
| 		throw new Error('report not found'); | ||||
| 		@Inject(DI.abuseUserReportsRepository) | ||||
| 		private abuseUserReportsRepository: AbuseUserReportsRepository, | ||||
|  | ||||
| 		private queueService: QueueService, | ||||
| 		private instanceActorService: InstanceActorService, | ||||
| 		private apRendererService: ApRendererService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); | ||||
|  | ||||
| 			if (report == null) { | ||||
| 				throw new Error('report not found'); | ||||
| 			} | ||||
|  | ||||
| 			if (ps.forward && report.targetUserHost != null) { | ||||
| 				const actor = await this.instanceActorService.getInstanceActor(); | ||||
| 				const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); | ||||
|  | ||||
| 				this.queueService.deliver(actor, this.apRendererService.renderActivity(this.apRendererService.renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); | ||||
| 			} | ||||
|  | ||||
| 			await this.abuseUserReportsRepository.update(report.id, { | ||||
| 				resolved: true, | ||||
| 				assigneeId: me.id, | ||||
| 				forwarded: ps.forward && report.targetUserHost != null, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	if (ps.forward && report.targetUserHost != null) { | ||||
| 		const actor = await getInstanceActor(); | ||||
| 		const targetUser = await Users.findOneByOrFail({ id: report.targetUserId }); | ||||
|  | ||||
| 		deliver(actor, renderActivity(renderFlag(actor, [targetUser.uri!], report.comment)), targetUser.inbox); | ||||
| 	} | ||||
|  | ||||
| 	await AbuseUserReports.update(report.id, { | ||||
| 		resolved: true, | ||||
| 		assigneeId: me.id, | ||||
| 		forwarded: ps.forward && report.targetUserHost != null, | ||||
| 	}); | ||||
| }); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import define from '../../define.js'; | ||||
| import { sendEmail } from '@/services/send-email.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { EmailService } from '@/core/EmailService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -19,6 +20,13 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps) => { | ||||
| 	await sendEmail(ps.to, ps.subject, ps.text, ps.text); | ||||
| }); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		private emailService: EmailService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			await this.emailService.sendEmail(ps.to, ps.subject, ps.text, ps.text); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import * as os from 'node:os'; | ||||
| import si from 'systeminformation'; | ||||
| import define from '../../define.js'; | ||||
| import { redisClient } from '../../../../db/redis.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { DataSource } from 'typeorm'; | ||||
| import Redis from 'ioredis'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	requireCredential: true, | ||||
| @@ -94,34 +96,46 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async () => { | ||||
| 	const memStats = await si.mem(); | ||||
| 	const fsStats = await si.fsSize(); | ||||
| 	const netInterface = await si.networkInterfaceDefault(); | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.db) | ||||
| 		private db: DataSource, | ||||
|  | ||||
| 	const redisServerInfo = await redisClient.info('Server'); | ||||
| 	const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); | ||||
| 	const redis_version = m?.[1]; | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
|  | ||||
| 	return { | ||||
| 		machine: os.hostname(), | ||||
| 		os: os.platform(), | ||||
| 		node: process.version, | ||||
| 		psql: await db.query('SHOW server_version').then(x => x[0].server_version), | ||||
| 		redis: redis_version, | ||||
| 		cpu: { | ||||
| 			model: os.cpus()[0].model, | ||||
| 			cores: os.cpus().length, | ||||
| 		}, | ||||
| 		mem: { | ||||
| 			total: memStats.total, | ||||
| 		}, | ||||
| 		fs: { | ||||
| 			total: fsStats[0].size, | ||||
| 			used: fsStats[0].used, | ||||
| 		}, | ||||
| 		net: { | ||||
| 			interface: netInterface, | ||||
| 		}, | ||||
| 	}; | ||||
| }); | ||||
| 	) { | ||||
| 		super(meta, paramDef, async () => { | ||||
| 			const memStats = await si.mem(); | ||||
| 			const fsStats = await si.fsSize(); | ||||
| 			const netInterface = await si.networkInterfaceDefault(); | ||||
|  | ||||
| 			const redisServerInfo = await this.redisClient.info('Server'); | ||||
| 			const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); | ||||
| 			const redis_version = m?.[1]; | ||||
|  | ||||
| 			return { | ||||
| 				machine: os.hostname(), | ||||
| 				os: os.platform(), | ||||
| 				node: process.version, | ||||
| 				psql: await this.db.query('SHOW server_version').then(x => x[0].server_version), | ||||
| 				redis: redis_version, | ||||
| 				cpu: { | ||||
| 					model: os.cpus()[0].model, | ||||
| 					cores: os.cpus().length, | ||||
| 				}, | ||||
| 				mem: { | ||||
| 					total: memStats.total, | ||||
| 				}, | ||||
| 				fs: { | ||||
| 					total: fsStats[0].size, | ||||
| 					used: fsStats[0].used, | ||||
| 				}, | ||||
| 				net: { | ||||
| 					interface: netInterface, | ||||
| 				}, | ||||
| 			}; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo