Ctrl K
ComunidadGuías…

server/models/FileOperation.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type {
  FindOptions,
  Identifier,
  InferAttributes,
  InferCreationAttributes,
  NonNullFindOptions,
  WhereOptions,
} from "sequelize";
import { truncate } from "es-toolkit/compat";
import { EmptyResultError, Op } from "sequelize";
import {
  ForeignKey,
  DefaultScope,
  Scopes,
  Column,
  BeforeDestroy,
  BelongsTo,
  Table,
  DataType,
} from "sequelize-typescript";
import { v4 as uuidv4 } from "uuid";
import type { CollectionPermission } from "@shared/types";
import {
  FileOperationFormat,
  FileOperationState,
  FileOperationType,
} from "@shared/types";
import FileStorage from "@server/storage/files";
import type { APIContext } from "@server/types";
import { ValidateKey } from "@server/validation";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import { Buckets } from "./helpers/AttachmentHelper";

export type FileOperationOptions = {
  includeAttachments?: boolean;
  includePrivate?: boolean;
  permission?: CollectionPermission | null;
  /** The owner of the personal collection that was exported, if any. */
  personalOwnerId?: string;
};

type AdditionalFindOptions = {
  userId?: string;
  rejectOnEmpty?: boolean | Error;
};

interface CreateCollectionExportOptions {
  collection?: Collection;
  team: Team;
  format?: FileOperationFormat;
  includeAttachments?: boolean;
  includePrivate?: boolean;
}

interface CreateDocumentExportOptions {
  document: Document;
  format: FileOperationFormat;
}

type CreateExportOptions =
  | CreateCollectionExportOptions
  | CreateDocumentExportOptions;

@DefaultScope(() => ({
  include: [
    {
      model: User,
      as: "user",
      paranoid: false,
    },
    {
      model: Collection,
      as: "collection",
      required: false,
      paranoid: false,
    },
    {
      model: Document.unscoped(),
      as: "document",
      attributes: ["id", "title", "collectionId"],
      required: false,
      paranoid: false,
      include: [
        {
          model: Collection.unscoped(),
          as: "collection",
          attributes: ["id", "ownerId"],
          required: false,
          paranoid: false,
        },
      ],
    },
  ],
}))
@Scopes(() => ({
  withSource: (userId: string) => {
    if (!userId) {
      return {};
    }

    return {
      include: [
        {
          model: User,
          as: "user",
          paranoid: false,
        },
        {
          model: Collection.scope([
            "defaultScope",
            {
              method: ["withMembership", userId],
            },
          ]),
          as: "collection",
          required: false,
          paranoid: false,
        },
        {
          model: Document.scope([
            "defaultScope",
            {
              method: ["withMembership", userId, false],
            },
          ]),
          as: "document",
          // Content columns are not needed to authorize or present an export.
          attributes: { exclude: ["text", "content", "state"] },
          required: false,
          paranoid: false,
        },
      ],
    };
  },
}))
@Table({ tableName: "file_operations", modelName: "file_operation" })
class FileOperation extends ParanoidModel<
  InferAttributes<FileOperation>,
  Partial<InferCreationAttributes<FileOperation>>
