mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
f50bb00b29
* Refactor of OAuth account linking flows * PR feedback
373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
import env from "@shared/env";
|
|
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
|
import { UnfurlResourceType } from "@shared/types";
|
|
|
|
export const GitLabOAuthNonceCookie = "gitlabOAuthNonce";
|
|
|
|
export type OAuthState = {
|
|
teamId: string;
|
|
nonce: string;
|
|
};
|
|
|
|
export class GitLabUtils {
|
|
public static defaultGitlabUrl = "https://gitlab.com";
|
|
|
|
private static supportedResources = [
|
|
UnfurlResourceType.Issue,
|
|
UnfurlResourceType.PR,
|
|
UnfurlResourceType.Project,
|
|
];
|
|
|
|
/**
|
|
* Gets the GitLab URL, preferring the provided custom URL over the default.
|
|
*
|
|
* @param customUrl - Optional custom GitLab URL from integration settings.
|
|
* @returns The GitLab URL to use.
|
|
*/
|
|
private static getGitlabUrl(customUrl?: string): string {
|
|
return customUrl || this.defaultGitlabUrl;
|
|
}
|
|
|
|
/**
|
|
* Gets the OAuth URL for the provided custom GitLab URL or default environment URL.
|
|
*
|
|
* @param customUrl - Optional custom GitLab URL from integration settings.
|
|
* @returns The OAuth URL.
|
|
*/
|
|
public static getOauthUrl(customUrl?: string): string {
|
|
return `${this.getGitlabUrl(customUrl)}/oauth`;
|
|
}
|
|
|
|
public static get url() {
|
|
return integrationSettingsPath("gitlab");
|
|
}
|
|
|
|
/**
|
|
* Generates the error URL for GitLab authorization errors.
|
|
*
|
|
* @param error - The error message to include in the URL.
|
|
* @returns The URL to redirect to upon authorization error.
|
|
*/
|
|
public static errorUrl(error: string): string {
|
|
return `${this.url}?error=${encodeURIComponent(error)}`;
|
|
}
|
|
|
|
/**
|
|
* Generates the callback URL for GitLab OAuth.
|
|
*
|
|
* @param baseUrl - The base URL of the application.
|
|
* @param params - Optional query parameters to include in the callback URL.
|
|
* @returns The full callback URL.
|
|
*/
|
|
public static callbackUrl(
|
|
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
|
baseUrl: env.URL,
|
|
params: undefined,
|
|
}
|
|
): string {
|
|
const callbackPath = "/api/gitlab.callback";
|
|
return params
|
|
? `${baseUrl}${callbackPath}?${params}`
|
|
: `${baseUrl}${callbackPath}`;
|
|
}
|
|
|
|
/**
|
|
* Generates the authorization URL for GitLab OAuth.
|
|
*
|
|
* @param state - The OAuth state with teamId for routing and nonce for CSRF.
|
|
* @param customUrl - Optional custom GitLab URL from integration settings.
|
|
* @param customClientId - Optional custom OAuth client ID from integration settings.
|
|
* @returns The full URL to redirect the user to GitLab's OAuth authorization page.
|
|
*/
|
|
public static authUrl(
|
|
state: OAuthState,
|
|
customUrl?: string,
|
|
customClientId?: string
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
client_id: customClientId || env.GITLAB_CLIENT_ID,
|
|
redirect_uri: this.callbackUrl(),
|
|
response_type: "code",
|
|
state: JSON.stringify(state),
|
|
scope: "read_api read_user",
|
|
});
|
|
|
|
return `${this.getOauthUrl(customUrl)}/authorize?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* Parses an OAuth state string from a GitLab callback.
|
|
*
|
|
* @param state - The state string carried in the callback query.
|
|
* @returns The parsed OAuth state.
|
|
*/
|
|
public static parseState(state: string): OAuthState | undefined {
|
|
try {
|
|
return JSON.parse(state);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates the installation request URL.
|
|
*
|
|
* @returns The URL for installation requests.
|
|
*/
|
|
public static installRequestUrl(): string {
|
|
return `${this.url}?install_request=true`;
|
|
}
|
|
|
|
/**
|
|
* Parses a GitLab URL and extracts resource identifiers.
|
|
*
|
|
* @param url - The GitLab URL to parse.
|
|
* @param customUrl - Optional custom GitLab URL from integration settings.
|
|
* @returns An object containing resource identifiers or undefined if the URL is invalid.
|
|
*/
|
|
public static parseUrl(
|
|
url: string,
|
|
customUrl?: string
|
|
):
|
|
| {
|
|
owner: string;
|
|
repo: string | undefined;
|
|
type: UnfurlResourceType.Issue | UnfurlResourceType.PR;
|
|
id: number;
|
|
url: string;
|
|
}
|
|
| {
|
|
owner: string;
|
|
repo: string;
|
|
type: UnfurlResourceType.Project;
|
|
url: string;
|
|
}
|
|
| undefined {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname;
|
|
|
|
if (parsed.hostname !== urlHostname) {
|
|
return;
|
|
}
|
|
|
|
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
|
|
// Try base64-encoded `show` query parameter first
|
|
// e.g. /owner/repo/-/issues?show=eyJ...
|
|
const showParam = parsed.searchParams.get("show");
|
|
if (showParam && parts.length >= 4) {
|
|
const resourceType = parts.pop();
|
|
parts.pop(); // separator ("-")
|
|
const repo = parts.pop();
|
|
const owner = parts.join("/");
|
|
|
|
const type =
|
|
resourceType === "issues" || resourceType === "work_items"
|
|
? UnfurlResourceType.Issue
|
|
: resourceType === "merge_requests"
|
|
? UnfurlResourceType.PR
|
|
: undefined;
|
|
|
|
if (!type || !this.supportedResources.includes(type)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const decoded = JSON.parse(atob(decodeURIComponent(showParam)));
|
|
const iid = Number(decoded.iid);
|
|
if (!iid) {
|
|
return;
|
|
}
|
|
return { owner, repo, type, id: iid, url };
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if it's a project URL (no -/ separator pattern in path)
|
|
if (!parsed.pathname.includes("/-/")) {
|
|
if (parts.length >= 2 && !this.isSystemPath(parts[0])) {
|
|
const repo = parts[parts.length - 1];
|
|
const owner = parts.slice(0, -1).join("/");
|
|
return {
|
|
owner,
|
|
repo,
|
|
type: UnfurlResourceType.Project,
|
|
url,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parts.length < 5) {
|
|
return;
|
|
}
|
|
|
|
// Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123
|
|
const resourceId = parts.pop();
|
|
const resourceType = parts.pop();
|
|
parts.pop(); // separator ("-")
|
|
|
|
const repo = parts.pop();
|
|
const owner = parts.join("/");
|
|
|
|
const type =
|
|
resourceType === "issues" || resourceType === "work_items"
|
|
? UnfurlResourceType.Issue
|
|
: resourceType === "merge_requests"
|
|
? UnfurlResourceType.PR
|
|
: undefined;
|
|
|
|
if (!type || !this.supportedResources.includes(type)) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
owner,
|
|
repo,
|
|
type,
|
|
id: Number(resourceId),
|
|
url,
|
|
};
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the first path segment is a known GitLab system path.
|
|
*
|
|
* @param segment - the first path segment of the URL.
|
|
* @returns true if the segment is a known system path.
|
|
*/
|
|
private static isSystemPath(segment: string): boolean {
|
|
const systemPaths = new Set([
|
|
"explore",
|
|
"help",
|
|
"admin",
|
|
"dashboard",
|
|
"users",
|
|
"groups",
|
|
"projects",
|
|
"snippets",
|
|
"search",
|
|
"-",
|
|
]);
|
|
return systemPaths.has(segment);
|
|
}
|
|
|
|
/**
|
|
* Returns the color associated with a given status.
|
|
*
|
|
* @param status - The status of the resource.
|
|
* @param isDraftMR - Whether the resource is a draft merge request.
|
|
* @returns The color associated with the status.
|
|
*/
|
|
public static getColorForStatus(
|
|
status: string,
|
|
isDraftMR: boolean = false
|
|
): string {
|
|
const statusColors: Record<string, string> = {
|
|
opened: isDraftMR ? "#848d97" : "#1f75cb",
|
|
done: "#a371f7",
|
|
closed: "#f85149",
|
|
merged: "#8250df",
|
|
canceled: "#848d97",
|
|
};
|
|
return statusColors[status] ?? "#848d97";
|
|
}
|
|
|
|
/**
|
|
* Returns a deterministic color for a GitLab project based on its ID.
|
|
* Mirrors GitLab's identicon algorithm: (id % 7) mapped to a palette.
|
|
*
|
|
* @param projectId - the numeric project ID.
|
|
* @returns a hex color string.
|
|
*/
|
|
public static getColorForProject(projectId: number): string {
|
|
const palette = [
|
|
"#e05842", // red
|
|
"#a972cc", // purple
|
|
"#5b6abf", // indigo
|
|
"#3e8fda", // blue
|
|
"#42a68c", // teal
|
|
"#e67e3c", // orange
|
|
"#7e7e7e", // neutral
|
|
];
|
|
return palette[projectId % 7];
|
|
}
|
|
|
|
/**
|
|
* Sanitizes GitLab-flavored markdown to standard markdown compatible with
|
|
* our editor. Strips or converts GitLab-specific syntax that would otherwise
|
|
* render as raw text in previews.
|
|
*
|
|
* Note: This is for display purposes only and is not a security boundary.
|
|
* Do not rely on this to sanitize untrusted HTML.
|
|
*
|
|
* @param text - the markdown text to sanitize.
|
|
* @returns the sanitized text, or null if input is null/undefined.
|
|
*/
|
|
public static sanitizeGitLabMarkdown(
|
|
text: string | null | undefined
|
|
): string | null {
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
|
|
// Strip HTML comments repeatedly in case of overlapping patterns like
|
|
// `<!<!-- x -->-- -->` that would leave `<!--` after a single pass.
|
|
let result = text;
|
|
let prev: string;
|
|
do {
|
|
prev = result;
|
|
result = result.replace(/<!--[\s\S]*?-->/g, "");
|
|
} while (result !== prev);
|
|
|
|
return (
|
|
result
|
|
// YAML front matter
|
|
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
|
|
// Collapsible sections: extract inner content
|
|
.replace(
|
|
/<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi,
|
|
"**$1**\n$2"
|
|
)
|
|
// TOC markers
|
|
.replace(/\[\[_TOC_\]\]/g, "")
|
|
.replace(/^\[TOC\]$/gm, "")
|
|
// Inline diffs
|
|
.replace(/\{\+([\s\S]*?)\+\}/g, "$1")
|
|
.replace(/\[-([\s\S]*?)-\]/g, "~~$1~~")
|
|
// Multiline blockquotes
|
|
.replace(/^>>>\s*$/gm, "")
|
|
// Footnote definitions
|
|
.replace(/^\[\^[^\]]+\]:\s+.*$/gm, "")
|
|
// Footnote references
|
|
.replace(/\[\^([^\]]+)\]/g, "")
|
|
// Include directives
|
|
.replace(/^::include\{[^}]*\}$/gm, "")
|
|
// Clean up excessive blank lines left by removals
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim() || null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the color associated with a given visibility level.
|
|
*
|
|
* @param visibility - The visibility level of the resource.
|
|
* @returns The color associated with the visibility level.
|
|
*/
|
|
public static getColorForVisibility(visibility: string): string {
|
|
const visibilityColors: Record<string, string> = {
|
|
public: "#1f75cb",
|
|
internal: "#f8ae1a",
|
|
private: "#848d97",
|
|
};
|
|
|
|
return visibilityColors[visibility] ?? "#848d97";
|
|
}
|
|
}
|