Files
outline/server/models/Team.ts
2023-04-23 12:20:44 -07:00

346 lines
8.3 KiB
TypeScript

import fs from "fs";
import path from "path";
import { URL } from "url";
import util from "util";
import { Op } from "sequelize";
import {
Column,
IsLowercase,
NotIn,
Default,
Table,
Unique,
IsIn,
HasMany,
Scopes,
Is,
DataType,
IsUUID,
IsUrl,
AllowNull,
AfterUpdate,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import {
CollectionPermission,
TeamPreference,
TeamPreferences,
} from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env";
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 TeamDomain from "./TeamDomain";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import IsFQDN from "./validators/IsFQDN";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
const readFile = util.promisify(fs.readFile);
@Scopes(() => ({
withDomains: {
include: [{ model: TeamDomain }],
},
withAuthenticationProviders: {
include: [
{
model: AuthenticationProvider,
as: "authenticationProviders",
},
],
},
}))
@Table({ tableName: "teams", modelName: "team" })
@Fix
class Team extends ParanoidModel {
@NotContainsUrl
@Length({ min: 2, max: 255, msg: "name must be between 2 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
@IsUrl
@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;
@Default(true)
@Column(DataType.JSONB)
signupQueryParams: { [key: string]: string } | null;
@Default(true)
@Column
guestSignin: boolean;
@Default(true)
@Column
documentEmbeds: boolean;
@Default(true)
@Column
memberCollectionCreate: boolean;
@Default("member")
@IsIn([["viewer", "member"]])
@Column
defaultUserRole: string;
@AllowNull
@Column(DataType.JSONB)
preferences: TeamPreferences | null;
// getters
/**
* 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.ENVIRONMENT === "development")
);
}
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.SUBDOMAINS_ENABLED) {
return env.URL;
}
url.host = `${this.subdomain}.${getBaseDomain()}`;
return url.href.replace(/\/$/, "");
}
/**
* 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[preference] = value;
this.changed("preferences", true);
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;
provisionFirstCollection = async (userId: string) => {
await this.sequelize!.transaction(async (transaction) => {
const collection = await Collection.create(
{
name: "Welcome",
description: `This collection is a quick guide to what ${env.APP_NAME} 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: CollectionPermission.ReadWrite,
},
{
transaction,
}
);
// 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,
},
{ transaction }
);
await document.publish(collection.createdById, collection.id, {
transaction,
});
}
});
};
public collectionIds = async function (this: Team, 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 domain The domain to check
* @returns True if the domain is allowed to sign-in to this team
*/
public isDomainAllowed = async function (
this: Team,
domain: string
): Promise<boolean> {
const allowedDomains = (await this.$get("allowedDomains")) || [];
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
@AfterUpdate
static deletePreviousAvatar = async (model: Team) => {
if (
model.previous("avatarUrl") &&
model.previous("avatarUrl") !== model.avatarUrl
) {
const attachmentIds = parseAttachmentIds(
model.previous("avatarUrl"),
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;