> {
  static eventNamespace = "fileOperations";

  /** The number of days a completed file operation remains downloadable. */
  static expiryDays = 15;

  /**
   * Create a file operation for a team, collection, or document export.
   *
   * @param ctx the request context with the acting user and transaction.
   * @param options the source and settings for the export.
   * @returns the new file operation with its source associations set.
   */
  static async createExport(
    ctx: APIContext,
    options: CreateExportOptions
  ): Promise<FileOperation> {
    const { user } = ctx.state.auth;
    const format = options.format ?? FileOperationFormat.MarkdownZip;

    // The owner is stored on the export, as a document can move out of the
    // personal collection after it was exported.
    const sourceCollectionId =
      "document" in options
        ? options.document.collectionId
        : options.collection?.id;
    const sourceCollection = sourceCollectionId
      ? await Collection.unscoped().findOne({
          attributes: ["ownerId"],
          where: { id: sourceCollectionId },
          transaction: ctx.state.transaction,
        })
      : null;
    const personalOwnerId = sourceCollection?.ownerId ?? undefined;

    const source =
      "document" in options
        ? {
            name: options.document.displayTitle,
            documentId: options.document.id,
            teamId: options.document.teamId,
            options: personalOwnerId ? { personalOwnerId } : null,
          }
        : {
            name: options.collection?.name || options.team.name,
            collectionId: options.collection?.id,
            teamId: user.teamId,
            options: {
              includeAttachments: options.includeAttachments ?? true,
              includePrivate: options.includePrivate ?? true,
              ...(personalOwnerId ? { personalOwnerId } : {}),
            },
          };
    const { name, ...attributes } = source;
    const key = this.getExportKey({
      name,
      teamId: source.teamId,
      format,
    });
    const fileOperation = await this.createWithCtx(ctx, {
      type: FileOperationType.Export,
      state: FileOperationState.Creating,
      format,
      key,
      url: null,
      size: 0,
      userId: user.id,
      ...attributes,
    });

    fileOperation.user = user;

    if ("document" in options) {
      fileOperation.document = options.document;
    } else if (options.collection) {
      fileOperation.collection = options.collection;
    }

    return fileOperation;
  }

  /**
   * Overrides the standard findByPk behavior to allow loading the exported
   * collection or document with memberships for a user passed in by `userId`.
   *
   * @param id uuid
   * @param options FindOptions
   * @returns a promise resolving to a file operation instance or null.
   */
  static async findByPk(
    id: Identifier,
    options?: NonNullFindOptions<FileOperation> & AdditionalFindOptions
  ): Promise<FileOperation>;
  static async findByPk(
    id: Identifier,
    options?: FindOptions<FileOperation> & AdditionalFindOptions
  ): Promise<FileOperation | null>;
  static async findByPk(
    id: Identifier,
    options: FindOptions<FileOperation> & AdditionalFindOptions = {}
  ): Promise<FileOperation | null> {
    if (typeof id !== "string") {
      if (options.rejectOnEmpty) {
        throw options.rejectOnEmpty instanceof Error
          ? options.rejectOnEmpty
          : new EmptyResultError(
              `File operation doesn't exist with id: ${String(id)}`
            );
      }
      return null;
    }

    const { userId, ...rest } = options;

    // Preserve the caller's scope when no userId is passed
    const scope = userId
      ? this.scope({ method: ["withSource", userId] })
      : this;

    const fileOperation = await scope.findOne({
      ...rest,
      where: { id },
      rejectOnEmpty: false,
    });

    if (!fileOperation && rest.rejectOnEmpty) {
      throw rest.rejectOnEmpty instanceof Error
        ? rest.rejectOnEmpty
        : new EmptyResultError(`File operation doesn't exist with id: ${id}`);
    }

    return fileOperation;
  }

  @Column(DataType.ENUM(...Object.values(FileOperationType)))
  type: FileOperationType;

  @Column(DataType.STRING)
  format: FileOperationFormat;

  @Column(DataType.ENUM(...Object.values(FileOperationState)))
  state: FileOperationState;

  @Column(DataType.STRING)
  key: string;

  @Column(DataType.STRING)
  url?: string | null;

  @Column(DataType.STRING)
  error: string | null;

  @Column(DataType.BIGINT)
  size: number;

  /**
   * Additional configuration options for the file operation.
   */
  @Column(DataType.JSON)
  options: FileOperationOptions | null;

  /**
   * Mark the current file operation as expired and remove the file from storage.
   */
  expire = async () => {
    this.state = FileOperationState.Expired;
    try {
      await FileStorage.deleteFile(this.key);
    } catch (err) {
      if (err instanceof Error && "retryable" in err && err.retryable) {
        throw err;
      }
    }
    return this.save();
  };

  /**
   * The file operation contents as a readable stream.
   */
  get stream() {
    return FileStorage.getFileStream(this.key);
  }

  /**
   * The file operation contents as a handle which contains a path and cleanup function.
   */
  get handle() {
    return FileStorage.getFileHandle(this.key);
  }

  // hooks

  @BeforeDestroy
  static async deleteFileFromS3(model: FileOperation) {
    await FileStorage.deleteFile(model.key);
  }

  // associations

  @BelongsTo(() => User, "userId")
  user: User;

  @ForeignKey(() => User)
  @Column(DataType.UUID)
  userId: string;

  @BelongsTo(() => Team, "teamId")
  team: Team;

  @ForeignKey(() => Team)
  @Column(DataType.UUID)
  teamId: string;

  @BelongsTo(() => Collection, "collectionId")
  collection: Collection | null;

  @ForeignKey(() => Collection)
  @Column(DataType.UUID)
  collectionId?: string | null;

  @BelongsTo(() => Document, "documentId")
  document: Document | null;

  @ForeignKey(() => Document)
  @Column(DataType.UUID)
  documentId?: string | null;

  /**
   * Count the number of export file operations for a given team after a point
   * in time.
   *
   * @param teamId The team id
   * @param startDate The start time
   * @returns The number of file operations
   */
  static async countExportsAfterDateTime(
    teamId: string,
    startDate: Date,
    where: WhereOptions<FileOperation> = {}
  ): Promise<number> {
    return this.count({
      where: Object.assign(
        {
          teamId,
          createdAt: {
            [Op.gt]: startDate,
          },
        },
        where
      ),
    });
  }

  /**
   * Get the storage key to write an export archive to.
   *
   * @param name the user-provided name of the exported source.
   * @param teamId the team id.
   * @param format the format of the export.
   * @returns a storage key.
   */
  static getExportKey({
    name,
    teamId,
    format,
  }: {
    name: string;
    teamId: string;
    format: FileOperationFormat;
  }) {
    const prefix = truncate(name, { length: 100, omission: "" });
    const fileName = ValidateKey.sanitizeSegment(
      `${prefix}-export.${format.replace(/outline-/, "")}.zip`
    );

    return `${Buckets.uploads}/${teamId}/${uuidv4()}/${fileName}`;
  }
}

export default FileOperation;