feat: Log user ips (#8872)
* wip * store ip and headers * Update admin-file.vue * require admin for view ip/headers * IP (recent) 消した * admin必須 * opt in * clean ips periodically * respect logging setting in drive/files/create
This commit is contained in:
		| @@ -1,10 +1,19 @@ | ||||
| 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 | ||||
| @@ -44,6 +53,31 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((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.insert({ | ||||
| 							createdAt: new Date(), | ||||
| 							userId: user.id, | ||||
| 							ip: ip, | ||||
| 						}); | ||||
| 					} catch { | ||||
| 					} | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 	}).catch(e => { | ||||
| 		if (e instanceof AuthenticationError) { | ||||
| 			reply(403, new ApiError({ | ||||
|   | ||||
| @@ -116,7 +116,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi | ||||
|  | ||||
| 	// API invoking | ||||
| 	const before = performance.now(); | ||||
| 	return await ep.exec(data, user, token, ctx?.file).catch((e: Error) => { | ||||
| 	return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => { | ||||
| 		if (e instanceof ApiError) { | ||||
| 			throw e; | ||||
| 		} else { | ||||
|   | ||||
| @@ -1,16 +1,16 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import Ajv from 'ajv'; | ||||
| import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; | ||||
| import { IEndpointMeta } from './endpoints.js'; | ||||
| import { ApiError } from './error.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) => | ||||
| 	(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({ | ||||
| @@ -20,23 +20,27 @@ const ajv = new Ajv({ | ||||
| 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) => Promise<any> { | ||||
| 		: (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) => { | ||||
| 		function cleanup() { | ||||
| 			fs.unlink(file.path, () => {}); | ||||
| 		} | ||||
| 	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 && file == null) return Promise.reject(new ApiError({ | ||||
| 			message: 'File required.', | ||||
| 			code: 'FILE_REQUIRED', | ||||
| 			id: '4267801e-70d1-416a-b011-4ee502885d8b', | ||||
| 		})); | ||||
| 		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(); | ||||
| 			if (file) cleanup!(); | ||||
|  | ||||
| 			const errors = validate.errors!; | ||||
| 			const err = new ApiError({ | ||||
| @@ -50,6 +54,6 @@ export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, pa | ||||
| 			return Promise.reject(err); | ||||
| 		} | ||||
|  | ||||
| 		return cb(params as SchemaType<Ps>, user, token, file, cleanup); | ||||
| 		return cb(params as SchemaType<Ps>, user, token, file, cleanup, ip, headers); | ||||
| 	}; | ||||
| } | ||||
|   | ||||
| @@ -35,6 +35,7 @@ import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/fed | ||||
| import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; | ||||
| import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; | ||||
| import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; | ||||
| import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; | ||||
| import * as ep___admin_invite from './endpoints/admin/invite.js'; | ||||
| import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; | ||||
| import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; | ||||
| @@ -348,6 +349,7 @@ const eps = [ | ||||
| 	['admin/federation/update-instance', ep___admin_federation_updateInstance], | ||||
| 	['admin/get-index-stats', ep___admin_getIndexStats], | ||||
| 	['admin/get-table-stats', ep___admin_getTableStats], | ||||
| 	['admin/get-user-ips', ep___admin_getUserIps], | ||||
| 	['admin/invite', ep___admin_invite], | ||||
| 	['admin/moderators/add', ep___admin_moderators_add], | ||||
| 	['admin/moderators/remove', ep___admin_moderators_remove], | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -184,5 +184,10 @@ export default define(meta, paramDef, async (ps, me) => { | ||||
| 		throw new ApiError(meta.errors.noSuchFile); | ||||
| 	} | ||||
|  | ||||
| 	if (!me.isAdmin) { | ||||
| 		delete file.requestIp; | ||||
| 		delete file.requestHeaders; | ||||
| 	} | ||||
|  | ||||
| 	return file; | ||||
| }); | ||||
|   | ||||
| @@ -0,0 +1,31 @@ | ||||
| import { UserIps } from '@/models/index.js'; | ||||
| import define from '../../define.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
|  | ||||
| 	requireCredential: true, | ||||
| 	requireAdmin: true, | ||||
| } as const; | ||||
|  | ||||
| export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		userId: { type: 'string', format: 'misskey:id' }, | ||||
| 	}, | ||||
| 	required: ['userId'], | ||||
| } 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, | ||||
| 	}); | ||||
|  | ||||
| 	return ips.map(x => ({ | ||||
| 		ip: x.ip, | ||||
| 		createdAt: x.createdAt.toISOString(), | ||||
| 	})); | ||||
| }); | ||||
| @@ -1,7 +1,7 @@ | ||||
| import config from '@/config/index.js'; | ||||
| import define from '../../define.js'; | ||||
| import { fetchMeta } from '@/misc/fetch-meta.js'; | ||||
| import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; | ||||
| import define from '../../define.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['meta'], | ||||
| @@ -304,6 +304,10 @@ export const meta = { | ||||
| 				type: 'boolean', | ||||
| 				optional: true, nullable: false, | ||||
| 			}, | ||||
| 			enableIpLogging: { | ||||
| 				type: 'boolean', | ||||
| 				optional: true, nullable: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -360,7 +364,6 @@ export default define(meta, paramDef, async (ps, me) => { | ||||
| 		pinnedPages: instance.pinnedPages, | ||||
| 		pinnedClipId: instance.pinnedClipId, | ||||
| 		cacheRemoteFiles: instance.cacheRemoteFiles, | ||||
|  | ||||
| 		useStarForReactionFallback: instance.useStarForReactionFallback, | ||||
| 		pinnedUsers: instance.pinnedUsers, | ||||
| 		hiddenTags: instance.hiddenTags, | ||||
| @@ -397,5 +400,6 @@ export default define(meta, paramDef, async (ps, me) => { | ||||
| 		objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, | ||||
| 		deeplAuthKey: instance.deeplAuthKey, | ||||
| 		deeplIsPro: instance.deeplIsPro, | ||||
| 		enableIpLogging: instance.enableIpLogging, | ||||
| 	}; | ||||
| }); | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import define from '../../define.js'; | ||||
| import { Meta } from '@/models/entities/meta.js'; | ||||
| import { insertModerationLog } from '@/services/insert-moderation-log.js'; | ||||
| import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; | ||||
| import { db } from '@/db/postgre.js'; | ||||
| import define from '../../define.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -96,6 +96,7 @@ export const paramDef = { | ||||
| 		objectStorageUseProxy: { type: 'boolean' }, | ||||
| 		objectStorageSetPublicRead: { type: 'boolean' }, | ||||
| 		objectStorageS3ForcePathStyle: { type: 'boolean' }, | ||||
| 		enableIpLogging: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -396,6 +397,10 @@ export default define(meta, paramDef, async (ps, me) => { | ||||
| 		set.deeplIsPro = ps.deeplIsPro; | ||||
| 	} | ||||
|  | ||||
| 	if (ps.enableIpLogging !== undefined) { | ||||
| 		set.enableIpLogging = ps.enableIpLogging; | ||||
| 	} | ||||
|  | ||||
| 	await db.transaction(async transactionalEntityManager => { | ||||
| 		const metas = await transactionalEntityManager.find(Meta, { | ||||
| 			order: { | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| import ms from 'ms'; | ||||
| import { addFile } from '@/services/drive/add-file.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||
| import { fetchMeta } from '@/misc/fetch-meta.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { apiLogger } from '../../../logger.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['drive'], | ||||
| @@ -50,7 +51,7 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user, _, file, cleanup) => { | ||||
| export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, headers) => { | ||||
| 	// Get 'name' parameter | ||||
| 	let name = ps.name || file.originalname; | ||||
| 	if (name !== undefined && name !== null) { | ||||
| @@ -66,9 +67,21 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup) => { | ||||
| 		name = null; | ||||
| 	} | ||||
|  | ||||
| 	const meta = await fetchMeta(); | ||||
|  | ||||
| 	try { | ||||
| 		// Create file | ||||
| 		const driveFile = await addFile({ user, path: file.path, name, comment: ps.comment, folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive }); | ||||
| 		const driveFile = await addFile({ | ||||
| 			user, | ||||
| 			path: file.path, | ||||
| 			name, | ||||
| 			comment: ps.comment, | ||||
| 			folderId: ps.folderId, | ||||
| 			force: ps.force, | ||||
| 			sensitive: ps.isSensitive, | ||||
| 			requestIp: meta.enableIpLogging ? ip : null, | ||||
| 			requestHeaders: meta.enableIpLogging ? headers : null, | ||||
| 		}); | ||||
| 		return await DriveFiles.pack(driveFile, { self: true }); | ||||
| 	} catch (e) { | ||||
| 		if (e instanceof Error || typeof e === 'string') { | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import ms from 'ms'; | ||||
| import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { DriveFiles } from '@/models/index.js'; | ||||
| import { publishMainStream } from '@/services/stream.js'; | ||||
| import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||
| import define from '../../../define.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['drive'], | ||||
| @@ -34,8 +34,8 @@ export const paramDef = { | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| export default define(meta, paramDef, async (ps, user) => { | ||||
| 	uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment }).then(file => { | ||||
| export default define(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { | ||||
| 	uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { | ||||
| 		DriveFiles.pack(file, { self: true }).then(packedFile => { | ||||
| 			publishMainStream(user.id, 'urlUploadFinished', { | ||||
| 				marker: ps.marker, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 syuilo
					syuilo