Ctrl K
ComunidadGuías…

server/models/Team.ts

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

Archivo original ↗Preguntar a KiBot
import crypto from "node:crypto";
import { URL } from "node:url";
import { addDays, subMinutes } from "date-fns";
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { type FindOptions, type SaveOptions } from "sequelize";
import { Op } from "sequelize";
import {
  Column,
  IsLowercase,
  NotIn,
  Default,
  Table,
  Unique,
  IsIn,
  IsDate,
  HasMany,
  Scopes,
  Is,
  DataType,
  IsUUID,
  AllowNull,
  AfterUpdate,
  BeforeUpdate,
  BeforeCreate,
  IsNumeric,
} from "sequelize-typescript";
import slugify from "slugify";
import { isEmail } from "validator";
import { TeamPreferenceDefaults } from "@shared/constants";
import type { Plan, TeamPreferences } from "@shared/types";
import { PlanFeature, TeamPreference, UserRole } from "@shared/types";
import {
  getBaseDomain,
  parseDomain,
  RESERVED_SUBDOMAINS,
} from "@shared/utils/domains";
import { PlanHelper } from "@shared/utils/PlanHelper";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import { parseEmail } from "@shared/utils/email";
import { TeamValidation } from "@shared/validations";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Document from "./Document";
import Share from "./Share";
import TeamDomain from "./TeamDomain";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import IsFQDN from "./validators/IsFQDN";
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
import { SkipChangeset } from "./decorators/Changeset";

/**
 * Flags that are available for setting on the team.
 */
export enum TeamFlag {
  MarkedSafe = "markedSafe",
}

const avatarRedirectPattern = new RegExp(attachmentRedirectRegex.source, "i");

const maxSubdomainLength = env.isCloudHosted
  ? TeamValidation.maxSubdomainLength
  : TeamValidation.maxSubdomainSelfHostedLength;

@Scopes(() => ({
  withDomains: {
    include: [{ model: TeamDomain }],
  },
  withAuthenticationProviders: {
    include: [
      {
        model: AuthenticationProvider,
        as: "authenticationProviders",
      },
    ],
  },
}))
@Table({ tableName: "teams", modelName: "team" })
class Team extends ParanoidModel<
  InferAttributes<Team>,
  Partial<InferCreationAttributes<Team>>
