Files
outline/server/models/Team.ts
Tom Moor 10f86ed218 feat: Webhooks (#3691)
* Webhooks (#3607)

* Get the migration and the model setup. Also make the sample env file a bit easier to use. Now just requires setting a SECRET_KEY and besides that will boot up from the sample

* WIP: Start getting a Webhook page created. Just the skeleton state right now

* WIP: Getting a form created to create webhooks, need to bring in react-hook-forms now

* WIP: Get library installed and make TS happy

* Get a few checkboxes ready to go

* Get creating and destroying working with a decent start to a frontend

* Didn't mean to enable this

* Remove eslint and fix other random typescript issue

* Rename some events to be more realistic

* Revert these changes

* PR review comments around policies. Also make sure this inherits from IdModel so it actually gets an id

* Allow any admin on the team to edit webhooks

* Start sending some webhooks for some User events

* Make sure the URL is valid

* Start recording webhook deliveries

* Make sure to verify if the subscription is for the type of event we are looking at

* Refactor sending Webhooks and follow better webhook schema

This creates a presenter to unify the format of webhooks. We also
extract the sending of webhooks and recording their deliveries to a
method than can be used by each of the different event type methods

We also add a status to WebhookDelivery since we need to save the record
before we make the HTTP request to get its id. Then once we make the
request and get a response we can update the delivery with the HTTP info

* Turn off a subscription that has failed for the last 25 deliveries

* Get a first spec passing. Found a bug in my returning of promises so good to patch that up now

* This looks nicer

* Get some tests added for the processor

* Add cron task to delete older webhooks

* Add Document Events to the Processor

* Revisions, FileOperations and Collections

* Get all the server side events added to the processor and make Typescript make sure they are all accounted for

* Get all the events added to the Frontend and work on styling them a bit, still needs some love though

* Get UI styled up a bit

* Get events wired up for webhook subscriptions

* Get delete events working and test at least one variant of them

* Get deletes working and actually make sure to send the model id in the webhook

* Remove webhook secrets from this slice

* Add disabled label for subscriptions that are disabled

* Make sure to cascade the delete

* Reorg this file a bit

* Fix association

* I removed secret for the moment

* Apply Copy changes from PR Review

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Actually apply the copy changes

TIL that if you Resolve a conversation it _also_ removes the 'staged suggestion' from your list on Github

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/Settings/Webhooks.tsx

Missed this copy change before

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Add disabled as yellow badge

* Resolve frontend comments

* Fixup Schema a bit and remove the dependency on the subscription

* Add test to make sure we don't disable until there are enough failures, and fix code to actually do that. Also some test fixes from the json response shape changes

* Fix WebhookDeliveries to store the responses as Text instead of blobs

* Switch to text better for response bodies, this is using the helpers better and makes the code read better

* Move the logic to a task but run in through the processor cause the tests expect that right now, moving the tests over next

* Split up the tests and actually enqueue the events from the WebhookProcessor instead of doing them inline

* Allow any team admin to see any webhook subscription for the team

* Add the indexes based on our lookup patterns

* Run eslint --fix to fix auto correct issues from when I tried to use Github to merge copy changes

* Allow subscriptions to be edited after creation

* Types caught that I didn't add the new event to the webhook processor, also added it to the frontend here

* I think this will get these into the translations file

* Catch a few more translations, use styled components better and remove usage of webhook subscription in the copy

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* fix: tsc
fix: Document model payload empty

* fix: Revision webhook payload
Add custom UA for hooks

* Add webhooks icon, move under Integrations settings
Some spacing fixes

* Add actorId to webhook payloads

* Add View and ApiKey event types

* Spacing tweaks, fix team payload

* fix: Webhook not disabled after 25 failures

* fix: Enable webhook when editing if previously disabled

* fix: Correctly store response headers

* fix: Error in json/parsing/presentation results in hanging 'pending' webhook delivery

* fix: Awkward payload for users.invite webhook

* Add BaseEvent, ShareEvent

* fix: Add share events to form

* fix: Move webhook delivery cleanup to single DB call
Remove some unused abstraction

* Add user, collection, group context to membership webhook events
Some associated refactoring

Co-authored-by: Corey Alexander <coreyja@gmail.com>
2022-06-28 22:44:50 -07:00

273 lines
6.2 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,
BeforeSave,
HasMany,
Scopes,
Length,
Is,
DataType,
} from "sequelize-typescript";
import { v4 as uuidv4 } from "uuid";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { generateAvatarUrl } from "@server/utils/avatars";
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
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";
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 {
@Column
name: string;
@IsLowercase
@Unique
@Length({ min: 4, max: 32, msg: "Must be between 4 and 32 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
@Column
domain: string | null;
@Column(DataType.UUID)
defaultCollectionId: string | null;
@Column
avatarUrl: string | null;
@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(true)
@Column
collaborativeEditing: boolean;
@Default("member")
@IsIn([["viewer", "member"]])
@Column
defaultUserRole: string;
// 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() {
// custom domain
if (this.domain) {
return `https://${this.domain}`;
}
if (!this.subdomain || !env.SUBDOMAINS_ENABLED) {
return env.URL;
}
const url = new URL(env.URL);
url.host = `${this.subdomain}.${getBaseDomain()}`;
return url.href.replace(/\/$/, "");
}
get logoUrl() {
return (
this.avatarUrl ||
generateAvatarUrl({
id: this.id,
name: this.name,
})
);
}
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 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",
},
{
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, { transaction });
}
});
};
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);
};
isDomainAllowed = async function (domain: string) {
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
@BeforeSave
static uploadAvatar = async (model: Team) => {
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,
});
}
}
};
}
export default Team;