This commit is contained in:
Philipinho
2026-08-08 22:25:33 +01:00
parent 92b6513f39
commit fed7d78495
29 changed files with 1091 additions and 122 deletions
@@ -45,6 +45,8 @@ import {
TransclusionSource,
TransclusionReference,
BaseEmbed,
IntegrationLink,
IntegrationMention,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -110,7 +112,9 @@ export const tiptapExtensions = [
Status,
TransclusionSource,
TransclusionReference,
BaseEmbed
BaseEmbed,
IntegrationLink,
IntegrationMention
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -1,4 +1,12 @@
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export class InstallIntegrationDto {
@IsNotEmpty()
@@ -42,6 +50,14 @@ export class OAuthAuthorizeDto {
@IsNotEmpty()
@IsString()
integrationId: string;
// In-app path to land on after OAuth; single leading slash keeps the
// redirect on the workspace origin.
@IsOptional()
@IsString()
@MaxLength(512)
@Matches(/^\/(?!\/)[^\s\\]*$/)
returnPath?: string;
}
export class OAuthDisconnectDto {
@@ -46,6 +46,7 @@ export class OAuthController {
dto.integrationId,
workspace.id,
user.id,
dto.returnPath,
);
return { authorizationUrl };
@@ -94,6 +95,8 @@ export class OAuthController {
// own hostname/customDomain (canonical DB truth, not user input), then
// signed into the state JWT. Tampering would invalidate the signature.
const returnUrl = statePayload.returnUrl;
// States signed before returnPath existed fall back to the admin page.
const returnPath = statePayload.returnPath ?? '/settings/integrations';
try {
await this.oauthService.exchangeCodeForTokens(
@@ -104,11 +107,11 @@ export class OAuthController {
statePayload.workspaceId,
);
return res.redirect(`${returnUrl}/settings/integrations`, 302).send();
return res.redirect(`${returnUrl}${returnPath}`, 302).send();
} catch (err) {
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
return res
.redirect(`${returnUrl}/settings/integrations?error=oauth_failed`, 302)
.redirect(`${returnUrl}${returnPath}?error=oauth_failed`, 302)
.send();
}
}
@@ -37,6 +37,9 @@ export type OAuthStatePayload = {
// accept), and this lets the callback redirect the user back to their own
// workspace host (subdomain or custom domain) after token exchange.
returnUrl: string;
// Settings page (relative to returnUrl) to land on after the callback.
// Derived server-side from the flow that started it, never from user input.
returnPath?: string;
exp: number;
};
@@ -57,6 +60,7 @@ export class OAuthService {
integrationId: string,
workspaceId: string,
userId: string,
returnPathOverride?: string,
): Promise<{ authorizationUrl: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
@@ -79,12 +83,22 @@ export class OAuthService {
workspace ?? { hostname: null, customDomain: null },
);
// Per-user connects are initiated from the account connections page;
// workspace-scoped authorizes from the admin integrations page. A connect
// started elsewhere (e.g. an editor connect card) passes its own path.
const returnPath =
returnPathOverride ??
((provider.definition.oauth.connectionScope ?? 'user') === 'workspace'
? '/settings/integrations'
: '/settings/account/connections');
const state = this.createSignedState({
integrationId,
type: integration.type,
userId,
workspaceId,
returnUrl,
returnPath,
exp: Date.now() + 10 * 60 * 1000,
});
@@ -154,6 +168,7 @@ export class OAuthService {
userId,
workspaceId,
returnUrl,
returnPath: '/settings/integrations',
exp: Date.now() + 10 * 60 * 1000,
});
@@ -65,6 +65,23 @@ export type UnfurlOpts = {
accessToken: string;
match: RegExpMatchArray;
patternType: string;
settings?: Record<string, any>;
};
export type LinkDescription = {
title: string;
description?: string;
};
// Returned instead of an UnfurlResult when the link needs a per-user
// connection the requesting user does not have yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
title: string;
description?: string;
};
export abstract class IntegrationProvider {
@@ -82,5 +99,13 @@ export abstract class IntegrationProvider {
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
// Tokenless summary of a matched link (e.g. "Pull Request #13337"),
// shown on the connect prompt before the user has authorized.
describeLink?(
patternType: string,
match: RegExpMatchArray,
url: string,
): LinkDescription | null;
handleEvent?(opts: HandleEventOpts): Promise<void>;
}
@@ -78,19 +78,24 @@ export class IntegrationConnectionRepo {
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
// The (integration_id, user_id) unique index is partial on kind='user';
// ON CONFLICT must repeat that predicate or Postgres cannot infer it.
return db
.insertInto('integrationConnections')
.values(connection)
.onConflict((oc) =>
oc.columns(['integrationId', 'userId']).doUpdateSet({
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
tokenExpiresAt: connection.tokenExpiresAt,
scopes: connection.scopes,
providerUserId: connection.providerUserId,
metadata: connection.metadata,
updatedAt: new Date(),
}),
oc
.columns(['integrationId', 'userId'])
.where(sql.ref('kind'), '=', 'user')
.doUpdateSet({
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
tokenExpiresAt: connection.tokenExpiresAt,
scopes: connection.scopes,
providerUserId: connection.providerUserId,
metadata: connection.metadata,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
@@ -125,9 +130,9 @@ export class IntegrationConnectionRepo {
);
}
// No need to clear other rows: the migration 20260524T020000 made the
// (integration_id, user_id) constraint partial-on-kind='user', so a
// workspace insert never conflicts with the installer's user-link row.
// No need to clear other rows: the (integration_id, user_id) unique index
// is partial on kind='user', so a workspace insert never conflicts with
// the installer's user-link row.
return db
.insertInto('integrationConnections')
@@ -5,6 +5,7 @@ import { IntegrationRepo } from '../repos/integration.repo';
import { OAuthService } from '../oauth/oauth.service';
import {
UnfurlResult,
UnfurlNeedsConnection,
IntegrationProvider,
} from '../registry/integration-provider.interface';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
@@ -33,7 +34,7 @@ export class UnfurlService {
url: string,
userId: string,
workspaceId: string,
): Promise<UnfurlResult | null> {
): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const cacheKey = this.buildCacheKey(workspaceId, userId, url);
const cached = await this.redis.get(cacheKey);
if (cached) {
@@ -52,13 +53,30 @@ export class UnfurlService {
return null;
}
const connection = await this.connectionRepo.findByIntegrationAndUser(
integration.id,
userId,
);
// Workspace-scoped providers (Slack) share one bot connection that serves
// every member; user-scoped providers need the requester's own token.
const connectionScope =
provider.definition.oauth?.connectionScope ?? 'user';
const connection =
connectionScope === 'workspace'
? await this.connectionRepo.findWorkspaceConnection(integration.id)
: await this.connectionRepo.findByIntegrationAndUser(
integration.id,
userId,
);
if (!connection) {
return null;
if (connectionScope === 'workspace') {
return null;
}
// Not cached: the card should load as soon as the user connects.
return this.buildNeedsConnection(
provider,
integration.id,
patternType,
match,
url,
);
}
try {
@@ -70,6 +88,7 @@ export class UnfurlService {
accessToken,
match,
patternType,
settings: (integration.settings as Record<string, any>) ?? {},
});
await this.redis.set(
@@ -86,6 +105,34 @@ export class UnfurlService {
}
}
private buildNeedsConnection(
provider: IntegrationProvider,
integrationId: string,
patternType: string,
match: RegExpMatchArray,
url: string,
): UnfurlNeedsConnection {
const described =
provider.describeLink?.(patternType, match, url) ?? null;
let fallbackDescription: string | undefined;
try {
const parsed = new URL(url);
fallbackDescription = `${parsed.host}${parsed.pathname}`;
} catch {
fallbackDescription = undefined;
}
return {
needsConnection: true,
integrationId,
integrationType: provider.definition.type,
integrationName: provider.definition.name,
title: described?.title ?? `${provider.definition.name} link`,
description: described?.description ?? fallbackDescription,
};
}
private async resolveProvider(
url: string,
workspaceId: string,
@@ -93,7 +140,12 @@ export class UnfurlService {
provider: IntegrationProvider;
match: RegExpMatchArray;
patternType: string;
integration: { id: string; isEnabled: boolean; type: string };
integration: {
id: string;
isEnabled: boolean;
type: string;
settings: unknown;
};
} | null> {
const staticResult = this.registry.findUnfurlProvider(url);
if (staticResult) {