Merge remote-tracking branch 'gitea/develop' into fix/review-batch-2
# Conflicts: # AGENTS.md # CHANGELOG.md # README.md # apps/server/src/collaboration/collaboration.handler.ts # apps/server/src/common/helpers/prosemirror/html-embed.spec.ts # apps/server/src/common/helpers/prosemirror/html-embed.util.ts # apps/server/src/core/ai-chat/public-share-chat.service.ts # apps/server/src/core/ai-chat/public-share-chat.spec.ts # apps/server/src/core/ai-chat/public-share-workspace-limiter.ts # apps/server/src/core/page/services/page.service.ts # apps/server/src/core/page/transclusion/transclusion.service.ts # apps/server/src/integrations/import/services/file-import-task.service.ts # apps/server/src/integrations/import/services/import.service.ts
This commit is contained in:
@@ -1,57 +1,32 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Admin toggle for the workspace HTML embed feature.
|
||||
* Workspace master toggle that enables/disables the HTML embed block type.
|
||||
*
|
||||
* SECURITY: when ON, workspace admins/owners can embed raw HTML/CSS/JS that
|
||||
* EXECUTES in the wiki page origin for every reader (a deliberate stored-XSS
|
||||
* surface, e.g. for analytics trackers). OFF by default. The server strips
|
||||
* htmlEmbed nodes on every write where the toggle is OFF or the saver is not an
|
||||
* admin, so this switch fully enables/disables the feature workspace-wide.
|
||||
* The block renders inside a SANDBOXED iframe (no same-origin access), so it
|
||||
* cannot touch the viewer's session/cookies/API — it is a feature switch, not a
|
||||
* security gate. When ON, ANY member can insert the block. OFF by default; for
|
||||
* anonymous public-share reads the server serves already-stripped content when
|
||||
* the toggle is OFF. The toggle itself is managed by workspace admins.
|
||||
*/
|
||||
export default function HtmlEmbedSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [checked, setChecked] = useState<boolean>(
|
||||
workspace?.settings?.htmlEmbed ?? false,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function handleToggle(value: boolean) {
|
||||
setIsLoading(true);
|
||||
const previous = checked;
|
||||
setChecked(value); // optimistic update
|
||||
try {
|
||||
const updated = await updateWorkspace({ htmlEmbed: value });
|
||||
// Force settings.htmlEmbed to the new value so the atom is consistent even
|
||||
// if the response shape omits it.
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
htmlEmbed: value,
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setChecked(previous); // revert on failure
|
||||
notifications.show({
|
||||
message: t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const ok = await save(value);
|
||||
if (!ok) setChecked(previous); // revert on failure
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -69,7 +44,7 @@ export default function HtmlEmbedSettings() {
|
||||
<Switch
|
||||
label={t("Enable HTML embed")}
|
||||
description={t(
|
||||
"Allow workspace admins to insert raw HTML/CSS/JavaScript that EXECUTES in the wiki page origin for everyone who views the page (a deliberate stored-XSS surface, e.g. for analytics trackers). Off by default.",
|
||||
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
@@ -79,17 +54,17 @@ export default function HtmlEmbedSettings() {
|
||||
<List size="xs" c="dimmed" mt="md" spacing={4}>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Only workspace admins/owners can insert HTML embeds. Members never can: the editor option is hidden for them and the server strips the embed on save at every write path.",
|
||||
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"If a non-admin edits and saves a page that contains an admin's embed, that save strips the embed (fail-closed). An admin must re-add it.",
|
||||
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"Turning this off strips existing embeds on their next save and immediately disables execution (existing embeds render as a disabled placeholder).",
|
||||
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
|
||||
)}
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Admin-only analytics/tracker snippet for public share pages.
|
||||
*
|
||||
* The value is injected VERBATIM into the <head> of PUBLIC SHARE pages only,
|
||||
* in the page's own (same-origin) context. It is the deliberate same-origin
|
||||
* surface for analytics snippets (Google Analytics, Yandex.Metrika, etc.).
|
||||
* Admin only — the workspace settings write is admin-gated server-side, and the
|
||||
* Save button is disabled for non-admins.
|
||||
*/
|
||||
export default function TrackerSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead");
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [value, setValue] = useState<string>(
|
||||
workspace?.settings?.trackerHead ?? "",
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
await save(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Analytics / tracker")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("advanced")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t(
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||
)}
|
||||
</Text>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
aria-label={t("Analytics / tracker")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
placeholder={t("<script>...</script>")}
|
||||
styles={{ input: { fontFamily: "monospace" } }}
|
||||
disabled={!isAdmin || isLoading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={isLoading}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Workspace setting keys that this hook can persist. Each key is both a
|
||||
* write-only field on the update payload and a read field under
|
||||
* `workspace.settings`, so the value type is derived from the settings shape.
|
||||
*/
|
||||
type WorkspaceSettingKey = "htmlEmbed" | "trackerHead";
|
||||
type WorkspaceSettingValue<K extends WorkspaceSettingKey> =
|
||||
NonNullable<IWorkspace["settings"][K]>;
|
||||
|
||||
/**
|
||||
* Shared "save a workspace setting" plumbing extracted from the individual
|
||||
* settings components. Owns the `isLoading` state and the persist-then-merge
|
||||
* flow (call `updateWorkspace`, merge the response back into the workspace atom
|
||||
* while forcing `settings[key]` to the saved value, and surface a success/error
|
||||
* notification). Callers keep their own interaction model (optimistic toggle,
|
||||
* edit-then-save, etc.) on top of this.
|
||||
*/
|
||||
export function useWorkspaceSetting<K extends WorkspaceSettingKey>(key: K) {
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (value: WorkspaceSettingValue<K>): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
[key]: value,
|
||||
} as Partial<IWorkspace>);
|
||||
// Force settings[key] to the new value so the atom is consistent even
|
||||
// if the response shape omits it.
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to update workspace setting "${key}"`, err);
|
||||
notifications.show({
|
||||
message:
|
||||
(err as any)?.response?.data?.message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[key, setWorkspace, t],
|
||||
);
|
||||
|
||||
return { workspace, isLoading, save };
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export interface IWorkspace {
|
||||
// Write-only field for updateWorkspace({ htmlEmbed }). Read state lives at
|
||||
// settings.htmlEmbed.
|
||||
htmlEmbed?: boolean;
|
||||
// Write-only field for updateWorkspace({ trackerHead }). Read state lives at
|
||||
// settings.trackerHead.
|
||||
trackerHead?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
@@ -40,8 +43,13 @@ export interface IWorkspaceSettings {
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
api?: IWorkspaceApiSettings;
|
||||
templates?: IWorkspaceTemplateSettings;
|
||||
// Admin-only HTML embed feature toggle. ABSENT/false => OFF (default).
|
||||
// HTML embed master toggle (enables/disables the block type). The block
|
||||
// renders in a sandboxed iframe, so this is a feature switch, not a security
|
||||
// gate. ABSENT/false => OFF (default).
|
||||
htmlEmbed?: boolean;
|
||||
// Admin-only analytics/tracker snippet injected into the <head> of public
|
||||
// share pages (same-origin). ABSENT/empty => none.
|
||||
trackerHead?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceApiSettings {
|
||||
|
||||
Reference in New Issue
Block a user