import crypto from "crypto"; import { URL } from "url"; import { subMinutes } from "date-fns"; import { InferAttributes, InferCreationAttributes, 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 { isEmail } from "validator"; import { TeamPreferenceDefaults } from "@shared/constants"; import { TeamPreference, TeamPreferences, UserRole } from "@shared/types"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import { parseEmail } from "@shared/utils/email"; 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 Fix from "./decorators/Fix"; import IsFQDN from "./validators/IsFQDN"; import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath"; import Length from "./validators/Length"; import NotContainsUrl from "./validators/NotContainsUrl"; @Scopes(() => ({ withDomains: { include: [{ model: TeamDomain }], }, withAuthenticationProviders: { include: [ { model: AuthenticationProvider, as: "authenticationProviders", }, ], }, })) @Table({ tableName: "teams", modelName: "team" }) @Fix class Team extends ParanoidModel< InferAttributes, Partial> > { @NotContainsUrl @Length({ min: 1, max: 255, msg: "name must be between 1 to 255 characters" }) @Column name: string; @IsLowercase @Unique @Length({ min: 2, max: env.isCloudHosted ? 32 : 255, msg: `subdomain must be between 2 and ${ env.isCloudHosted ? 32 : 255 } 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 subdomain: string | null; @Unique @Length({ max: 255, msg: "domain must be 255 characters or less" }) @IsFQDN @Column 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); } @Default(true) @Column sharing: boolean; @Default(false) @Column inviteRequired: boolean; @Column(DataType.JSONB) signupQueryParams: { [key: string]: string } | null; @Default(true) @Column guestSignin: boolean; @Default(true) @Column documentEmbeds: boolean; @Default(true) @Column memberCollectionCreate: boolean; @Default(true) @Column 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) approximateTotalAttachmentsSize: number; @AllowNull @Column(DataType.JSONB) preferences: TeamPreferences | null; @IsDate @Column suspendedAt: Date | null; @IsDate @Column lastActiveAt: Date | null; // getters /** * Returns whether the team has been suspended and is no longer accessible. */ get isSuspended(): boolean { return !!this.suspendedAt; } /** * 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.SMTP_HOST || env.isDevelopment); } get url() { const url = new URL(env.URL); // custom domain if (this.domain) { return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`; } if (!this.subdomain || !env.isCloudHosted) { return env.URL; } url.host = `${this.subdomain}.${getBaseDomain()}`; return url.href.replace(/\/$/, ""); } /** * 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 = ( 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; /** * 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 function (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 { 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; } @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 DeleteAttachmentTask.schedule({ attachmentId: attachment.id, teamId: model.id, }); } } }; } export default Team;