Ctrl K
ComunidadGuías…

server/models/Attachment.ts

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

Archivo original ↗Preguntar a KiBot
import { createReadStream } from "node:fs";
import { subMinutes } from "date-fns";
import path from "node:path";
import type { File } from "formidable";
import type {
  InferAttributes,
  InferCreationAttributes,
  FindOptions,
  Sequelize,
} from "sequelize";
import { QueryTypes } from "sequelize";
import {
  BeforeDestroy,
  BelongsTo,
  Column,
  Default,
  ForeignKey,
  IsIn,
  Table,
  DataType,
  IsNumeric,
  BeforeCreate,
  BeforeUpdate,
} from "sequelize-typescript";
import { errToString } from "@shared/utils/error";
import { ValidationError } from "@server/errors";
import FileStorage from "@server/storage/files";
import { ValidateKey } from "@server/validation";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset";
import Length from "./validators/Length";
import Logger from "@server/logging/Logger";
import { Buckets } from "./helpers/AttachmentHelper";

@Table({ tableName: "attachments", modelName: "attachment" })
class Attachment extends IdModel<
  InferAttributes<Attachment>,
  Partial<InferCreationAttributes<Attachment>>
> {
  @Length({
    max: 4096,
    msg: "key must be 4096 characters or less",
  })
  @Column(DataType.STRING)
  key: string;

  @Length({
    max: 255,
    msg: "contentType must be 255 characters or less",
  })
  @Column(DataType.STRING)
  contentType: string;

  @IsNumeric
  @Column(DataType.BIGINT)
  size: number;

  @Default("public-read")
  @IsIn([["private", "public-read"]])
  @Column(DataType.STRING)
  acl: string;

  @Column(DataType.DATE)
  @SkipChangeset
  lastAccessedAt: Date | null;

  @Column(DataType.DATE)
  expiresAt: Date | null;

  // getters

  /**
   * Get the original uploaded file name.
   */
  get name() {
    return path.parse(this.key).base;
  }

  /**
   * Whether the attachment is stored in a public bucket. This does not relate
   * to the ACL of the attachment itself. Previously "public" attachments were
   * stored in a separate bucket – now all attachments are stored in a private
   * bucket and ACL is checked per attachment.
   */
  get isStoredInPublicBucket() {
    const bucket = this.key.split("/")[0];
    return [Buckets.avatars, Buckets.public].includes(bucket as Buckets);
  }

  /**
   * Whether the attachment is private or not.
   */
  get isPrivate() {
    return this.acl === "private";
  }

  /**
   * Get the contents of this attachment as a readable stream.
   */
  get stream() {
    return FileStorage.getFileStream(this.key);
  }

  /**
   * Get the contents of this attachment as a buffer.
   */
  get buffer() {
    return FileStorage.getFileBuffer(this.key);
  }

  /**
   * Get a url that can be used to download the attachment if the user has a valid session.
   */
  get url() {
    return this.isPrivate ? this.redirectUrl : this.canonicalUrl;
  }

  /**
   * Get a url that can be used to download a private attachment if the user has a valid session.
   */
  get redirectUrl() {
    return Attachment.getRedirectUrl(this.id);
  }

  /**
   * Get a direct URL to the attachment in storage. Note that this will not work
   * for private attachments, a signed URL must be used.
   */
  get canonicalUrl() {
    return encodeURI(FileStorage.getUrlForKey(this.key));
  }

  /**
   * Get a signed URL with the default expiry to download the attachment from storage.
   */
  get signedUrl() {
    return FileStorage.getSignedUrl(this.key);
  }

  /**
   * Store the given file in storage at the location specified by the attachment key.
   * If the attachment already exists, an error will be thrown.
   *
   * @param file The file to store
   * @returns A promise resolving to the attachment
   */
  async writeFile(file: File) {
    return FileStorage.store({
      body: createReadStream(file.filepath),
      contentLength: file.size,
      contentType: this.contentType,
      key: this.key,
      acl: this.acl,
    });
  }

  /**
   * Record that the attachment was accessed. The write happens at most once
   * every five minutes.
   *
   * @returns A promise resolving to the attachment.
   */
  async updateAccessedAt() {
    const fiveMinutesAgo = subMinutes(new Date(), 5);

    if (this.lastAccessedAt && this.lastAccessedAt >= fiveMinutesAgo) {
      return this;
    }

    this.lastAccessedAt = new Date();
    return this.save({ silent: true });
  }

  // hooks

  @BeforeCreate
  static async sanitizeKey(model: Attachment) {
    model.key = ValidateKey.sanitize(model.key);
    return model;
  }

  @BeforeUpdate
  static async preventKeyChange(model: Attachment) {
    if (model.changed("key")) {
      throw ValidationError("Cannot change the key of an attachment");
    }
  }

  @BeforeDestroy
  static async deleteAttachmentFromS3(model: Attachment) {
    try {
      await FileStorage.deleteFile(model.key);
    } catch (err) {
      // do not block deletion of the database record if S3 deletion fails
      Logger.warn(
        `Failed to delete attachment file ${model.key} from storage`,
        {
          id: model.id,
          teamId: model.teamId,
          message: errToString(err),
        }
      );
    }
  }

  // static methods

  /**
   * Find an attachment by its key.
   *
   * @param key - The key of the attachment to find.
   * @param options - Additional options for the query.
   * @returns A promise resolving to the attachment with the given key, or null if not found.
   */
  static async findByKey(
    key: string,
    options?: FindOptions<Attachment>
  ): Promise<Attachment | null> {
    return this.findOne({ where: { key }, ...options });
  }

  /**
   * Get the total size of all attachments for a given team.
   *
   * @param connection - The Sequelize connection to use for the query.
   * @param teamId - The ID of the team to get the total size for.
   * @returns A promise resolving to the total size of all attachments for the given team in bytes.
   */
  static async getTotalSizeForTeam(
    connection: Sequelize,
    teamId: string
  ): Promise<number> {
    const result = await connection.query<{ total: string }>(
      `
      SELECT SUM(size) as total
      FROM attachments
      WHERE "teamId" = :teamId
    `,
      {
        replacements: { teamId },
        type: QueryTypes.SELECT,
      }
    );

    return parseInt(result?.[0]?.total ?? "0", 10);
  }

  /**
   * Get the redirect URL for a private attachment. Use `attachment.redirectUrl` if you already have
   * an instance of the attachment.
   *
   * @param id The ID of the attachment to get the redirect URL for.
   * @returns The redirect URL for the attachment.
   */
  static getRedirectUrl(id: string) {
    return `/api/attachments.redirect?id=${id}`;
  }

  // associations

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

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

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

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

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

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

export default Attachment;