Bläddra i källkod

add api /audit-log-bulk-export

Naoki427 6 månader sedan
förälder
incheckning
ad656b507d

+ 50 - 0
apps/app/src/features/audit-log-bulk-export/interfaces/audit-log-bulk-export.ts

@@ -0,0 +1,50 @@
+import type {
+  HasObjectId,
+  IUser,
+  Ref,
+} from '@growi/core';
+
+export const AuditLogBulkExportFormat = {
+  json: 'json',
+} as const;
+
+export type AuditLogBulkExportFormat =
+  (typeof AuditLogBulkExportFormat)[keyof typeof AuditLogBulkExportFormat];
+
+export const AuditLogBulkExportJobInProgressJobStatus = {
+  exporting: 'exporting',
+  uploading: 'uploading',
+} as const;
+
+export const AuditLogBulkExportJobStatus = {
+  ...AuditLogBulkExportJobInProgressJobStatus,
+  completed: 'completed',
+  failed: 'failed',
+} as const;
+
+export type AuditLogBulkExportJobStatus =
+  (typeof AuditLogBulkExportJobStatus)[keyof typeof AuditLogBulkExportJobStatus];
+
+export interface IAuditLogBulkExportFilters {
+  users? : Array<Ref<IUser>>;
+  actions? : string[];
+  dateFrom? : Date;
+  dateTo? : Date;
+}
+
+export interface IAuditLogBulkExportJob {
+  user: Ref<IUser>; // user who initiated the audit log export job
+  filters: IAuditLogBulkExportFilters; // filter conditions used for export (e.g. user, action, date range)
+  filterHash: string; // hash string generated from the filter set to detect duplicate export jobs
+  format: AuditLogBulkExportFormat; // export file format (currently only 'json' is supported)
+  status: AuditLogBulkExportJobStatus; // current status of the export job
+  lastExportedId?: string; // ID of the last exported audit log record
+  completedAt?: Date | null; // the date when the job was completed
+  restartFlag: boolean; // flag indicating whether this job is a restarted one
+  totalExportedCount?: number; // total number of exported audit log entries
+  createdAt?: Date;
+  updatedAt?: Date;
+}
+
+export interface IAuditLogBulkExportJobHasId
+  extends IAuditLogBulkExportJob, HasObjectId {}

+ 44 - 0
apps/app/src/features/audit-log-bulk-export/server/models/audit-log-bulk-export-job.ts

