Compare commits

..

7 Commits

Author SHA1 Message Date
codegen-sh[bot] 3187619328 fix: Remove unused theme variable in GitLabIssueStatusIcon 2025-08-18 22:32:01 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
57 changed files with 1511 additions and 714 deletions
+4
View File
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
-3
View File
@@ -26,6 +26,3 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
-5
View File
@@ -42,11 +42,6 @@
"value": "true",
"required": true
},
"ALLOW_FILE_PROTOCOL": {
"description": "Allow file:// links in documents. This is a security risk and should only be enabled in self-hosted environments.",
"value": "false",
"required": false
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
+21
View File
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
@@ -49,6 +50,16 @@ export const resolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -71,6 +82,16 @@ export const unresolveCommentFactory = ({
stores.policies.abilities(comment.documentId).update,
perform: async () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
-11
View File
@@ -11,15 +11,9 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -45,11 +39,6 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
+1 -1
View File
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
}
if (isDocumentUrl(navigateTo)) {
const document = documents.get(navigateTo);
const document = documents.getByUrl(navigateTo);
if (document) {
navigateTo = document.path;
}
+4 -35
View File
@@ -1,44 +1,13 @@
import { useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import useStores from "./useStores";
import { useDocumentContext } from "~/components/DocumentContext";
import { useEffect } from "react";
import { useHistory } from "react-router-dom";
/**
* Custom hook to retrieve the currently focused comment in a document.
* It checks both the document context and the query string for the comment ID.
* If a comment is focused, it returns the comment itself or the parent thread if it exists
*/
export function useFocusedComment() {
export default function useFocusedComment() {
const { comments } = useStores();
const context = useDocumentContext();
const location = useLocation<{ commentId?: string }>();
const query = useQuery();
const focusedCommentId = context.focusedCommentId || query.get("commentId");
const focusedCommentId = location.state?.commentId || query.get("commentId");
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
const history = useHistory();
// Move the query string into context
useEffect(() => {
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
context.setFocusedCommentId(focusedCommentId);
}
}, [focusedCommentId, context]);
// Clear query string from location
useEffect(() => {
if (focusedCommentId) {
const params = new URLSearchParams(history.location.search);
if (params.get("commentId") === focusedCommentId) {
params.delete("commentId");
history.replace({
pathname: history.location.pathname,
search: params.toString(),
state: history.location.state,
});
}
}
}, [focusedCommentId, history]);
return comment?.parentCommentId
? comments.get(comment.parentCommentId)
+1 -10
View File
@@ -1,10 +1,9 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback } from "react";
import { Primitive } from "utility-types";
import Storage from "@shared/utils/Storage";
import { isBrowser } from "@shared/utils/browser";
import Logger from "~/utils/Logger";
import useEventListener from "./useEventListener";
import usePrevious from "./usePrevious";
type Options = {
/* Whether to listen and react to changes in the value from other tabs */
@@ -42,7 +41,6 @@ export default function usePersistedState<T extends Primitive | object>(
defaultValue: T,
options?: Options
): [T, (value: T) => void] {
const previousKey = usePrevious(key);
const [storedValue, setStoredValue] = useState(() => {
if (!isBrowser) {
return defaultValue;
@@ -67,13 +65,6 @@ export default function usePersistedState<T extends Primitive | object>(
[key, storedValue]
);
// Sync state when key changes
useEffect(() => {
if (previousKey !== key) {
setStoredValue(Storage.get(key) ?? defaultValue);
}
}, [previousKey, key, defaultValue]);
// Listen to the key changing in other tabs so we can keep UI in sync
useEventListener("storage", (event: StorageEvent) => {
if (options?.listen !== false && event.key === key && event.newValue) {
+2 -1
View File
@@ -75,7 +75,8 @@ const CollectionScene = observer(function _CollectionScene() {
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined = collections.get(id);
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
@@ -1,22 +1,23 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelect, Option } from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";
type Props = {
/** Callback when the sort type changes */
onChange?: (sortType: CommentSortType | "resolved") => void;
/** Whether resolved comments are being viewed */
viewingResolved?: boolean;
};
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();
const preferredSortType = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
@@ -24,23 +25,42 @@ const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: CommentSortType | "resolved") => {
if (val !== "resolved") {
if (val !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
val === CommentSortType.OrderInDocument
);
void user.save();
}
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
}
onChange?.(val);
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
},
[user, onChange, preferredSortType]
[history, location, sidebarContext, user, preferredSortType]
);
const options: Option[] = React.useMemo(
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -16,6 +17,7 @@ import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -49,11 +51,14 @@ function CommentThread({
collapseNumDisplayed = 3,
}: Props) {
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor, setFocusedCommentId } = useDocumentContext();
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const replyRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
@@ -97,7 +102,14 @@ function CommentThread({
!(event.target as HTMLElement).classList.contains("comment") &&
event.defaultPrevented === false
) {
setFocusedCommentId(null);
history.replace({
search: location.search,
pathname: location.pathname,
state: {
commentId: undefined,
sidebarContext,
},
});
}
});
@@ -106,7 +118,15 @@ function CommentThread({
}, [editor, thread.id]);
const handleClickThread = () => {
setFocusedCommentId(thread.id);
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
search: thread.isResolved ? "resolved=" : "",
pathname: location.pathname.replace(/\/history$/, ""),
state: {
commentId: thread.id,
sidebarContext,
},
});
};
const handleClickExpand = (ev: React.SyntheticEvent) => {
@@ -30,7 +30,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
import { HighlightedText } from "./HighlightText";
import { useDocumentContext } from "~/components/DocumentContext";
/**
* Hook to calculate if we should display a timestamp on a comment
@@ -112,7 +111,6 @@ function CommentThreadItem({
onEditStart,
onEditEnd,
}: Props) {
const { setFocusedCommentId } = useDocumentContext();
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
@@ -156,9 +154,6 @@ function CommentThreadItem({
const handleUpdate = React.useCallback(
(attrs: { resolved: boolean }) => {
onUpdate?.(comment.id, attrs);
if ("resolved" in attrs) {
setFocusedCommentId(null);
}
},
[comment.id, onUpdate]
);
+6 -19
View File
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
@@ -31,13 +31,11 @@ function Comments() {
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const document = documents.get(match.params.documentSlug);
const params = useQuery();
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
const query = useQuery();
const [viewingResolved, setViewingResolved] = useState(
query.get("resolved") !== null || focusedComment?.isResolved || false
);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const prevThreadCount = useRef(0);
const isAtBottom = useRef(true);
@@ -45,13 +43,6 @@ function Comments() {
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
// Account for the resolved status of the comment changing
useEffect(() => {
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
setViewingResolved(focusedComment.isResolved);
}
}, [focusedComment, viewingResolved]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document?.id}-new`,
undefined
@@ -66,6 +57,7 @@ function Comments() {
}
: { type: CommentSortType.MostRecent };
const viewingResolved = params.get("resolved") === "";
const threads = !document
? []
: viewingResolved
@@ -132,12 +124,7 @@ function Comments() {
title={
<Flex align="center" justify="space-between" auto>
<span>{t("Comments")}</span>
<CommentSortMenu
viewingResolved={viewingResolved}
onChange={(val) => {
setViewingResolved(val === "resolved");
}}
/>
<CommentSortMenu />
</Flex>
}
onClose={() => ui.set({ commentsExpanded: false })}
@@ -67,7 +67,9 @@ function DataLoader({ match, children }: Props) {
const { revisionId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document = documents.get(match.params.documentSlug);
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
if (document) {
setDocument(document);
+30 -9
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useRouteMatch } from "react-router-dom";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { richExtensions, withComments } from "@shared/editor/nodes";
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useFocusedComment } from "~/hooks/useFocusedComment";
import useFocusedComment from "~/hooks/useFocusedComment";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
@@ -55,15 +55,15 @@ type Props = Omit<EditorProps, "editorStyle"> & {
* The main document editor includes an editable title with metadata below it,
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
const { setFocusedCommentId } = useDocumentContext();
const focusedComment = useFocusedComment();
const { ui, comments } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const params = useQuery();
const {
@@ -95,11 +95,18 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
setFocusedCommentId(focusedComment.id);
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: {
commentId: focusedComment.id,
sidebarContext,
},
});
}
ui.set({ commentsExpanded: true });
}
}, [focusedComment, ui, document.id, params]);
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
@@ -120,6 +127,16 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[focusAtStart, ref]
);
const handleClickComment = React.useCallback(
(commentId: string) => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
},
[history, sidebarContext]
);
// Create a Comment model in local store when a comment mark is created, this
// acts as a local draft before submission.
const handleDraftComment = React.useCallback(
@@ -139,9 +156,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
);
comment.id = commentId;
comments.add(comment);
setFocusedCommentId(commentId);
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId, sidebarContext },
});
},
[comments, user?.id, props.id]
[comments, user?.id, props.id, history, sidebarContext]
);
// Soft delete the Comment model when associated mark is totally removed.
@@ -237,7 +258,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
userId={user?.id}
focusedCommentId={focusedComment?.id}
onClickCommentMark={
commentingEnabled && can.comment ? setFocusedCommentId : undefined
commentingEnabled && can.comment ? handleClickComment : undefined
}
onCreateCommentMark={
commentingEnabled && can.comment ? handleDraftComment : undefined
+1 -1
View File
@@ -166,7 +166,7 @@ function DocumentHeader({
);
useKeyDown(
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
(event) => event.ctrlKey && event.altKey && event.key === "˙",
handleToggle,
{
allowInInput: true,
+1 -1
View File
@@ -34,7 +34,7 @@ function History() {
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.get(match.params.documentSlug);
const document = documents.getByUrl(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
@@ -51,7 +51,10 @@ type MessageEvent = {
};
};
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
function MultiplayerEditor(
{ onSynced, ...props }: Props,
ref: React.Ref<unknown>
) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
-15
View File
@@ -361,21 +361,6 @@ function Security() {
onChange={handleDocumentEmbedsChange}
/>
</SettingRow>
{!isCloudHosted && (
<SettingRow
label={t("Allow file protocol")}
name="allowFileProtocol"
description={t(
"To allow file:// links in documents, set the ALLOW_FILE_PROTOCOL=true environment variable and restart the server."
)}
>
<Switch
id="allowFileProtocol"
checked={env.ALLOW_FILE_PROTOCOL === "true"}
disabled
/>
</SettingRow>
)}
<SettingRow
label={t("Collection creation")}
name="memberCollectionCreate"
+6 -1
View File
@@ -1,4 +1,5 @@
import invariant from "invariant";
import find from "lodash/find";
import isEmpty from "lodash/isEmpty";
import orderBy from "lodash/orderBy";
import sortBy from "lodash/sortBy";
@@ -185,7 +186,7 @@ export default class CollectionsStore extends Store<Collection> {
statusFilter: [CollectionStatusFilter.Archived],
});
get(id: string = ""): Collection | undefined {
get(id: string): Collection | undefined {
return (
this.data.get(id) ??
this.orderedData.find((collection) => id.endsWith(collection.urlId))
@@ -241,6 +242,10 @@ export default class CollectionsStore extends Store<Collection> {
return this.orderedData.map((collection) => collection.asNavigationNode);
}
getByUrl(url: string): Collection | null | undefined {
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
}
async delete(collection: Collection) {
await super.delete(collection);
await this.rootStore.documents.fetchRecentlyUpdated();
+8 -1
View File
@@ -1,6 +1,7 @@
import invariant from "invariant";
import compact from "lodash/compact";
import filter from "lodash/filter";
import find from "lodash/find";
import omitBy from "lodash/omitBy";
import orderBy from "lodash/orderBy";
import { observable, action, computed, runInAction } from "mobx";
@@ -459,7 +460,7 @@ export default class DocumentsStore extends Store<Document> {
@action
prefetchDocument = async (id: string) => {
if (!this.get(id)) {
if (!this.data.get(id) && !this.getByUrl(id)) {
return this.fetch(id, {
prefetch: true,
});
@@ -745,6 +746,12 @@ export default class DocumentsStore extends Store<Document> {
return subscription?.delete();
};
getByUrl = (url = ""): Document | undefined =>
find(
this.orderedData,
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
);
getCollectionForDocument(document: Document) {
return document.collectionId
? this.rootStore.collections.get(document.collectionId)
+7
View File
@@ -34,6 +34,13 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.Linear
);
}
@computed
get gitlab(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.GitLab
);
}
}
export default IntegrationsStore;
+4 -4
View File
@@ -47,7 +47,7 @@ class ApiClient {
this.shareId = shareId;
};
fetch = async <T = any>(
fetch = async <T = unknown>(
path: string,
method: string,
data: JSONObject | FormData | undefined,
@@ -180,7 +180,7 @@ class ApiClient {
const error: {
message?: string;
error?: string;
data?: Record<string, any>;
data?: Record<string, unknown>;
} = {};
try {
@@ -244,13 +244,13 @@ class ApiClient {
throw err;
};
get = <T = any>(
get = <T = unknown>(
path: string,
data: JSONObject | undefined,
options?: FetchOptions
) => this.fetch<T>(path, "GET", data, options);
post = <T = any>(
post = <T = unknown>(
path: string,
data?: JSONObject | FormData | undefined,
options?: FetchOptions
+1 -1
View File
@@ -13,7 +13,7 @@ type LogCategory =
| "plugins"
| "policies";
type Extra = Record<string, any>;
type Extra = Record<string, unknown>;
class Logger {
/**
+20
View File
@@ -37,6 +37,16 @@ export const isURLMentionable = ({
);
}
case IntegrationService.GitLab: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "gitlab.com" &&
settings.gitlab?.project.path_with_namespace === pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
);
}
default:
return false;
}
@@ -67,6 +77,16 @@ export const determineMentionType = ({
return type === "issue" ? MentionType.Issue : undefined;
}
case IntegrationService.GitLab: {
const type = pathParts[pathParts.length - 2];
if (type === "issues") {
return MentionType.Issue;
} else if (type === "merge_requests") {
return MentionType.PullRequest;
}
return undefined;
}
default:
return;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
export function commentPath(document: Document, comment: Comment): string {
return `${documentPath(document)}?commentId=${comment.id}${
comment.isResolved ? "&resolved=1" : ""
comment.isResolved ? "&resolved=" : ""
}`;
}
+28 -28
View File
@@ -51,17 +51,17 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.873.0",
"@aws-sdk/lib-storage": "3.873.0",
"@aws-sdk/s3-presigned-post": "3.873.0",
"@aws-sdk/s3-request-presigner": "3.873.0",
"@aws-sdk/signature-v4-crt": "^3.873.0",
"@babel/core": "^7.28.3",
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/lib-storage": "3.864.0",
"@aws-sdk/s3-presigned-post": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@aws-sdk/signature-v4-crt": "^3.864.0",
"@babel/core": "^7.27.7",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-regenerator": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/plugin-transform-regenerator": "^7.28.1",
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
@@ -70,13 +70,13 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dotenvx/dotenvx": "^1.49.0",
"@dotenvx/dotenvx": "^1.48.4",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@fortawesome/react-fontawesome": "^0.2.3",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-redis": "1.1.2",
"@hocuspocus/extension-throttle": "1.1.2",
@@ -84,22 +84,22 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@linear/sdk": "^39.2.1",
"@linear/sdk": "^39.0.0",
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/webhooks": "^13.9.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-one-time-password-field": "^0.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
@@ -123,11 +123,11 @@
"content-disposition": "^0.5.4",
"cookie": "^0.7.0",
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.45.1",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"date-fns": "^3.6.0",
"dd-trace": "^5.63.0",
"dd-trace": "^5.62.0",
"diff": "^5.2.0",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
@@ -170,7 +170,7 @@
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.10.0",
"mermaid": "11.9.0",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -258,14 +258,14 @@
"tmp": "^0.2.4",
"tunnel-agent": "^0.6.0",
"turndown": "^7.2.0",
"ukkonen": "^2.2.0",
"ukkonen": "^2.1.0",
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.15",
"vaul": "^1.1.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-pwa": "1.0.3",
"vite-plugin-pwa": "^1.0.2",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -276,7 +276,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/cli": "^7.28.0",
"@babel/preset-typescript": "^7.27.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
@@ -351,7 +351,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.20",
"discord-api-types": "^0.37.119",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.9.0",
@@ -365,7 +365,7 @@
"prettier": "^3.6.2",
"react-refresh": "^0.17.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "2.1.3",
"rollup-plugin-webpack-stats": "^2.1.3",
"terser": "^5.43.1",
"typescript": "^5.9.2",
"yarn-deduplicate": "^6.0.2"
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.3"
/>
<path
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.7"
/>
</svg>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GitLabIcon from "./Icon";
import { GitLabConnectButton } from "./components/GitLabButton";
function GitLab() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.GitLab,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="GitLab" icon={<GitLabIcon />}>
<Heading>GitLab</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in GitLab to connect{" "}
{{ appName }} to your project. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.GITLAB_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of GitLab issues and merge requests in documents by connecting a
GitLab project to {appName}.
</Trans>
</Text>
{integrations.gitlab.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<GitLabConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.gitlab.map((integration) => {
const gitlabProject =
integration.settings?.gitlab?.project;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={gitlabProject?.id}
small
title={gitlabProject?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={gitlabProject?.avatar_url}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<GitLabConnectButton icon={<GitLabIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The GitLab integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(GitLab);
@@ -0,0 +1,24 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { GitLabUtils } from "../../shared/GitLabUtils";
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+8
View File
@@ -0,0 +1,8 @@
{
"id": "gitlab",
"name": "GitLab",
"priority": 16,
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+100
View File
@@ -0,0 +1,100 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitLab } from "../gitlab";
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
import * as T from "./schema";
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
const router = new Router();
router.get(
"gitlab.callback",
auth({
optional: true,
}),
validate(T.GitLabCallbackSchema),
apexAuthRedirect<T.GitLabCallbackReq>({
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
getRedirectPath: (ctx, team) =>
GitLabUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
}),
getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"),
}),
transaction(),
async (ctx: APIContext<T.GitLabCallbackReq>) => {
const { code, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(GitLabUtils.errorUrl(error));
return;
}
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await GitLab.oauthAccess(code!);
const project = await GitLab.getInstalledProject(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.GitLab,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.GitLab,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
gitlab: {
project: {
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
if (project.avatar_url) {
await new UploadGitLabProjectAvatarTask().schedule({
integrationId: integration.id,
avatarUrl: project.avatar_url,
});
}
});
ctx.redirect(GitLabUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during GitLab OAuth callback", err);
ctx.redirect(GitLabUtils.errorUrl("unknown"));
}
}
);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const GitLabCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
+7
View File
@@ -0,0 +1,7 @@
import env from "@server/env";
export default {
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
};
+273
View File
@@ -0,0 +1,273 @@
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
import { GitLabUtils } from "../shared/GitLabUtils";
import env from "./env";
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
scope: z.string(),
created_at: z.number(),
});
const GitLabProjectSchema = z.object({
id: z.string(),
name: z.string(),
path_with_namespace: z.string(),
avatar_url: z.string().optional(),
});
const GitLabIssueSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
});
const GitLabMergeRequestSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
draft: z.boolean().optional(),
});
export class GitLab {
private static supportedUnfurls = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("redirect_uri", GitLabUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(GitLabUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while exchanging oauth code from GitLab; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("token", accessToken);
await fetch(GitLabUtils.revokeUrl, {
method: "POST",
headers,
body,
});
}
static async getInstalledProject(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
// Get the first project the user has access to
// In a real implementation, we would want to let the user select which project to connect
const res = await fetch(
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
{
headers,
}
);
if (res.status !== 200) {
throw new Error(
`Error while fetching GitLab projects; status: ${res.status}`
);
}
const projects = await res.json();
if (!projects.length) {
throw new Error("No GitLab projects found");
}
return GitLabProjectSchema.parse(projects[0]);
}
/**
*
* @param url GitLab resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitLab issue or merge request details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = GitLab.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.GitLab,
teamId: actor.teamId,
"settings.gitlab.project.path_with_namespace": resource.projectPath,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const headers = {
Authorization: `Bearer ${integration.authentication.token}`,
Accept: "application/json",
};
let apiUrl: string;
let resourceSchema: z.ZodObject<z.ZodRawShape>;
let resourceType: UnfurlResourceType;
if (resource.type === "issues") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
resourceSchema = GitLabIssueSchema;
resourceType = UnfurlResourceType.Issue;
} else if (resource.type === "merge_requests") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
resourceSchema = GitLabMergeRequestSchema;
resourceType = UnfurlResourceType.PR;
} else {
return;
}
const res = await fetch(apiUrl, { headers });
if (res.status !== 200) {
return { error: `Resource not found (${res.status})` };
}
const data = resourceSchema.parse(await res.json());
// Fetch labels if they exist
let labels = [];
if (data.labels && data.labels.length > 0) {
labels = data.labels.map((label) => ({
name: label,
color: "#428BCA", // Default GitLab blue
}));
}
return {
type: resourceType,
url,
id: `#${data.iid}`,
title: data.title,
description: data.description,
author: {
name: data.author.name,
avatarUrl: data.author.avatar_url || "",
},
labels,
state: {
name: data.state,
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
draft:
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
},
createdAt: data.created_at,
} satisfies UnfurlIssueOrPR;
} catch (err) {
Logger.warn("Failed to fetch resource from GitLab", err);
return { error: err.message || "Unknown error" };
}
};
/**
* Parses a given URL and returns resource identifiers for GitLab specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "gitlab.com") {
return;
}
const parts = pathname.split("/");
// Remove empty first element
parts.shift();
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
if (parts.length < 4) {
return;
}
// Find the index of "-" which separates project path from resource type
const separatorIndex = parts.indexOf("-");
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
return;
}
const projectPath = parts.slice(0, separatorIndex).join("/");
const type = parts[separatorIndex + 1];
const id = parts[separatorIndex + 2];
if (
!type ||
!id ||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
) {
return;
}
return { projectPath, type, id };
}
}
@@ -0,0 +1,56 @@
import { IntegrationType } from "@shared/types";
import BaseTask from "@server/queues/tasks/BaseTask";
import { Integration } from "@server/models";
import { FileOperation } from "@server/models";
import fetch from "node-fetch";
type Props = {
integrationId: string;
avatarUrl: string;
};
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
public async perform({ integrationId, avatarUrl }: Props) {
const integration = await Integration.findByPk(integrationId, {
rejectOnEmpty: true,
});
try {
const res = await fetch(avatarUrl);
const buffer = await res.buffer();
const name = avatarUrl.split("/").pop() || "avatar";
const contentType = res.headers.get("content-type") || "image/png";
const operation = await FileOperation.createFromBuffer({
buffer,
contentType,
name,
userId: integration.userId,
teamId: integration.teamId,
source: "gitlab",
});
await integration.update({
settings: {
...integration.settings,
gitlab: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab,
project: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab?.project,
avatar_url: operation.url,
},
},
},
});
} catch (err) {
// If the avatar upload fails, we don't need to fail the entire task
// as it's not critical to the integration's functionality.
// Just log the error and continue.
this.logger.error(
`Failed to upload GitLab project avatar: ${err.message}`
);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class GitLabUtils {
private static oauthScopes = "api read_api read_user read_repository";
public static tokenUrl = "https://gitlab.com/oauth/token";
public static revokeUrl = "https://gitlab.com/oauth/revoke";
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
private static settingsUrl = integrationSettingsPath("gitlab");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/gitlab.callback?${params}`
: `${baseUrl}/api/gitlab.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.GITLAB_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OAuth2Strategy, { Strategy } from "passport-oauth2";
import { Request } from "express";
export class OIDCStrategy extends Strategy {
constructor(
@@ -14,12 +15,12 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: any, options: any) {
authenticate(req: Request, options: Record<string, unknown>) {
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: any) {
authorizationParams(options: Record<string, unknown>) {
return {
...options.originalQuery,
...super.authorizationParams?.(options),
+2 -2
View File
@@ -6,7 +6,7 @@ import env from "./env";
const SLACK_API_URL = "https://slack.com/api";
export async function post(endpoint: string, body: Record<string, any>) {
export async function post(endpoint: string, body: Record<string, unknown>) {
let data;
const token = body.token;
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, any>) {
return data;
}
export async function request(endpoint: string, body: Record<string, any>) {
export async function request(endpoint: string, body: Record<string, unknown>) {
let data;
try {
+1 -1
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState(
teamId: string,
type: IntegrationType,
data?: Record<string, any>
data?: Record<string, unknown>
) {
return JSON.stringify({ type, teamId, ...data });
}
+2 -14
View File
@@ -78,7 +78,7 @@ export class Environment {
/**
* The url of the database.
*/
@IsOptional()
@IsNotEmpty()
@IsUrl({
require_tld: false,
allow_underscores: true,
@@ -91,7 +91,7 @@ export class Environment {
"DATABASE_USER",
"DATABASE_PASSWORD",
])
public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);
public DATABASE_URL = environment.DATABASE_URL ?? "";
/**
* Database host for individual component configuration.
@@ -793,18 +793,6 @@ export class Environment {
public get isTest() {
return this.ENVIRONMENT === "test";
}
/**
* Allow file:// protocol links in the editor. This is a security risk and should
* only be enabled in self-hosted environments where you trust your users.
* This is useful for companies with a local NAS.
*/
@Public
@IsBoolean()
@IsOptional()
public ALLOW_FILE_PROTOCOL = this.toBoolean(
environment.ALLOW_FILE_PROTOCOL ?? "false"
);
protected toOptionalString(value: string | undefined) {
return value ? value : undefined;
+2 -2
View File
@@ -14,7 +14,7 @@ export enum TaskSchedule {
Minute = "minute",
}
export default abstract class BaseTask<T extends Record<string, any>> {
export default abstract class BaseTask<T extends Record<string, unknown>> {
/**
* An optional schedule for this task to be run automatically.
*/
@@ -43,7 +43,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
* @param props Properties to be used by the task
* @returns A promise that resolves once the task has completed.
*/
public abstract perform(props: T): Promise<any>;
public abstract perform(props: T): Promise<unknown>;
/**
* Handle failure when all attempts are exhausted for the task.
+1 -1
View File
@@ -3,7 +3,7 @@ import BaseTask from "./BaseTask";
type Props = {
templateName: string;
props: Record<string, any>;
props: Record<string, unknown>;
};
export default class EmailTask extends BaseTask<Props> {
+27 -44
View File
@@ -72,35 +72,6 @@ export default class ExportJSONTask extends ExportTask {
attachments: {},
};
async function addAttachments(attachments: Attachment[]) {
await Promise.all(
attachments.map(async (attachment) => {
zip.file(
attachment.key,
new Promise<Buffer>((resolve) => {
attachment.buffer.then(resolve).catch((err) => {
Logger.warn(`Failed to read attachment from storage`, {
attachmentId: attachment.id,
teamId: attachment.teamId,
error: err.message,
});
resolve(Buffer.from(""));
});
}),
{
date: attachment.updatedAt,
createFolders: true,
}
);
output.attachments[attachment.id] = {
...omit(presentAttachment(attachment), "url"),
key: attachment.key,
};
})
);
}
async function addDocumentTree(nodes: NavigationNode[]) {
for (const node of nodes) {
const document = await Document.findByPk(node.id, {
@@ -111,7 +82,7 @@ export default class ExportJSONTask extends ExportTask {
continue;
}
const documentAttachments = includeAttachments
const attachments = includeAttachments
? await Attachment.findAll({
where: {
teamId: document.teamId,
@@ -122,7 +93,32 @@ export default class ExportJSONTask extends ExportTask {
})
: [];
await addAttachments(documentAttachments);
await Promise.all(
attachments.map(async (attachment) => {
zip.file(
attachment.key,
new Promise<Buffer>((resolve) => {
attachment.buffer.then(resolve).catch((err) => {
Logger.warn(`Failed to read attachment from storage`, {
attachmentId: attachment.id,
teamId: attachment.teamId,
error: err.message,
});
resolve(Buffer.from(""));
});
}),
{
date: attachment.updatedAt,
createFolders: true,
}
);
output.attachments[attachment.id] = {
...omit(presentAttachment(attachment), "url"),
key: attachment.key,
};
})
);
output.documents[document.id] = {
id: document.id,
@@ -150,19 +146,6 @@ export default class ExportJSONTask extends ExportTask {
}
}
const collectionAttachments = includeAttachments
? await Attachment.findAll({
where: {
teamId: collection.teamId,
id: ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(collection)
),
},
})
: [];
await addAttachments(collectionAttachments);
if (collection.documentStructure) {
await addDocumentTree(collection.documentStructure);
}
@@ -825,7 +825,7 @@ describe("#documents.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(2);
const docIds = body.data.map((doc: any) => doc.id);
const docIds = body.data.map((doc: { id: string }) => doc.id);
expect(docIds).toContain(docs[0].id);
expect(docIds).toContain(docs[1].id);
expect(docIds).not.toContain(docs[2].id);
@@ -5361,7 +5361,7 @@ describe("#documents.documents", () => {
expect(res.status).toBe(200);
expect(body.data.id).toBe(parent.id);
const childIds = body.data.children.map((node: any) => node.id);
const childIds = body.data.children.map((node: { id: string }) => node.id);
expect(childIds).toContain(child1.id);
expect(childIds).toContain(child2.id);
});
@@ -153,7 +153,9 @@ describe("#relationships.info", () => {
expect(body.data.relationship).toBeTruthy();
expect(body.data.documents).toHaveLength(2);
// User can read their own document but admin document should also be included
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(userDocument.id);
});
@@ -170,7 +172,9 @@ describe("#relationships.info", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents).toHaveLength(2);
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(document.id);
expect(documentIds).toContain(reverseDocument.id);
});
@@ -265,7 +269,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should be backlinks
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { type: string }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
});
});
@@ -283,7 +287,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified documentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { documentId: string }) => {
expect(rel.documentId).toEqual(documents[0].id);
});
});
@@ -301,7 +305,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified reverseDocumentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { reverseDocumentId: string }) => {
expect(rel.reverseDocumentId).toEqual(documents[1].id);
});
});
@@ -320,10 +324,12 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should match both filters
body.data.relationships.forEach((rel: any) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
});
body.data.relationships.forEach(
(rel: { type: string; documentId: string }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
}
);
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe("#searches.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(3);
const queries = body.data.map((d: any) => d.query);
const queries = body.data.map((d: { query: string }) => d.query);
expect(queries).toContain("query");
expect(queries).toContain("foo");
expect(queries).toContain("bar");
@@ -0,0 +1,34 @@
import * as React from "react";
import { isSafari } from "../../utils/browser";
import { BaseIconProps } from ".";
/** Renders an icon for a specific GitLab issue state */
export function GitLabIssueStatusIcon(props: BaseIconProps) {
const { state } = props;
const isOpen = state.name === "opened";
const color = state.color || (isOpen ? "#1aaa55" : "#db3b21"); // Green for open, red for closed
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ marginTop: isSafari() ? 0 : -2 }}
>
<circle cx="8" cy="8" r="7" stroke={color} strokeWidth="2" fill="none" />
{!isOpen && (
<path
d="M4.5 4.5L11.5 11.5M4.5 11.5L11.5 4.5"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
)}
{state.draft && (
<rect x="4" y="7" width="8" height="2" rx="1" fill={color} />
)}
</svg>
);
}
@@ -7,6 +7,7 @@ import {
UnfurlResponse,
} from "../../types";
import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon";
import { GitLabIssueStatusIcon } from "./GitLabIssueStatusIcon";
import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
export type BaseIconProps = {
@@ -33,6 +34,8 @@ function getIcon(props: Props) {
return <GitHubIssueStatusIcon {...props} />;
case IntegrationService.Linear:
return <LinearIssueStatusIcon {...props} />;
case IntegrationService.GitLab:
return <GitLabIssueStatusIcon {...props} />;
}
}
-1
View File
@@ -120,7 +120,6 @@ export default class Mention extends Node {
toPlainText(node),
],
toPlainText,
leafText: toPlainText,
};
}
@@ -1182,6 +1182,10 @@
"Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}",
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?",
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?": "Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?",
"Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.": "Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.",
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?": "Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?",
"The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Google Analytics": "Google Analytics",
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.",
"Measurement ID": "Measurement ID",
+6 -1
View File
@@ -126,6 +126,7 @@ export enum IntegrationService {
Umami = "umami",
GitHub = "github",
Linear = "linear",
GitLab = "gitlab",
Notion = "notion",
}
@@ -140,12 +141,13 @@ export const ImportableIntegrationService = {
export type IssueTrackerIntegrationService = Extract<
IntegrationService,
IntegrationService.GitHub | IntegrationService.Linear
IntegrationService.GitHub | IntegrationService.Linear | IntegrationService.GitLab
>;
export const IssueTrackerIntegrationService = {
GitHub: IntegrationService.GitHub,
Linear: IntegrationService.Linear,
GitLab: IntegrationService.GitLab,
} as const;
export type UserCreatableIntegrationService = Extract<
@@ -189,6 +191,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
linear?: {
workspace: { id: string; name: string; key: string; logoUrl?: string };
};
gitlab?: {
project: { id: string; name: string; path_with_namespace: string; avatar_url?: string };
};
}
: T extends IntegrationType.Analytics
? { measurementId: string; instanceUrl?: string; scriptName?: string }
+2 -2
View File
@@ -14,7 +14,7 @@ const stripEmojis = (value: string) => value.replace(regex, "");
const cleanValue = (value: string) => stripEmojis(deburr(value));
function getSortByField<T extends Record<string, any>>(
function getSortByField<T extends Record<string, unknown>>(
item: T,
keyOrCallback: string | ((item: T) => string)
) {
@@ -25,7 +25,7 @@ function getSortByField<T extends Record<string, any>>(
return cleanValue(field);
}
function naturalSortBy<T extends Record<string, any>>(
function naturalSortBy<T extends Record<string, unknown>>(
items: T[],
key: string | ((item: T) => string),
sortOptions?: NaturalSortOptions
+3 -15
View File
@@ -113,21 +113,9 @@ export function isUrl(
try {
const url = new URL(text);
// Define protocols that are always blocked
const alwaysBlockedProtocols = ["javascript:", "vbscript:", "data:"];
// Define protocols that can be conditionally allowed
const conditionallyBlockedProtocols = ["file:"];
// Check if file:// links are allowed via environment variable
const allowFileProtocol = env.ALLOW_FILE_PROTOCOL === "true";
// Block always-blocked protocols
if (alwaysBlockedProtocols.includes(url.protocol)) {
return false;
}
// Block conditionally-blocked protocols if not explicitly allowed
if (conditionallyBlockedProtocols.includes(url.protocol) && !allowFileProtocol) {
const blockedProtocols = ["javascript:", "file:", "vbscript:", "data:"];
if (blockedProtocols.includes(url.protocol)) {
return false;
}
if (url.hostname) {
+432 -437
View File
File diff suppressed because it is too large Load Diff