> {
  @NotContainsUrl
  @Length({
    min: 1,
    max: TeamValidation.maxNameLength,
    msg: `Team name must be between 1 and ${TeamValidation.maxNameLength} characters`,
  })
  @Column(DataType.STRING)
  name: string;

  @AllowNull
  @Length({
    max: TeamValidation.maxDescriptionLength,
    msg: `Team description must be ${TeamValidation.maxDescriptionLength} characters or less`,
  })
  @Column(DataType.TEXT)
  description: string | null;

  @IsLowercase
  @Unique
  @Length({
    min: TeamValidation.minSubdomainLength,
    max: maxSubdomainLength,
    msg: `subdomain must be between ${TeamValidation.minSubdomainLength} and ${maxSubdomainLength} characters`,
  })
  @Is({
    args: [/^[a-z\d-]+$/, "i"],
    msg: "Must be only alphanumeric and dashes",
  })
  @NotIn({
    args: [RESERVED_SUBDOMAINS],
    msg: "You chose a restricted word, please try another.",
  })
  @Column(DataType.STRING)
  subdomain: string | null;

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

  @IsUUID(4)
  @Column(DataType.UUID)
  defaultCollectionId: string | null;

  @AllowNull
  @IsUrlOrRelativePath
  @Length({ max: 4096, msg: "avatarUrl must be 4096 characters or less" })
  @Column(DataType.STRING)
  get avatarUrl() {
    const original = this.getDataValue("avatarUrl");

    if (original && !original.startsWith("https://tiley.herokuapp.com")) {
      return original;
    }

    return null;
  }

  set avatarUrl(value: string | null) {
    this.setDataValue("avatarUrl", value);
  }

  /**
   * Returns a directly-accessible URL for the team's avatar suitable for use
   * in contexts without authentication. Attachment is loaded and a signed (or
   * canonical) URL is returned; any other URL is returned unchanged.
   *
   * @returns A promise resolving to a direct URL, or null when no avatar is set.
   */
  async publicAvatarUrl(): Promise<string | null> {
    const url = this.avatarUrl;
    if (!url) {
      return null;
    }

    const match = avatarRedirectPattern.exec(url);
    if (!match?.groups?.id) {
      return url;
    }

    const attachment = await Attachment.findOne({
      where: { id: match.groups.id, teamId: this.id },
    });

    if (!attachment) {
      return url;
    }

    return attachment.isStoredInPublicBucket
      ? attachment.canonicalUrl
      : await attachment.signedUrl;
  }

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

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

  @Column(DataType.JSONB)
  signupQueryParams: { [key: string]: string } | null;

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

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

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

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

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

  @Default(UserRole.Member)
  @IsIn([[UserRole.Viewer, UserRole.Member]])
  @Column(DataType.STRING)
  defaultUserRole: UserRole;

  /** Approximate size in bytes of all attachments in the team. */
  @IsNumeric
  @Column(DataType.BIGINT)
  @SkipChangeset
  approximateTotalAttachmentsSize: number;

  @AllowNull
  @Length({
    max: TeamValidation.maxGuidanceMCPLength,
    msg: `MCP guidance must be ${TeamValidation.maxGuidanceMCPLength} characters or less`,
  })
  @Column(DataType.TEXT)
  guidanceMCP: string | null;

  @AllowNull
  @Column(DataType.JSONB)
  preferences: TeamPreferences | null;

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

  @Column(DataType.JSONB)
  flags: { [key in TeamFlag]?: number } | null;

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

  @Column(DataType.ARRAY(DataType.STRING))
  @SkipChangeset
  previousSubdomains: string[] | null;

  // getters

  /**
   * Returns whether the team has been suspended and is no longer accessible.
   */
  get isSuspended(): boolean {
    return !!this.suspendedAt;
  }

  /**
   * Returns the plan that the team is on.
   */
  get plan(): Plan {
    return PlanHelper.defaultPlan;
  }

  /**
   * Returns the features that the team is entitled to use.
   */
  get entitlements(): PlanFeature[] {
    return PlanHelper.getFeatures(this.plan);
  }

  /**
   * Returns the expiry for a share created now, if the team has a default.
   */
  get defaultShareExpiresAt(): Date | undefined {
    const days = this.getPreference(TeamPreference.DefaultShareExpiryDays);

    if (
      typeof days !== "number" ||
      days === 0 ||
      !this.entitlements.includes(PlanFeature.AdvancedSecurity)
    ) {
      return undefined;
    }

    return addDays(new Date(), days);
  }

  /**
   * Returns whether the team has email login enabled. For self-hosted installs
   * this also considers whether SMTP connection details have been configured.
   *
   * @return {boolean} Whether to show email login options
   */
  get emailSigninEnabled(): boolean {
    return this.guestSignin && env.EMAIL_ENABLED;
  }

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

    if (!this.subdomain || !env.isCloudHosted) {
      return env.URL;
    }

    const url = new URL(env.URL);
    url.host = `${this.subdomain}.${getBaseDomain()}`;
    return url.href.replace(/\/$/, "");
  }

  /**
   * Returns whether the given url points at this team's installation, taking
   * into account custom domains and hosted subdomains.
   *
   * @param url The url to check.
   * @returns True if the url belongs to this team.
   */
  public isTeamUrl(url: string): boolean {
    if (!url) {
      return false;
    }
    return parseDomain(url).host === parseDomain(this.url).host;
  }

  /**
   * Returns a code that can be used to delete the user's team. The code will
   * be rotated when the user signs out.
   *
   * @returns The deletion code.
   */
  public getDeleteConfirmationCode(user: User) {
    return crypto
      .createHash("md5")
      .update(`${this.id}${user.jwtSecret}`)
      .digest("hex")
      .replace(/[l1IoO0]/gi, "")
      .slice(0, 8)
      .toUpperCase();
  }

  /**
   * Preferences that decide behavior for the team.
   *
   * @param preference The team preference to set
   * @param value Sets the preference value
   * @returns The current team preferences
   */
  public setPreference = <T extends keyof TeamPreferences>(
    preference: T,
    value: TeamPreferences[T]
  ) => {
    if (!this.preferences) {
      this.preferences = {};
    }

    this.preferences = {
      ...this.preferences,
      [preference]: value,
    };

    return this.preferences;
  };

  /**
   * Returns the value of the given preference.
   *
   * @param preference The team preference to retrieve
   * @returns The preference value if set, else the default value
   */
  public getPreference = (preference: TeamPreference) =>
    this.preferences?.[preference] ??
    TeamPreferenceDefaults[preference] ??
    false;

  /**
   * Team flags are for storing information on a team record that is not visible
   * to the team members.
   *
   * @param flag The flag to set
   * @param value Set the flag to true/false
   * @returns The current team flags
   */
  public setFlag = (flag: TeamFlag, value = true) => {
    if (!this.flags) {
      this.flags = {};
    }
    const binary = value ? 1 : 0;
    if (this.flags[flag] !== binary) {
      this.flags = {
        ...this.flags,
        [flag]: binary,
      };
    }

    return this.flags;
  };

  /**
   * Returns the content of the given team flag.
   *
   * @param flag The flag to retrieve
   * @returns The flag value
   */
  public getFlag = (flag: TeamFlag) => this.flags?.[flag] ?? 0;

  /**
   * Team flags are for storing information on a team record that is not visible
   * to the team members.
   *
   * @param flag The flag to set
   * @param value The amount to increment by, defaults to 1
   * @returns The current team flags
   */
  public incrementFlag = (flag: TeamFlag, value = 1) => {
    if (!this.flags) {
      this.flags = {};
    }
    this.flags = {
      ...this.flags,
      [flag]: (this.flags[flag] ?? 0) + value,
    };
    return this.flags;
  };

  /**
   * Updates the lastActiveAt timestamp to the current time.
   *
   * @param force Whether to force the update even if the last update was recent
   * @returns A promise that resolves with the updated team
   */
  public updateActiveAt = async (force = false) => {
    const fiveMinutesAgo = subMinutes(new Date(), 5);

    // ensure this is updated only every few minutes otherwise
    // we'll be constantly writing to the DB as API requests happen
    if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo || force) {
      this.lastActiveAt = new Date();
    }

    // Save only writes to the database if there are changes
    return this.save({
      hooks: false,
    });
  };

  public collectionIds = async (paranoid = true) => {
    const models = await Collection.findAll({
      attributes: ["id"],
      where: {
        teamId: this.id,
        permission: {
          [Op.ne]: null,
        },
      },
      paranoid,
    });
    return models.map((c) => c.id);
  };

  /**
   * Find whether the passed domain can be used to sign-in to this team. Note
   * that this method always returns true if no domain restrictions are set.
   *
   * @param domainOrEmail The domain or email to check
   * @returns True if the domain is allowed to sign-in to this team
   */
  public isDomainAllowed = async function (
    this: Team,
    domainOrEmail: string
  ): Promise<boolean> {
    const allowedDomains = (await this.$get("allowedDomains")) || [];

    let domain = domainOrEmail;
    if (isEmail(domainOrEmail)) {
      const parsed = parseEmail(domainOrEmail);
      domain = parsed.domain;
    }

    return (
      allowedDomains.length === 0 ||
      allowedDomains.map((d: TeamDomain) => d.name).includes(domain)
    );
  };

  // associations

  @HasMany(() => Collection)
  collections: Collection[];

  @HasMany(() => Document)
  documents: Document[];

  @HasMany(() => User)
  users: User[];

  @HasMany(() => AuthenticationProvider)
  authenticationProviders: AuthenticationProvider[];

  @HasMany(() => TeamDomain)
  allowedDomains: TeamDomain[];

  // hooks

  @BeforeCreate
  static async setPreferences(model: Team) {
    // Set here rather than in TeamPreferenceDefaults as we only want to enable by default for new
    // workspaces.
    model.setPreference(TeamPreference.MembersCanInvite, true);

    // Set last active at on creation.
    model.lastActiveAt = new Date();

    return model;
  }

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

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

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

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

    return model;
  }

  @BeforeUpdate
  static async savePreviousSubdomain(model: Team) {
    const previousSubdomain = model.previous("subdomain");
    if (previousSubdomain && previousSubdomain !== model.subdomain) {
      const previousSubdomains = model.previousSubdomains || [];

      if (!previousSubdomains.includes(previousSubdomain)) {
        // Assign a new array, Sequelize does not detect in-place mutation.
        // Keep up to a maximum of 3 previous subdomains.
        model.previousSubdomains = [
          ...previousSubdomains,
          previousSubdomain,
        ].slice(-3);
      }
    }

    return model;
  }

  @AfterUpdate
  static deletePreviousAvatar = async (model: Team) => {
    const previousAvatarUrl = model.previous("avatarUrl");
    if (previousAvatarUrl && previousAvatarUrl !== model.avatarUrl) {
      const attachmentIds = parseAttachmentIds(previousAvatarUrl, true);
      if (!attachmentIds.length) {
        return;
      }

      const attachment = await Attachment.findOne({
        where: {
          id: attachmentIds[0],
          teamId: model.id,
        },
      });

      if (attachment) {
        await new DeleteAttachmentTask().schedule({
          attachmentId: attachment.id,
          teamId: model.id,
        });
      }
    }
  };

  /**
   * Find a team by its custom domain. The input is normalized by stripping
   * protocol, port, path, and lowercasing to match the stored format.
   *
   * @param domain the domain to search for.
   * @param options additional find options to pass to the query.
   * @returns the team with the given domain, or null if not found.
   */
  static async findByDomain(domain: string, options?: FindOptions<Team>) {
    const normalized = domain
      .replace(/(https?:)?\/\//, "")
      .split(/[/:?]/)[0]
      .toLowerCase();

    return this.findOne({
      ...options,
      where: Object.assign({}, options?.where, { domain: normalized }),
    });
  }

  /**
   * Find a team by its current or previous subdomain.
   *
   * @param subdomain - The subdomain to search for.
   * @returns The team with the given or previous subdomain, or null if not found.
   */
  static async findBySubdomain(subdomain: string) {
    // Preference is always given to the team with the subdomain currently
    // otherwise we can try and find a team that previously used the subdomain.
    return (
      (await this.findOne({
        where: {
          subdomain,
        },
      })) || (await this.findByPreviousSubdomain(subdomain))
    );
  }

  /**
   * Find a subdomain that is not yet in use, derived from the requested value.
   * A trailing top-level domain is removed and the remainder is slugified,
   * a numeric suffix is appended until a free subdomain is found. The result
   * is truncated to fit the maximum subdomain length.
   *
   * @param requested - The preferred subdomain or team name.
   * @param options - Additional find options to pass to the query.
   * @returns An available subdomain.
   */
  static async findAvailableSubdomain(
    requested: string,
    options?: FindOptions<Team>
  ) {
    // strip a trailing top-level domain so "acme.com" becomes "acme"
    const withoutTld = requested.replace(/\s*\.[a-z]{2,}\s*$/i, "");

    // filter subdomain to only valid characters
    // if there are less than the minimum length, use a default subdomain
    const normalized = slugify(withoutTld, { lower: true, strict: true });
    const base =
      normalized.length < 3 || RESERVED_SUBDOMAINS.includes(normalized)
        ? "team"
        : normalized;

    const withSuffix = (suffix: string) =>
      `${base.slice(0, maxSubdomainLength - suffix.length).replace(/-+$/, "")}${suffix}`;

    let subdomain = withSuffix("");
    let append = 0;

    for (;;) {
      const existing = await this.findOne({
        ...options,
        where: { subdomain },
        paranoid: false,
      });

      if (!existing) {
        return subdomain;
      }

      subdomain = withSuffix(String(++append));
    }
  }

  /**
   * Find a team by its previous subdomain.
   *
   * @param previousSubdomain - The previous subdomain to search for.
   * @returns The team with the given previous subdomain, or null if not found.
   */
  static async findByPreviousSubdomain(previousSubdomain: string) {
    return this.findOne({
      where: {
        previousSubdomains: {
          [Op.contains]: [previousSubdomain],
        },
      },
      order: [["updatedAt", "DESC"]],
    });
  }
}

export default Team;