@@ -0,0 +1,44 @@
+import { type Model, Schema } from 'mongoose';
+import type { HydratedDocument } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { IAuditLogBulkExportJob } from '../../interfaces/audit-log-bulk-export';
+import {
+  AuditLogBulkExportFormat,
+  AuditLogBulkExportJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+
+export type AuditLogBulkExportJobDocument = HydratedDocument<IAuditLogBulkExportJob>;
+
+export type AuditLogBulkExportJobModel = Model<AuditLogBulkExportJobDocument>;
+
+const auditLogBulkExportJobSchema = new Schema<IAuditLogBulkExportJob>(
+  {
+    user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+    filters: { type: Schema.Types.Mixed, required: true },
+    filterHash: { type: String, required: true, index: true },
+    format: {
+      type: String,
+      enum: Object.values(AuditLogBulkExportFormat),
+      required: true,
+      default: AuditLogBulkExportFormat.json,
+    },
+    status: {
+      type: String,
+      enum: Object.values(AuditLogBulkExportJobStatus),
+      required: true,
+      default: AuditLogBulkExportJobStatus.exporting,
+    },
+    lastExportedId: { type: String },
+    completedAt: { type: Date },
+    restartFlag: { type: Boolean, required: true, default: false },
+    totalExportedCount: { type: Number, default: 0 },
+  },
+  { timestamps: true },
+);
+
+export default getOrCreateModel<AuditLogBulkExportJobDocument, AuditLogBulkExportJobModel>(
+  'AuditLogBulkExportJob',
+  auditLogBulkExportJobSchema,
+);

+ 104 - 0
apps/app/src/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export.ts

@@ -0,0 +1,104 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request } from 'express';
+import { Router } from 'express';
+import { body, validationResult } from 'express-validator';
+
+import { AuditLogBulkExportFormat } from '~/features/audit-log-bulk-export/interfaces/audit-log-bulk-export';
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import {
+  DuplicateAuditLogBulkExportJobError,
+  auditLogBulkExportService,
+} from '../../service/audit-log-bulk-export';
+
+const logger = loggerFactory('growi:routes:apiv3:audit-log-bulk-export');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any;
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const accessTokenParser = crowi.accessTokenParser;
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
+
+  const validators = {
+    auditLogBulkExport: [
+      body('filters').exists({ checkFalsy: true }).isObject(),
+      body('filters.users').optional({ nullable: true }).isArray(),
+      body('filters.users.*').optional({ nullable: true }).isString(),
+      body('filters.actions').optional({ nullable: true }).isArray(),
+      body('filters.actions.*').optional({ nullable: true }).isString(),
+      body('filters.dateFrom').optional({ nullable: true }).isISO8601().toDate(),
+      body('filters.dateTo').optional({ nullable: true }).isISO8601().toDate(),
+      body('format')
+        .optional({ nullable: true })
+        .isString()
+        .isIn(Object.values(AuditLogBulkExportFormat)),
+      body('restartJob').isBoolean().optional(),
+    ],
+  };
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.AUDIT_LOG]),
+    loginRequiredStrictly,
+    validators.auditLogBulkExport,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
+      }
+
+      const { filters, format = AuditLogBulkExportFormat.json, restartJob } = req.body as {
+        filters: {
+          users?: string[];
+          actions?: string[];
+          dateFrom?: Date;
+          dateTo?: Date;
+        };
+        format?: (typeof AuditLogBulkExportFormat)[keyof typeof AuditLogBulkExportFormat];
+        restartJob?: boolean;
+      };
+
+      try {
+        await auditLogBulkExportService.createOrResetExportJob(
+          filters,
+          format,
+          req.user,
+          restartJob,
+        );
+        return res.apiv3({}, 204);
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (err instanceof DuplicateAuditLogBulkExportJobError) {
+          return res.apiv3Err(
+            new ErrorV3(
+              'Duplicate audit-log bulk export job is in progress',
+              'audit_log_bulk_export.duplicate_export_job_error',
+              undefined,
+              {
+                duplicateJob: {
+                  createdAt: err.duplicateJob.createdAt,
+                },
+              },
+            ),
+            409,
+          );
+        }
+
+        return res.apiv3Err(
+          new ErrorV3('Failed to start audit-log bulk export', 'audit_log_bulk_export.failed_to_export'),
+        );
+      }
+    },
+  );
+  return router;
+};

+ 126 - 0
apps/app/src/features/audit-log-bulk-export/server/service/audit-log-bulk-export.ts

