// @flow import uuid from "uuid"; import { URL } from "url"; import fs from "fs"; import util from "util"; import path from "path"; import { DataTypes, sequelize, Op } from "../sequelize"; import { publicS3Endpoint, uploadToS3FromUrl } from "../utils/s3"; import { stripSubdomain, RESERVED_SUBDOMAINS, } from "../../shared/utils/domains"; import { ValidationError } from "../errors"; import Collection from "./Collection"; import Document from "./Document"; import User from "./User"; 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, }, 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 }, guestSignin: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, }, documentEmbeds: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, }, slackData: DataTypes.JSONB, }, { getterMethods: { url() { if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") { return process.env.URL; } const url = new URL(process.env.URL); url.host = `${this.subdomain}.${stripSubdomain(url.host)}`; return url.href.replace(/\/$/, ""); }, logoUrl() { return ( this.avatarUrl || (this.slackData ? this.slackData.image_88 : null) ); }, }, } ); Team.associate = (models) => { Team.hasMany(models.Collection, { as: "collections" }); Team.hasMany(models.Document, { as: "documents" }); Team.hasMany(models.User, { as: "users" }); }; 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}/${uuid.v4()}`, "public-read" ); if (newUrl) model.avatarUrl = newUrl; } catch (err) { // we can try again next time console.error(err); } } }; Team.prototype.provisionSubdomain = async function (subdomain) { if (this.subdomain) return this.subdomain; let append = 0; while (true) { try { await this.update({ subdomain }); break; } catch (err) { // subdomain was invalid or already used, try again subdomain = `${subdomain}${++append}`; } } return subdomain; }; 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!", type: "atlas", teamId: this.id, creatorId: userId, }); // 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 = [ "Support", "Integrations & API", "Our Editor", "What is Outline", ]; for (const title of onboardingDocs) { const text = await readFile( path.join(__dirname, "..", "onboarding", `${title}.md`), "utf8" ); const document = await Document.create({ version: 1, isWelcome: true, parentDocumentId: null, collectionId: collection.id, teamId: collection.teamId, userId: collection.creatorId, lastModifiedById: collection.creatorId, createdById: collection.creatorId, title, text, }); await document.publish(); } }; Team.prototype.addAdmin = async function (user: User) { return user.update({ isAdmin: true }); }; Team.prototype.removeAdmin = async function (user: User) { const res = await User.findAndCountAll({ where: { teamId: this.id, isAdmin: true, id: { [Op.ne]: user.id, }, }, limit: 1, }); if (res.count >= 1) { return user.update({ isAdmin: false }); } else { throw new ValidationError("At least one admin is required"); } }; Team.prototype.suspendUser = async function (user: User, admin: User) { if (user.id === admin.id) throw new ValidationError("Unable to suspend the current user"); return user.update({ suspendedById: admin.id, suspendedAt: new Date(), }); }; Team.prototype.activateUser = async function (user: User, admin: User) { return user.update({ suspendedById: null, suspendedAt: null, }); }; Team.prototype.collectionIds = async function (paranoid: boolean = true) { let models = await Collection.findAll({ attributes: ["id", "private"], where: { teamId: this.id, private: false }, paranoid, }); return models.map((c) => c.id); }; Team.beforeSave(uploadAvatar); export default Team;