* 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>
273 lines
6.2 KiB
TypeScript
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;
|