Ctrl K
ComunidadGuías…

server/models/Share.ts

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

Archivo original ↗Preguntar a KiBot
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { Op, type SaveOptions } from "sequelize";
import {
  ForeignKey,
  BelongsTo,
  Column,
  DefaultScope,
  Table,
  Scopes,
  DataType,
  Default,
  AllowNull,
  Is,
  Unique,
  BeforeUpdate,
} from "sequelize-typescript";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { ShareValidation } from "@shared/validations";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import IsFQDN from "./validators/IsFQDN";
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";

@DefaultScope(() => ({
  include: [
    {
      association: "user",
      paranoid: false,
    },
    {
      association: "collection",
      required: false,
    },
    {
      association: "document",
      required: false,
    },
    {
      association: "team",
      required: true,
    },
  ],
}))
@Scopes(() => ({
  // A function so that the current time is read for each query.
  notExpired: () => ({
    where: {
      [Op.or]: [
        { expiresAt: { [Op.is]: null } },
        { expiresAt: { [Op.gt]: new Date() } },
      ],
    },
  }),
  withDrafts: {
    include: [
      {
        association: "user",
        paranoid: false,
      },
      {
        association: "collection",
        required: false,
      },
      {
        model: Document.scope("withDrafts"),
        as: "document",
        required: false,
      },
      {
        association: "team",
        required: true,
      },
    ],
  },
  withCollectionPermissions: (userId: string) => ({
    include: [
      {
        attributes: [
          "id",
          "name",
          "permission",
          "ownerId",
          "sharing",
          "urlId",
          "teamId",
          "deletedAt",
        ],
        model: Collection.scope({
          method: ["withMembership", userId],
        }),
        as: "collection",
      },
      {
        model: Document.scope([
          "withDrafts",
          {
            method: ["withMembership", userId],
          },
        ]),
        paranoid: true,
        as: "document",
        include: [
          {
            attributes: [
              "id",
              "name",
              "permission",
              "ownerId",
              "urlId",
              "sharing",
              "teamId",
              "deletedAt",
            ],
            model: Collection.scope({
              method: ["withMembership", userId],
            }),
            as: "collection",
          },
        ],
      },
      {
        association: "user",
        paranoid: false,
      },
      {
        association: "team",
      },
    ],
  }),
}))
@Table({ tableName: "shares", modelName: "share" })
class Share extends IdModel<
  InferAttributes<Share>,
  Partial<InferCreationAttributes<Share>>
> {
  @Column(DataType.BOOLEAN)
  published: boolean;

  @Column(DataType.BOOLEAN)
  includeChildDocuments: boolean;

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

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

  /** The time after which the public link stops working, if any. */
  @AllowNull
  @Column(DataType.DATE)
  expiresAt: Date | null;

  /** Total count of times the shared link has been accessed */
  @Default(0)
  @Column(DataType.INTEGER)
  views: number;

  @AllowNull
  @Is({
    args: UrlHelper.SHARE_URL_SLUG_REGEX,
    msg: "Must be only alphanumeric and dashes",
  })
  @Column(DataType.STRING)
  urlId: string | null | undefined;

  @Unique
  @Length({ max: 255, msg: "domain must be 255 characters or less" })
  @IsFQDN
  @Column(DataType.STRING)
  domain: string | null;

  @Default(false)
  @Column(DataType.BOOLEAN)
  allowIndexing: boolean;

  @Default(true)
  @Column(DataType.BOOLEAN)
  allowSubscriptions: boolean;

  @Default(false)
  @Column(DataType.BOOLEAN)
  showLastUpdated: boolean;

  @Default(false)
  @Column(DataType.BOOLEAN)
  showTOC: boolean;

  @AllowNull
  @Length({
    max: ShareValidation.maxTitleLength,
    msg: `title must be ${ShareValidation.maxTitleLength} characters or less`,
  })
  @Column(DataType.STRING)
  title: string | null;

  @AllowNull
  @IsUrlOrRelativePath
  @Length({
    max: ShareValidation.maxIconUrlLength,
    msg: `iconUrl must be ${ShareValidation.maxIconUrlLength} characters or less`,
  })
  @Column(DataType.STRING)
  iconUrl: string | null;

  // hooks

  @BeforeUpdate
  static async checkDomain(model: Share, options: SaveOptions) {
    if (!model.domain) {
      return model;
    }

    model.domain = model.domain.toLowerCase();

    const count = await Team.count({
      ...options,
      where: {
        domain: model.domain,
      },
    });

    if (count > 0) {
      throw ValidationError("Domain is already in use");
    }

    return model;
  }

  // static methods

  /**
   * Finds published, non-revoked, unexpired shares for the given document(s)
   * within a team.
   *
   * @param teamId - the team the documents belong to.
   * @param documentIds - a single document ID or an array of document IDs.
   * @returns the matching public shares.
   */
  static getPublicSharesForDocumentIds(
    teamId: string,
    documentIds: string | string[]
  ): Promise<Share[]> {
    return this.findPublicShares(teamId, "documentId", documentIds);
  }

  /**
   * Finds published, non-revoked, unexpired shares for the given collection(s)
   * within a team.
   *
   * @param teamId - the team the collections belong to.
   * @param collectionIds - a single collection ID or an array of collection IDs.
   * @returns the matching public shares.
   */
  static getPublicSharesForCollectionIds(
    teamId: string,
    collectionIds: string | string[]
  ): Promise<Share[]> {
    return this.findPublicShares(teamId, "collectionId", collectionIds);
  }

  // getters

  get isRevoked() {
    return !!this.revokedAt;
  }

  get isExpired() {
    return !!this.expiresAt && this.expiresAt <= new Date();
  }

  get canonicalUrl() {
    if (this.domain) {
      const url = new URL(env.URL);
      return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`;
    }

    return this.urlId
      ? `${this.team.url}/s/${this.urlId}`
      : `${this.team.url}/s/${this.id}`;
  }

  // associations

  @BelongsTo(() => User, "revokedById")
  revokedBy: User;

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

  @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;

  revoke(ctx: APIContext) {
    const { user } = ctx.state.auth;
    this.revokedAt = new Date();
    this.revokedById = user.id;
    return this.saveWithCtx(ctx, undefined, { name: "revoke" });
  }

  private static findPublicShares(
    teamId: string,
    column: "documentId" | "collectionId",
    ids: string | string[]
  ): Promise<Share[]> {
    const values = Array.isArray(ids) ? ids : [ids];
    if (values.length === 0) {
      return Promise.resolve([]);
    }

    return this.scope("notExpired").findAll({
      where: {
        teamId,
        published: true,
        revokedAt: { [Op.is]: null },
        [column]: values,
      },
      // Deterministic order so the same share consistently wins when a
      // resource has more than one active share.
      order: [["createdAt", "ASC"]],
    });
  }
}

export default Share;