mirror of
https://github.com/sim1222/misskey.git
synced 2025-08-03 23:16:28 +09:00
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