@@ -0,0 +1,126 @@
+import { createHash } from 'crypto';
+
+import type { IUserHasId } from '@growi/core';
+
+import type {
+  IAuditLogBulkExportFilters,
+  AuditLogBulkExportFormat,
+} from '../../interfaces/audit-log-bulk-export';
+import {
+  AuditLogBulkExportJobStatus,
+  AuditLogBulkExportJobInProgressJobStatus,
+} from '../../interfaces/audit-log-bulk-export';
+import type { AuditLogBulkExportJobDocument } from '../models/audit-log-bulk-export-job';
+import AuditLogBulkExportJob from '../models/audit-log-bulk-export-job';
+
+export interface IAuditLogBulkExportService {
+  createOrResetExportJob: (
+    filters: IAuditLogBulkExportFilters,
+    format: AuditLogBulkExportFormat,
+    currentUser: IUserHasId,
+    restartJob?: boolean,
+  ) => Promise<void>;
+  resetExportJob: (
+    job: AuditLogBulkExportJobDocument,
+  )=> Promise<void>;
+}
+
+/** ============================== utils ============================== */
+
+/**
+ * Normalizes filter values to ensure that logically equivalent filters,
+ * regardless of order or formatting differences, generate the same hash.
+ */
+function canonicalizeFilters(filters: IAuditLogBulkExportFilters) {
+  const normalized: Record<string, unknown> = {};
+
+  if (filters.users?.length) {
+    normalized.users = filters.users.map(String).sort();
+  }
+  if (filters.actions?.length) {
+    normalized.actions = [...filters.actions].sort();
+  }
+  if (filters.dateFrom) {
+    normalized.dateFrom = new Date(filters.dateFrom).toISOString();
+  }
+  if (filters.dateTo) {
+    normalized.dateTo = new Date(filters.dateTo).toISOString();
+  }
+  return normalized;
+}
+
+/**
+ * Generates a SHA-256 hash used to uniquely identify a set of filters.
+ * Requests with the same input produce the same hash value,
+ * preventing duplicate audit-log export jobs from being executed.
+ */
+function sha256(input: string): string {
+  return createHash('sha256').update(input).digest('hex');
+}
+
+/** ============================== error ============================== */
+
+export class DuplicateAuditLogBulkExportJobError extends Error {
+
+  duplicateJob: AuditLogBulkExportJobDocument;
+
+  constructor(duplicateJob:AuditLogBulkExportJobDocument) {
+    super('Duplicate audit-log bulk export job is in progress');
+    this.duplicateJob = duplicateJob;
+  }
+
+}
+
+/** ============================== service ============================== */
+
+class AuditLogBulkExportService implements IAuditLogBulkExportService {
+
+  /**
+   * Create a new audit-log bulk export job or reset the existing one
+   */
+  async createOrResetExportJob(
+      filters: IAuditLogBulkExportFilters,
+      format: AuditLogBulkExportFormat,
+      currentUser: IUserHasId,
+      restartJob?: boolean,
+  ) : Promise<void> {
+    const normalizedFilters = canonicalizeFilters(filters);
+    const filterHash = sha256(JSON.stringify(normalizedFilters));
+
+    const duplicateInProgress: AuditLogBulkExportJobDocument | null = await AuditLogBulkExportJob.findOne({
+      user: { $eq: currentUser },
+      filterHash,
+      $or: Object.values(AuditLogBulkExportJobInProgressJobStatus).map(status => ({ status })),
+    });
+
+    if (duplicateInProgress != null) {
+      if (restartJob) {
+        await this.resetExportJob(duplicateInProgress);
+        return;
+      }
+      throw new DuplicateAuditLogBulkExportJobError(duplicateInProgress);
+    }
+
+    await AuditLogBulkExportJob.create({
+      user: currentUser,
+      filters: normalizedFilters,
+      filterHash,
+      format,
+      status: AuditLogBulkExportJobStatus.exporting,
+      totalExportedCount: 0,
+    });
+  }
+
+  /**
+   * Reset audit-log export job in progress
+   */
+  async resetExportJob(
+      job: AuditLogBulkExportJobDocument,
+  ): Promise<void> {
+    job.restartFlag = true;
+    await job.save();
+  }
+
+}
+
+export const auditLogBulkExportService = new AuditLogBulkExportService(); // singleton

+ 1 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -125,6 +125,7 @@ module.exports = (crowi, app) => {
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
   router.use('/page-bulk-export', require('~/features/page-bulk-export/server/routes/apiv3/page-bulk-export')(crowi));
+  router.use('/audit-log-bulk-export', require('~/features/audit-log-bulk-export/server/routes/apiv3/audit-log-bulk-export')(crowi));
 
   router.use('/openai', openaiRouteFactory(crowi));