import fs from "fs"; import path from "path"; import { URL } from "url"; import util from "util"; import { v4 as uuidv4 } from "uuid"; import { stripSubdomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import Logger from "@server/logging/logger"; import { generateAvatarUrl } from "@server/utils/avatars"; import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3"; import { DataTypes, sequelize, Op } from "../sequelize"; import Collection from "./Collection"; import Document from "./Document"; const readFile = util.promisify(fs.readFile); const Team = sequelize.define( "team", { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true, }, name: DataTypes.STRING, subdomain: { type: DataTypes.STRING, allowNull: true, validate: { isLowercase: true, is: { args: [/^[a-z\d-]+$/, "i"], msg: "Must be only alphanumeric and dashes", }, len: { args: [4, 32], msg: "Must be between 4 and 32 characters", }, notIn: { args: [RESERVED_SUBDOMAINS], msg: "You chose a restricted word, please try another.", }, }, unique: true, }, domain: { type: DataTypes.STRING, allowNull: true, unique: true, }, slackId: { type: DataTypes.STRING, allowNull: true, }, googleId: { type: DataTypes.STRING, allowNull: true, }, avatarUrl: { type: DataTypes.STRING, allowNull: true, }, sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, }, signupQueryParams: { type: DataTypes.JSONB, allowNull: true, }, guestSignin: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, }, documentEmbeds: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, }, collaborativeEditing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, }, defaultUserRole: { type: DataTypes.STRING, defaultValue: "member", allowNull: false, validate: { isIn: [["viewer", "member"]], }, }, }, { paranoid: true, getterMethods: { url() { if (this.domain) { return `https://${this.domain}`; } if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") { return process.env.URL; } // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message const url = new URL(process.env.URL); url.host = `${this.subdomain}.${stripSubdomain(url.host)}`; return url.href.replace(/\/$/, ""); }, logoUrl() { return ( this.avatarUrl || generateAvatarUrl({ id: this.id, name: this.name, }) ); }, }, } ); // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type. Team.associate = (models) => { Team.hasMany(models.Collection, { as: "collections", }); Team.hasMany(models.Document, { as: "documents", }); Team.hasMany(models.User, { as: "users", }); Team.hasMany(models.AuthenticationProvider, { as: "authenticationProviders", }); Team.addScope("withAuthenticationProviders", { include: [ { model: models.AuthenticationProvider, as: "authenticationProviders", }, ], }); }; // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type. const uploadAvatar = async (model) => { const endpoint = publicS3Endpoint(); const { avatarUrl } = model; if ( avatarUrl && !avatarUrl.startsWith("/api") && !avatarUrl.startsWith(endpoint) ) { try { const newUrl = await uploadToS3FromUrl( avatarUrl, `avatars/${model.id}/${uuidv4()}`, "public-read" ); if (newUrl) model.avatarUrl = newUrl; } catch (err) { Logger.error("Error uploading avatar to S3", err, { url: avatarUrl, }); } } }; Team.prototype.provisionSubdomain = async function ( requestedSubdomain: string, options = {} ) { if (this.subdomain) return this.subdomain; let subdomain = requestedSubdomain; let append = 0; for (;;) { try { await this.update( { subdomain, }, options ); break; } catch (err) { // subdomain was invalid or already used, try again subdomain = `${requestedSubdomain}${++append}`; } } return subdomain; }; // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type. Team.prototype.provisionFirstCollection = async function (userId) { const collection = await Collection.create({ name: "Welcome", description: "This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!", teamId: this.id, createdById: userId, sort: Collection.DEFAULT_SORT, permission: "read_write", }); // For the first collection we go ahead and create some intitial documents to get // the team started. You can edit these in /server/onboarding/x.md const onboardingDocs = [ "Integrations & API", "Our Editor", "Getting Started", "What is Outline", ]; for (const title of onboardingDocs) { const text = await readFile( path.join(process.cwd(), "server", "onboarding", `${title}.md`), "utf8" ); const document = await Document.create({ version: 2, isWelcome: true, parentDocumentId: null, collectionId: collection.id, teamId: collection.teamId, userId: collection.createdById, lastModifiedById: collection.createdById, createdById: collection.createdById, title, text, }); await document.publish(collection.createdById); } }; Team.prototype.collectionIds = async function (paranoid = true) { const models = await Collection.findAll({ attributes: ["id"], where: { teamId: this.id, permission: { [Op.ne]: null, }, }, paranoid, }); // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type. return models.map((c) => c.id); }; Team.beforeSave(uploadAvatar); export default Team;