Ctrl K
ComunidadGuías…

server/models/ShareSubscription.ts

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

Archivo original ↗Preguntar a KiBot
import crypto from "node:crypto";
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { type SaveOptions, Op } from "sequelize";
import {
  BeforeCreate,
  Column,
  DataType,
  BelongsTo,
  ForeignKey,
  Table,
  Scopes,
} from "sequelize-typescript";
import { subHours } from "date-fns";
import { Hour } from "@shared/utils/time";
import { ValidationError } from "@server/errors";
import { LockHelper } from "@server/storage/LockHelper";
import Document from "./Document";
import Share from "./Share";
import IdModel from "./base/IdModel";

/**
 * A subscription to email notifications for updates to a publicly shared
 * document and its descendants.
 */
@Scopes(() => ({
  active: {
    where: {
      confirmedAt: { [Op.not]: null },
      unsubscribedAt: null,
    },
  },
}))
@Table({ tableName: "share_subscriptions", modelName: "share_subscription" })
class ShareSubscription extends IdModel<
  InferAttributes<ShareSubscription>,
  Partial<InferCreationAttributes<ShareSubscription>>
> {
  @BelongsTo(() => Share, "shareId")
  share: Share;

  @ForeignKey(() => Share)
  @Column(DataType.UUID)
  shareId: string;

  /** The document to scope notifications to (the document and its descendants). */
  @BelongsTo(() => Document, "documentId")
  document: Document;

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

  /** The subscribed email */
  @Column(DataType.STRING)
  email: string;

  /** Normalized email fingerprint helps to improve spam detection through removal of common bypasses */
  @Column(DataType.STRING)
  emailFingerprint: string;

  /** Signing secret for subscribe/unsubscribe links */
  @Column(DataType.STRING)
  secret: string;

  /** IP address of the user that subscribed */
  @Column(DataType.STRING(45))
  ipAddress: string | null;

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

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

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

  /** Maximum number of unique email subscriptions allowed per IP address. */
  static maxSubscriptionsPerIP = 3;

  /** Minimum time in milliseconds to wait before a confirmation email may be resent. */
  static minResendInterval = Hour.ms;

  /**
   * Enforce a per-IP rate limit on subscription creation to prevent abuse.
   *
   * @param model The subscription being created.
   * @param options The save options including the current transaction.
   * @throws when the IP has reached the maximum number of subscriptions.
   */
  @BeforeCreate
  static async checkIPLimit(
    model: ShareSubscription,
    options: SaveOptions<ShareSubscription>
  ) {
    if (!model.ipAddress) {
      return;
    }

    // Serialize concurrent creation from the address, otherwise every request
    // can read the same count and pass the check.
    await LockHelper.acquire(
      model.sequelize,
      `shareSubscriptions:${model.ipAddress}`,
      options.transaction
    );

    const results = await this.findAll({
      attributes: ["emailFingerprint"],
      where: { ipAddress: model.ipAddress },
      group: ["emailFingerprint"],
      transaction: options.transaction,
    });
    const count = results.length;

    if (count >= this.maxSubscriptionsPerIP) {
      throw ValidationError(`You have reached the limit of subscriptions`);
    }
  }

  /**
   * Whether this subscription has been confirmed via email.
   */
  get isConfirmed(): boolean {
    return !!this.confirmedAt;
  }

  /**
   * Whether this subscription has been unsubscribed.
   */
  get isUnsubscribed(): boolean {
    return !!this.unsubscribedAt;
  }

  /**
   * Whether a confirmation email may be sent again for this subscription. The
   * required wait doubles as the subscription ages, so an unconfirmed address
   * cannot be mailed repeatedly.
   */
  get canResendConfirmation(): boolean {
    const now = Date.now();
    const age = now - this.createdAt.getTime();
    const sinceLastSend = now - this.updatedAt.getTime();

    return (
      sinceLastSend >= Math.max(age / 2, ShareSubscription.minResendInterval)
    );
  }

  /**
   * Whether the confirmation token has expired (24-hour window from last update).
   */
  get isTokenExpired(): boolean {
    return this.updatedAt < subHours(new Date(), 24);
  }

  /**
   * Normalize an email address into a fingerprint for uniqueness comparison.
   * Lowercases, removes dots from local part, and strips +alias suffixes.
   *
   * @param email The email address to normalize.
   * @returns The normalized email fingerprint.
   */
  static normalizeEmailFingerprint(email: string): string {
    // Strip null bytes to prevent injection bypasses
    const lower = email.replace(/\0/g, "").toLowerCase().trim();
    const [localPart, domain] = lower.split("@");
    if (!localPart || !domain) {
      return crypto.createHash("sha256").update(lower).digest("hex");
    }

    const withoutPlus = localPart.split("+")[0];

    // Normalize googlemail.com to gmail.com as they are the same service
    const normalizedDomain = domain === "googlemail.com" ? "gmail.com" : domain;

    // Gmail ignores dots in the local part; other providers treat them as significant
    const normalizedLocal =
      normalizedDomain === "gmail.com"
        ? withoutPlus.replace(/\./g, "")
        : withoutPlus;

    const normalized = `${normalizedLocal}@${normalizedDomain}`;
    return crypto.createHash("sha256").update(normalized).digest("hex");
  }

  /**
   * Generate an HMAC token for confirming this subscription.
   *
   * @param subscription The subscription to generate a token for.
   * @returns The confirmation token as a hex string.
   */
  static generateConfirmToken(subscription: ShareSubscription): string {
    return crypto
      .createHmac("sha256", subscription.secret)
      .update(`${subscription.shareId}:${subscription.email}:confirm`)
      .digest("hex");
  }

  /**
   * Generate an HMAC token for unsubscribing from this subscription.
   *
   * @param subscription The subscription to generate a token for.
   * @returns The unsubscribe token as a hex string.
   */
  static generateUnsubscribeToken(subscription: ShareSubscription): string {
    return crypto
      .createHmac("sha256", subscription.secret)
      .update(`${subscription.shareId}:${subscription.email}:unsubscribe`)
      .digest("hex");
  }
}

export default ShareSubscription;