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:
claude code agent 227
2026-06-21 05:32:44 +03:00
65 changed files with 1448 additions and 2927 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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 {