{
title={t("application:navbar.taskQueue")}
/>
{tasks.map((task) => (
-
+
))}
{nextPageToken != undefined && (
diff --git a/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx b/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx
index 9f86107d..143d8148 100644
--- a/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx
+++ b/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx
@@ -27,6 +27,8 @@ export interface TaskSummaryStatusProps {
summary?: TaskSummary;
error?: string;
simplified?: boolean;
+ // Live transfer-phase progress percent for remote downloads.
+ transferPercent?: number;
}
interface TaskStatusContentProps {
@@ -46,7 +48,7 @@ const TaskStatusContent = forwardRef(({ icon, title, color, ...props }: TaskStat
);
});
-const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSummaryStatusProps) => {
+const TaskSummaryStatus = ({ type, status, summary, error, simplified, transferPercent }: TaskSummaryStatusProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const theme = useTheme();
@@ -111,7 +113,11 @@ const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSum
} else if (summary?.phase == "transfer") {
return (
}
color={theme.palette.primary.main}
/>
diff --git a/frontend/src/component/Uploader/Popup/UploadTask.tsx b/frontend/src/component/Uploader/Popup/UploadTask.tsx
index 11385594..831fe21b 100644
--- a/frontend/src/component/Uploader/Popup/UploadTask.tsx
+++ b/frontend/src/component/Uploader/Popup/UploadTask.tsx
@@ -5,11 +5,13 @@ import MuiAccordionSummary from "@mui/material/AccordionSummary";
import Chip from "@mui/material/Chip";
import { lighten, useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
+import dayjs from "dayjs";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileType } from "../../../api/explorer.ts";
import { navigateToPath } from "../../../redux/thunks/filemanager.ts";
import { sizeToString } from "../../../util";
+import { formatDuration } from "../../../util/datetime.ts";
import { NoWrapBox } from "../../Common/StyledComponents.tsx";
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx";
import ArrowClockwiseFilled from "../../Icons/ArrowClockwiseFilled.tsx";
@@ -70,6 +72,16 @@ const getSpeedText = (speed: number, speedAvg: number, useSpeedAvg: boolean) =>
return `${sizeToString(displayedSpeed ? displayedSpeed : 0)}/s`;
};
+// ETA from remaining bytes over the displayed speed; empty when speed is
+// unknown or zero.
+const getEtaText = (speed: number, speedAvg: number, useSpeedAvg: boolean, loaded: number, total: number) => {
+ const displayedSpeed = useSpeedAvg ? speedAvg : speed;
+ if (!displayedSpeed || displayedSpeed <= 0 || total <= loaded) {
+ return "";
+ }
+ return formatDuration(dayjs.duration(Math.ceil((total - loaded) / displayedSpeed), "seconds"));
+};
+
const getErrMsg = (error?: Error) => {
if (error) {
let errMsg = error.message;
@@ -165,6 +177,7 @@ export default function UploadTask({
);
case Status.processing:
if (progress) {
+ const eta = getEtaText(speed, speedAvg, useAvgSpeed, progress.total.loaded, progress.total.size);
return (
{t("progressDescriptionFull", {
@@ -172,6 +185,7 @@ export default function UploadTask({
uploaded: sizeToString(progress.total.loaded),
total: sizeToString(progress.total.size),
percentage: progress.total.percent.toFixed(2),
+ eta: eta ? t("etaSuffix", { eta }) : "",
})}
);
diff --git a/frontend/src/component/Uploader/core/index.ts b/frontend/src/component/Uploader/core/index.ts
index 3d3021eb..5ccbeddf 100644
--- a/frontend/src/component/Uploader/core/index.ts
+++ b/frontend/src/component/Uploader/core/index.ts
@@ -169,11 +169,22 @@ export default class UploadManager {
throw new UploaderError(UploaderErrorName.NoPolicySelected, "No policy selected.");
}
- this.fileInput.onchange = (ev: Event) => this.addFiles(ev, dst, resolve, reject);
- this.directoryInput.onchange = (ev: Event) => this.addFiles(ev, dst, resolve, reject);
- this.fileInput.value = "";
- this.directoryInput.value = "";
- type == SelectType.File ? this.fileInput.click() : this.directoryInput.click();
+ const input = type == SelectType.File ? this.fileInput : this.directoryInput;
+ // Some mobile pickers deliver multi-selections via several `change`
+ // events; the first resolve would swallow the rest. Accumulate and
+ // resolve once the picker goes quiet (#3571).
+ let files: File[] = [];
+ let timer: ReturnType | undefined;
+ input.onchange = (ev: Event) => {
+ const target = ev.target as HTMLInputElement;
+ if (target?.files?.length) {
+ files = files.concat(Array.from(target.files));
+ }
+ clearTimeout(timer);
+ timer = setTimeout(() => this.addFiles(files, dst, resolve, reject), 400);
+ };
+ input.value = "";
+ input.click();
});
};
diff --git a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx
index c45cb94d..294710e6 100644
--- a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx
+++ b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx
@@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { confirmOperation } from "../../../redux/thunks/dialog.ts";
import { getEntityContent } from "../../../redux/thunks/file.ts";
import { saveCode } from "../../../redux/thunks/viewer.ts";
+import SessionManager, { UserSettings } from "../../../session/index.ts";
import { fileExtension } from "../../../util";
import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx";
import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
@@ -107,7 +108,9 @@ const CodeViewer = () => {
const [anchorEl, setAnchorEl] = useState(null);
const [optionAnchorEl, setOptionAnchorEl] = useState(null);
const [language, setLng] = useState(null);
+ const [charsetState, setCharsetState] = useState(undefined);
const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off");
+ const [eol, setEol] = useState<"LF" | "CRLF">("LF");
const saveFunction = useRef<() => void>(() => {});
const closeViewer = useCallback(() => {
@@ -137,11 +140,16 @@ const CodeViewer = () => {
return;
}
+ setCharsetState(charset);
setLoaded(false);
setOptionAnchorEl(null);
dispatch(getEntityContent(viewerState.file, viewerState.version))
.then((res) => {
- setValue(new TextDecoder(charset).decode(res));
+ const content = new TextDecoder(charset).decode(res);
+ setValue(content);
+ // Preserve the file's existing line endings as the default
+ // (#3009): CRLF files keep CRLF, everything else uses LF.
+ setEol(content.includes("\r\n") ? "CRLF" : "LF");
setLoaded(true);
})
.catch(() => {
@@ -151,15 +159,37 @@ const CodeViewer = () => {
[viewerState, closeViewer],
);
+ // Remember charset/language picks per extension (#2249).
+ const currentExt = fileExtension(viewerState?.file?.name ?? "") ?? "";
+
+ const pickCharset = useCallback(
+ (charset: string) => {
+ SessionManager.set(UserSettings.CodeCharsetPrefix + currentExt, charset);
+ loadContent(charset);
+ },
+ [currentExt, loadContent],
+ );
+
+ const pickLanguage = useCallback(
+ (l: string) => {
+ SessionManager.set(UserSettings.CodeLanguagePrefix + currentExt, l);
+ setLng(l);
+ },
+ [currentExt],
+ );
+
useEffect(() => {
if (!viewerState || !viewerState.open) {
return;
}
+ const ext = fileExtension(viewerState.file.name) ?? "";
+ const rememberedCharset = SessionManager.get(UserSettings.CodeCharsetPrefix + ext);
+ const rememberedLng = SessionManager.get(UserSettings.CodeLanguagePrefix + ext);
setLng(null);
setSaved(true);
- setLng(codePreviewSuffix[fileExtension(viewerState.file.name) ?? ""] ?? "");
- loadContent();
+ setLng(rememberedLng ?? codePreviewSuffix[ext] ?? "");
+ loadContent(rememberedCharset);
}, [viewerState?.open]);
const openMore = useCallback(
@@ -188,7 +218,8 @@ const CodeViewer = () => {
}
setLoading(true);
- dispatch(saveCode(value, viewerState.file, viewerState.version, saveAs))
+ const eolChar = eol == "LF" ? "\n" : "\r\n";
+ dispatch(saveCode(value.replace(/\r\n|\r|\n/g, eolChar), viewerState.file, viewerState.version, saveAs))
.then(() => {
setSaved(true);
})
@@ -196,7 +227,7 @@ const CodeViewer = () => {
setLoading(false);
});
},
- [value, viewerState],
+ [value, viewerState, eol],
);
const onChange = useCallback((v: string) => {
@@ -280,14 +311,19 @@ const CodeViewer = () => {
>
{allCharsets.map((charset) => (
- loadContent(charset)}>
+ pickCharset(charset)}>
{charset}
+ {charset == (charsetState ?? "UTF-8") && (
+
+
+
+ )}
))}
{allLng.map((l) => (
- setLng(l)}>
+ pickLanguage(l)}>
{l}
{l == language && (
@@ -298,6 +334,18 @@ const CodeViewer = () => {
))}
+
+ {(["LF", "CRLF"] as const).map((e) => (
+ setEol(e)} dense>
+ {e}
+ {e == eol && (
+
+
+
+ )}
+
+ ))}
+
{t("fileManager.wordWrap")}
{wordWrap === "on" && (
@@ -327,6 +375,7 @@ const CodeViewer = () => {
}}
value={value}
language={language ?? ""}
+ eol={eol}
onChange={(v) => onChange(v as string)}
/>
diff --git a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx
index b9496f62..cb9eb79d 100644
--- a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx
+++ b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx
@@ -107,6 +107,11 @@ export interface MonacoEditorProps extends MonacoEditorBaseProps {
minHeight?: string | number;
onSave?: React.MutableRefObject<() => void>;
+
+ /**
+ * Desired end-of-line sequence applied to the model via setEOL.
+ */
+ eol?: "LF" | "CRLF";
}
// ============ Diff Editor ============
@@ -206,6 +211,7 @@ function MonacoEditor({
className,
uri,
onSave,
+ eol,
}: MonacoEditorProps) {
const containerElement = useRef(null);
@@ -322,6 +328,13 @@ function MonacoEditor({
}
}, [language]);
+ useEffect(() => {
+ if (editor.current && eol) {
+ const model = editor.current.getModel();
+ model?.setEOL(eol == "LF" ? monaco.editor.EndOfLineSequence.LF : monaco.editor.EndOfLineSequence.CRLF);
+ }
+ }, [eol]);
+
useEffect(() => {
if (editor.current) {
// Don't pass in the model on update because monaco crashes if we pass the model
diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx
new file mode 100644
index 00000000..bccd5bbc
--- /dev/null
+++ b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx
@@ -0,0 +1,75 @@
+import { Suspense, useCallback, useEffect, useState } from "react";
+import { enqueueSnackbar } from "notistack";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { getFileEntityUrl } from "../../../api/api.ts";
+import { closeModelViewer } from "../../../redux/globalStateSlice.ts";
+import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
+import { getFileLinkedUri } from "../../../util";
+import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
+import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
+
+const ModelViewerCanvas = React.lazy(() => import("./ModelViewerCanvas.tsx"));
+
+const ModelViewer = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const viewerState = useAppSelector((state) => state.globalState.modelViewer);
+
+ const [loading, setLoading] = useState(false);
+ const [src, setSrc] = useState("");
+
+ useEffect(() => {
+ if (!viewerState || !viewerState.open) {
+ return;
+ }
+
+ setSrc("");
+ setLoading(true);
+ dispatch(
+ getFileEntityUrl({
+ uris: [getFileLinkedUri(viewerState.file)],
+ entity: viewerState.version,
+ }),
+ )
+ .then((res) => setSrc(res.urls[0].url))
+ .catch(() => onClose())
+ .finally(() => setLoading(false));
+ }, [viewerState]);
+
+ const onClose = useCallback(() => {
+ dispatch(closeModelViewer());
+ }, [dispatch]);
+
+ const onError = useCallback(() => {
+ enqueueSnackbar({
+ message: t("fileManager.modelLoadFailed"),
+ variant: "error",
+ action: DefaultCloseAction,
+ });
+ onClose();
+ }, [onClose, t]);
+
+ return (
+
+ {!src && }
+ {src && (
+ }>
+
+
+ )}
+
+ );
+};
+
+export default ModelViewer;
diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx
new file mode 100644
index 00000000..3ef43c58
--- /dev/null
+++ b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx
@@ -0,0 +1,135 @@
+import { Box } from "@mui/material";
+import { useEffect, useRef } from "react";
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
+import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
+import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
+import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
+import { fileExtension } from "../../../util";
+
+export interface ModelViewerCanvasProps {
+ src: string;
+ fileName: string;
+ onLoaded?: () => void;
+ onError?: () => void;
+}
+
+const loadModel = (src: string, ext: string): Promise => {
+ switch (ext) {
+ case "gltf":
+ case "glb":
+ return new Promise((resolve, reject) =>
+ new GLTFLoader().load(src, (g) => resolve(g.scene), undefined, reject),
+ );
+ case "fbx":
+ return new Promise((resolve, reject) => new FBXLoader().load(src, resolve, undefined, reject));
+ case "obj":
+ return new Promise((resolve, reject) => new OBJLoader().load(src, resolve, undefined, reject));
+ default:
+ return Promise.reject(new Error(`unsupported model format: ${ext}`));
+ }
+};
+
+const ModelViewerCanvas = ({ src, fileName, onLoaded, onError }: ModelViewerCanvasProps) => {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container || !src) {
+ return;
+ }
+
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ container.appendChild(renderer.domElement);
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x1a1a1a);
+
+ const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 5000);
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enableDamping = true;
+
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
+ const dir = new THREE.DirectionalLight(0xffffff, 1.5);
+ dir.position.set(3, 5, 4);
+ scene.add(dir);
+ scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x2a2a2a));
+
+ let disposed = false;
+ let model: THREE.Object3D | undefined;
+ loadModel(src, fileExtension(fileName) ?? "")
+ .then((obj) => {
+ if (disposed) {
+ return;
+ }
+ model = obj;
+ // Center on origin and scale so the model fits the view.
+ const box = new THREE.Box3().setFromObject(obj);
+ const center = box.getCenter(new THREE.Vector3());
+ const size = box.getSize(new THREE.Vector3());
+ const radius = Math.max(size.x, size.y, size.z, 1e-6);
+ obj.position.sub(center);
+ const scale = 4 / radius;
+ obj.scale.setScalar(scale);
+ scene.add(obj);
+
+ const sphere = new THREE.Box3().setFromObject(obj).getBoundingSphere(new THREE.Sphere());
+ camera.position.set(sphere.radius * 1.8, sphere.radius * 1.2, sphere.radius * 1.8);
+ camera.near = sphere.radius / 100;
+ camera.far = sphere.radius * 100;
+ camera.updateProjectionMatrix();
+ controls.target.set(0, 0, 0);
+ controls.update();
+ onLoaded?.();
+ })
+ .catch(() => onError?.());
+
+ const resize = () => {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ if (w === 0 || h === 0) {
+ return;
+ }
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, h);
+ };
+ resize();
+ const observer = new ResizeObserver(resize);
+ observer.observe(container);
+
+ const animate = () => {
+ if (disposed) {
+ return;
+ }
+ requestAnimationFrame(animate);
+ controls.update();
+ renderer.render(scene, camera);
+ };
+ animate();
+
+ return () => {
+ disposed = true;
+ observer.disconnect();
+ controls.dispose();
+ model?.traverse((o) => {
+ const mesh = o as THREE.Mesh;
+ mesh.geometry?.dispose();
+ const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
+ if (Array.isArray(material)) {
+ material.forEach((m) => m.dispose());
+ } else {
+ material?.dispose();
+ }
+ });
+ renderer.dispose();
+ container.removeChild(renderer.domElement);
+ };
+ }, [src, fileName]);
+
+ return ;
+};
+
+export default ModelViewerCanvas;
diff --git a/frontend/src/component/Viewers/Video/Artplayer.tsx b/frontend/src/component/Viewers/Video/Artplayer.tsx
index 47cf497c..0ccafd4d 100644
--- a/frontend/src/component/Viewers/Video/Artplayer.tsx
+++ b/frontend/src/component/Viewers/Video/Artplayer.tsx
@@ -82,7 +82,7 @@ const playM3u8 =
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
- art.on("destroy", () => hls.destroy());
+ hookMediaDestroy(art, "hls");
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
} else {
@@ -106,12 +106,35 @@ const playFlv = (video: HTMLVideoElement, url: string, art: Artplayer) => {
flv.attachMediaElement(video);
flv.load();
art.flv = flv;
- art.on("destroy", () => flv.destroy());
+ hookMediaDestroy(art, "flv");
} else {
art.notice.show = "Unsupported playback format: flv";
}
};
+// Registers a single destroy hook per media type. switchUrl() re-invokes the
+// customType handler and would otherwise stack one handler per player —
+// destroying a stale, already-destroyed instance (mpegts null-derefs, #3139).
+const hookedPlayers = new WeakMap>();
+const hookMediaDestroy = (art: Artplayer, key: "hls" | "flv") => {
+ let flags = hookedPlayers.get(art);
+ if (!flags) {
+ flags = new Set();
+ hookedPlayers.set(art, flags);
+ }
+ if (flags.has(key)) {
+ return;
+ }
+ flags.add(key);
+ art.on("destroy", () => {
+ try {
+ art[key]?.destroy();
+ } catch (e) {
+ console.warn(`Failed to destroy ${key} player:`, e);
+ }
+ });
+};
+
export default function Player({
option,
chapters,
diff --git a/frontend/src/component/Viewers/Video/VideoViewer.tsx b/frontend/src/component/Viewers/Video/VideoViewer.tsx
index eac93061..a0d5899e 100644
--- a/frontend/src/component/Viewers/Video/VideoViewer.tsx
+++ b/frontend/src/component/Viewers/Video/VideoViewer.tsx
@@ -469,6 +469,26 @@ const VideoViewer = () => {
fontSize: `${subtitleStyle.fontSize ?? 20}px`,
},
},
+ settings: [
+ {
+ name: "loop",
+ html: t("application:fileManager.loop"),
+ switch: false,
+ mounted(item: any) {
+ const enabled = this.storage.get("loop") === true;
+ if (enabled) {
+ item.switch = true;
+ this.loop = true;
+ }
+ },
+ onSwitch(item: any) {
+ const next = !item.switch;
+ this.loop = next;
+ this.storage.set("loop", next);
+ return next;
+ },
+ },
+ ],
plugins: [],
lang: t("artPlayerLocaleCode", { ns: "common" }), // TODO: review
}}
diff --git a/frontend/src/redux/globalStateSlice.ts b/frontend/src/redux/globalStateSlice.ts
index 6d8a8747..42b4d11b 100644
--- a/frontend/src/redux/globalStateSlice.ts
+++ b/frontend/src/redux/globalStateSlice.ts
@@ -245,6 +245,7 @@ export interface GlobalStateSlice {
pdfViewer?: GeneralViewerState;
customViewer?: CustomViewerState;
epubViewer?: GeneralViewerState;
+ modelViewer?: GeneralViewerState;
musicPlayer?: MusicPlayerState;
excalidrawViewer?: GeneralViewerState;
archiveViewer?: GeneralViewerState;
@@ -423,6 +424,7 @@ export const globalStateSlice = createSlice({
state.pdfViewer = undefined;
state.customViewer = undefined;
state.epubViewer = undefined;
+ state.modelViewer = undefined;
state.excalidrawViewer = undefined;
state.archiveViewer = undefined;
},
@@ -575,6 +577,12 @@ export const globalStateSlice = createSlice({
closeEpubViewer: (state) => {
state.epubViewer && (state.epubViewer.open = false);
},
+ setModelViewer: (state, action: PayloadAction) => {
+ state.modelViewer = action.payload;
+ },
+ closeModelViewer: (state) => {
+ state.modelViewer && (state.modelViewer.open = false);
+ },
setCustomViewer: (state, action: PayloadAction) => {
state.customViewer = action.payload;
},
@@ -874,6 +882,8 @@ export const {
closeMusicPlayer,
setEpubViewer,
closeEpubViewer,
+ setModelViewer,
+ closeModelViewer,
setCustomViewer,
closeCustomViewer,
setPdfViewer,
diff --git a/frontend/src/redux/thunks/download.ts b/frontend/src/redux/thunks/download.ts
index 64a816d1..bf589811 100644
--- a/frontend/src/redux/thunks/download.ts
+++ b/frontend/src/redux/thunks/download.ts
@@ -2,8 +2,8 @@ import dayjs from "dayjs";
import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar } from "notistack";
import streamSaver from "streamsaver";
-import { getFileEntityUrl } from "../../api/api.ts";
-import { FileResponse, FileType, Metadata } from "../../api/explorer.ts";
+import { getFileEntityUrl, getFileList } from "../../api/api.ts";
+import { FileResponse, FileType, ListResponse, Metadata } from "../../api/explorer.ts";
import { GroupPermission } from "../../api/user.ts";
import {
DefaultCloseAction,
@@ -51,6 +51,32 @@ export function downloadFiles(index: number, files: FileResponse[]): AppThunk {
};
}
+// downloadAll fetches every page of the current directory listing and
+// downloads the whole set — the visible list may be only the first page.
+export function downloadAll(index: number): AppThunk {
+ return async (dispatch, getState) => {
+ const path = getState().fileManager[index]?.path;
+ if (!path) {
+ return;
+ }
+
+ const files: FileResponse[] = [];
+ let token: string | undefined = undefined;
+ do {
+ const res: ListResponse = await dispatch(
+ getFileList({ uri: path, next_page_token: token, page_size: 1000 }),
+ );
+ files.push(...(res.files ?? []));
+ token = res.pagination?.next_token;
+ } while (token);
+
+ if (files.length == 0) {
+ return;
+ }
+ await dispatch(downloadFiles(index, files));
+ };
+}
+
export function downloadMultipleFiles(files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
// Prepare download options
@@ -102,7 +128,45 @@ export function backendBatchDownload(files: FileResponse[]): AppThunk {
"application:fileManager.preparingBathDownload",
);
- window.location.assign(downloadUrl.urls[0].url);
+ window.location.assign(await dispatch(pickDownloadRoute(downloadUrl.urls[0].url)));
+ };
+}
+
+// pickDownloadRoute lets the user choose one of the admin-configured CDN
+// mirror endpoints (#2987); the signed URL keeps its path+query so the
+// signature stays valid through the CDN. Cancel falls back to direct.
+export function pickDownloadRoute(url: string): AppThunk> {
+ return async (dispatch, getState) => {
+ const routes = getState().siteConfig.basic.config.download_cdn_routes;
+ if (!routes || routes.length === 0) {
+ return url;
+ }
+
+ const options: DialogSelectOption[] = [
+ {
+ value: -1,
+ name: i18next.t("fileManager.downloadRouteDirect"),
+ description: i18next.t("fileManager.downloadRouteDirectDescription"),
+ },
+ ...routes.map((r, i): DialogSelectOption => ({ value: i, name: r.name, description: r.url })),
+ ];
+
+ let picked: number;
+ try {
+ picked = (await dispatch(selectOption(options, "fileManager.selectDownloadRoute"))) as number;
+ } catch {
+ return url;
+ }
+ if (picked < 0 || picked >= routes.length) {
+ return url;
+ }
+
+ try {
+ const u = new URL(url);
+ return routes[picked].url.replace(/\/+$/, "") + u.pathname + u.search + u.hash;
+ } catch {
+ return url;
+ }
};
}
@@ -527,11 +591,12 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string)
"application:fileManager.preparingDownload",
);
+ const downloadUrl = await dispatch(pickDownloadRoute(urlRes.urls[0].url));
const streamSaverName = urlRes.urls[0].stream_saver_display_name;
if (streamSaverName) {
// remove streamSaverParam from query
const fileStream = streamSaver.createWriteStream(streamSaverName);
- const res = await fetch(urlRes.urls[0].url);
+ const res = await fetch(downloadUrl);
const readableStream = res.body;
if (!readableStream) {
return;
@@ -549,7 +614,7 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string)
return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar));
}
} else {
- window.location.assign(urlRes.urls[0].url);
+ window.location.assign(downloadUrl);
}
};
}
diff --git a/frontend/src/redux/thunks/file.ts b/frontend/src/redux/thunks/file.ts
index e7865b40..ea1811b5 100644
--- a/frontend/src/redux/thunks/file.ts
+++ b/frontend/src/redux/thunks/file.ts
@@ -6,6 +6,7 @@ import {
getFileEntityUrl,
getFileList,
getFileThumb,
+ isNameConflictBatchError,
sendCreateFile,
sendDeleteFiles,
sendEmptyTrash,
@@ -75,7 +76,7 @@ import {
} from "../globalStateSlice.ts";
import { ConfigLoadState, Viewers } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
-import { confirmOperation, deleteConfirmation, renameForm, requestCreateNew, selectPath } from "./dialog.ts";
+import { confirmOperation, deleteConfirmation, renameForm, requestCreateNew, selectOption, selectPath } from "./dialog.ts";
import { downloadSingleFile } from "./download.ts";
import { navigateToPath, refreshFileList, updateUserCapacity } from "./filemanager.ts";
import { queueLoadShareInfo } from "./share.ts";
@@ -380,6 +381,7 @@ export function submitRenameFile(index: number, file: FileResponse, newName: str
sendRenameFile({
uri: file.path,
new_name: newName,
+ expect_id: file.id,
}),
);
} catch (e) {
@@ -665,14 +667,47 @@ export function moveFiles(index: number, src: FileResponse[], dst: string, isCop
return;
}
+ const moveReq = (onConflict?: "skip" | "overwrite") => ({
+ uris: src.map((f) => f.path),
+ dst,
+ copy: isCopy,
+ expect_ids: src.map((f) => f.id),
+ on_conflict: onConflict,
+ });
+ const moveLabel = isCopy ? "application:modals.processingCopying" : "application:modals.processingMoving";
+
let success = true;
try {
- await longRunningTaskWithSnackbar(
- dispatch(sendMoveFile({ uris: src.map((f) => f.path), dst, copy: isCopy })),
- isCopy ? "application:modals.processingCopying" : "application:modals.processingMoving",
- );
+ await longRunningTaskWithSnackbar(dispatch(sendMoveFile(moveReq())), moveLabel);
} catch (e) {
success = false;
+ if (isNameConflictBatchError(e as Error)) {
+ const onConflict = await dispatch(
+ selectOption(
+ [
+ {
+ name: i18next.t("application:modals.overwrite"),
+ description: i18next.t("application:modals.conflictOverwriteDes"),
+ value: "overwrite",
+ },
+ {
+ name: i18next.t("application:modals.skip"),
+ description: i18next.t("application:modals.conflictSkipDes"),
+ value: "skip",
+ },
+ ],
+ "application:modals.nameConflict",
+ ),
+ ).catch(() => undefined);
+ if (onConflict == "skip" || onConflict == "overwrite") {
+ try {
+ await longRunningTaskWithSnackbar(dispatch(sendMoveFile(moveReq(onConflict))), moveLabel);
+ success = true;
+ } catch (e) {
+ // Retried operation failed; error snackbar already shown.
+ }
+ }
+ }
}
if (isCopy) {
diff --git a/frontend/src/redux/thunks/share.ts b/frontend/src/redux/thunks/share.ts
index fcdbe4b5..05c526fe 100644
--- a/frontend/src/redux/thunks/share.ts
+++ b/frontend/src/redux/thunks/share.ts
@@ -34,6 +34,7 @@ export function createOrUpdateShareLink(
allow_edit: setting.allow_edit,
preview_only: setting.preview_only,
upload_only: setting.upload_only,
+ note: setting.note?.trim() || undefined,
downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined,
expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
};
diff --git a/frontend/src/redux/thunks/viewer.ts b/frontend/src/redux/thunks/viewer.ts
index 4ecd68d4..00336e96 100644
--- a/frontend/src/redux/thunks/viewer.ts
+++ b/frontend/src/redux/thunks/viewer.ts
@@ -23,6 +23,7 @@ import {
setImageEditor,
setImageViewer,
setMarkdownViewer,
+ setModelViewer,
setMusicPlayer,
setPdfViewer,
setPhotopeaViewer,
@@ -54,6 +55,7 @@ export const builtInViewers = {
music: "music",
excalidraw: "excalidraw",
archive: "archive",
+ model3d: "model3d",
};
export function openViewers(
@@ -72,11 +74,17 @@ export function openViewers(
const ext = fileExtension(file.name) ?? "";
const entitySize = size ?? file.size;
- // Try user preference
- const userPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext);
- if (!ignorePreference && userPreference && ViewersByID[userPreference]) {
- dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion));
- return;
+ // Try user preference — the server-synced map wins over the per-device
+ // localStorage copy so the choice follows the user across devices. A
+ // stale server entry (viewer removed site-wide) falls back to local.
+ if (!ignorePreference) {
+ const serverPreference = SessionManager.currentUser()?.preferred_viewers?.[ext];
+ const localPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext);
+ const userPreference = [serverPreference, localPreference].find((id) => id && ViewersByID[id]);
+ if (userPreference) {
+ dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion));
+ return;
+ }
}
const viewerOptions = Viewers[ext];
@@ -239,6 +247,15 @@ export function openViewer(
}),
);
break;
+ case builtInViewers.model3d:
+ dispatch(
+ setModelViewer({
+ open: true,
+ file,
+ version: preferredVersion ?? primaryEntity,
+ }),
+ );
+ break;
case builtInViewers.archive:
dispatch(
setArchiveViewer({
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
index d3270cdb..93cc7d55 100644
--- a/frontend/src/router/index.tsx
+++ b/frontend/src/router/index.tsx
@@ -4,6 +4,7 @@ import ErrorBoundary from "../component/Common/ErrorBoundary.tsx";
import HeadlessFrame from "../component/Frame/HeadlessFrame.tsx";
import { HomeRedirect } from "../component/Pages/HomeRedirect.tsx";
import Activate from "../component/Pages/Login/Activate.tsx";
+import ActivateEmailChange from "../component/Pages/Login/ActivateEmailChange.tsx";
import Authorize from "../component/Pages/Login/Authorize.tsx";
import Reset from "../component/Pages/Login/Reset.tsx";
import SessionIntro from "../component/Pages/Login/SessionIntro.tsx";
@@ -41,6 +42,10 @@ export const router = createBrowserRouter([
path: "activate",
element: ,
},
+ {
+ path: "activate_email",
+ element: ,
+ },
{
path: "reset",
element: ,
diff --git a/frontend/src/session/index.ts b/frontend/src/session/index.ts
index 2d68e9cc..e6b37600 100644
--- a/frontend/src/session/index.ts
+++ b/frontend/src/session/index.ts
@@ -25,6 +25,8 @@ export enum UserSettings {
BookLocationPrefix = "book_location_",
MusicVolume = "music_volume",
OpenWithPrefix = "open_with_",
+ CodeCharsetPrefix = "code_charset_",
+ CodeLanguagePrefix = "code_language_",
ConcurrentLimit = "concurrent_limit",
UseAvgSpeed = "use_avg_speed",
TaskFilter = "task_filter",
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 5cf35793..31e85433 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1297,6 +1297,11 @@
react-devtools-inline "4.4.0"
react-is "^17.0.2"
+"@dimforge/rapier3d-compat@~0.12.0":
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
+ integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
+
"@emotion/babel-plugin@^11.11.0":
version "11.11.0"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz"
@@ -3800,6 +3805,11 @@
dependencies:
tippy.js "^6.3.1"
+"@tweenjs/tween.js@~23.1.3":
+ version "23.1.3"
+ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
+ integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
+
"@types/acorn@^4.0.0":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22"
@@ -4056,6 +4066,11 @@
resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
+"@types/stats.js@*":
+ version "0.17.4"
+ resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
+ integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
+
"@types/streamsaver@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.4.tgz#b57e47f9246dc0ed69378f7d1387857c94043ecf"
@@ -4073,6 +4088,18 @@
dependencies:
"@types/geojson" "*"
+"@types/three@^0.186.0":
+ version "0.186.0"
+ resolved "https://registry.yarnpkg.com/@types/three/-/three-0.186.0.tgz#fc936323d7eeb885052fd5de352e3772f488ea99"
+ integrity sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==
+ dependencies:
+ "@dimforge/rapier3d-compat" "~0.12.0"
+ "@tweenjs/tween.js" "~23.1.3"
+ "@types/stats.js" "*"
+ "@types/webxr" ">=0.5.17"
+ fflate "~0.8.3"
+ meshoptimizer "~1.1.1"
+
"@types/trusted-types@^2.0.2":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
@@ -4103,6 +4130,11 @@
resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.9.tgz#c6c17c631afdfad5859deb69c6a106886b263f73"
integrity sha512-4uDknRaO9G1fonoEALN6SDYvyRb7luVYP7lm7y0OkdVdMcU7rIh+Nq/VepDe0arkh+gfUzwCr5cNfqmKm+x5wA==
+"@types/webxr@>=0.5.17":
+ version "0.5.24"
+ resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
+ integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
+
"@types/wicg-file-system-access@^2023.10.5":
version "2023.10.5"
resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d"
@@ -6177,6 +6209,11 @@ fdir@^6.4.3:
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72"
integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==
+fflate@~0.8.3:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc"
+ integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==
+
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
@@ -7702,6 +7739,11 @@ mermaid@10.9.3:
uuid "^9.0.0"
web-worker "^1.2.0"
+meshoptimizer@~1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz#20c7d050d59bdc573154814f6a37d47d89908cca"
+ integrity sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==
+
micromark-core-commonmark@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
@@ -9913,6 +9955,11 @@ terser@^5.17.4:
commander "^2.20.0"
source-map-support "~0.5.20"
+three@^0.186.0:
+ version "0.186.0"
+ resolved "https://registry.yarnpkg.com/three/-/three-0.186.0.tgz#08f70ce80dffa9247a567b421165bec630e86f8d"
+ integrity sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==
+
timeago-react@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.6.tgz#d1b55af67ed21b1c5429b9ba8043d0d0f7dab295"
diff --git a/go.mod b/go.mod
index 8cbde9dd..4a2a1332 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,6 @@ require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0
github.com/aws/aws-sdk-go v1.34.0
github.com/bodgit/sevenzip v1.6.1
- github.com/cloudflare/cfssl v1.6.1
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/dsoprea/go-heic-exif-extractor v0.0.0-20210512044107-62067e44c235
@@ -32,7 +31,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gomodule/redigo v1.9.2
github.com/google/go-querystring v1.1.0
- github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -62,7 +60,6 @@ require (
golang.org/x/image v0.41.0
golang.org/x/text v0.37.0
golang.org/x/time v0.12.0
- golang.org/x/tools v0.44.0
modernc.org/sqlite v1.30.0
)
@@ -105,9 +102,8 @@ require (
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
- github.com/hashicorp/errwrap v1.1.0 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl/v2 v2.13.0 // indirect
@@ -136,6 +132,9 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/sergi/go-diff v1.2.0 // indirect
+ github.com/smartystreets/assertions v1.0.0 // indirect
+ github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
@@ -152,6 +151,7 @@ require (
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
+ gopkg.in/ini.v1 v1.51.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
diff --git a/go.sum b/go.sum
index f1355df4..21399bc7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,16 +1,11 @@
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 h1:GwdJbXydHCYPedeeLt4x/lrlIISQ4JTH1mRWuE5ZZ14=
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43/go.mod h1:uj3pm+hUTVN/X5yfdBexHlZv+1Xu5u5ZbZx7+CDavNU=
-bazil.org/fuse v0.0.0-20180421153158-65cc252bf669/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
-bitbucket.org/creachadair/shell v0.0.6/go.mod h1:8Qqi/cYk7vPnsOePHroKXDJYmb5x7ENhtiFtfZq8K+M=
-bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
@@ -35,117 +30,45 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/spanner v1.17.0/go.mod h1:+17t2ixFwRG4lWRwE+5kipDR9Ef07Jkmc8z0IbMDKUs=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-code.gitea.io/sdk/gitea v0.11.3/go.mod h1:z3uwDV/b9Ls47NGukYM9XhnHtqPh/J+t40lsUrR6JDY=
-contrib.go.opencensus.io/exporter/aws v0.0.0-20181029163544-2befc13012d0/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA=
-contrib.go.opencensus.io/exporter/ocagent v0.5.0/go.mod h1:ImxhfLRpxoYiSq891pBrLVhN+qmP8BTVvdH2YLs7Gl0=
-contrib.go.opencensus.io/exporter/stackdriver v0.12.1/go.mod h1:iwB6wGarfphGGe/e5CWqyUk/cLzKnWsOKPVW3no6OTw=
-contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc=
-contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
-contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
entgo.io/ent v0.13.0 h1:DclxWczaCpyiKn6ZWVcJjq1zIKtJ11iNKy+08lNYsJE=
entgo.io/ent v0.13.0/go.mod h1:+oU8oGna69xy29O+g+NEz+/TM7yJDhQQGJfuOWq1pT8=
-github.com/Azure/azure-amqp-common-go/v2 v2.1.0/go.mod h1:R8rea+gJRuJR6QxTir/XuEd+YuKoUiazDC/N96FiDEU=
-github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4=
-github.com/Azure/azure-sdk-for-go v29.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go v30.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-service-bus-go v0.9.1/go.mod h1:yzBx6/BUGfjfeqbRZny9AQIbIe3AcV9WZbAdpkoXOa0=
-github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
-github.com/Azure/go-autorest v12.0.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
-github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
-github.com/GeertJohan/go.rice v1.0.2/go.mod h1:af5vUNlDNkCjOZeSGFgIJxDje9qdjsO6hshx0gTmZt4=
-github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20191009163259-e802c2cb94ae/go.mod h1:mjwGPas4yKduTyubHvD1Atl9r1rUq8DfVy+gkVvZ+oo=
-github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
-github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
-github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
-github.com/Masterminds/sprig v2.15.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
-github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
-github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg=
-github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
-github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
-github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
-github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
-github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
-github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
-github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ=
-github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
-github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
-github.com/apache/beam v2.28.0+incompatible/go.mod h1:/8NX3Qi8vGstDLLaeaU7+lzVEu/ACaQhYjeefzQ0y1o=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/apex/log v1.1.4/go.mod h1:AlpoD9aScyQfJDVHmLMEcx4oU6LqzkWp4Mg9GdAcEvQ=
-github.com/apex/logs v0.0.4/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
-github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=
-github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
-github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
-github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
-github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.19.45/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.25.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.34.0 h1:brux2dRrlwCF5JhTL7MUT3WUwo9zfDHZZp3+g3Mvlmo=
github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
-github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
-github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
-github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
-github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
-github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
@@ -158,73 +81,28 @@ github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
-github.com/caarlos0/ctrlc v1.0.0/go.mod h1:CdXpj4rmq0q/1Eb44M9zi2nKB0QraNKuRGYGrrHhcQw=
-github.com/campoy/unique v0.0.0-20180121183637-88950e537e7e/go.mod h1:9IOqJGCPMSc6E5ydlp5NIonxObaeu/Iub/X03EKPVYo=
-github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
-github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A=
-github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
-github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
-github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
-github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY=
-github.com/cloudflare/cfssl v1.6.1 h1:aIOUjpeuDJOpWjVJFP2ByplF53OgqG8I1S40Ggdlk3g=
-github.com/cloudflare/cfssl v1.6.1/go.mod h1:ENhCj4Z17+bY2XikpxVmTHDg/C2IsG2Q0ZBeXpAqhCk=
-github.com/cloudflare/redoctober v0.0.0-20201013214028-99c99a8e7544/go.mod h1:6Se34jNoqrd8bTxrmJB2Bg2aoZ2CdSXonils9NsiNgo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20210322005330-6414d713912e/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
-github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
-github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
-github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
-github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
-github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
-github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
-github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
-github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA=
github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM=
-github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 h1:simG0vMYFvNriGhaaat7QVVkaVkXzvqcohaBoLZl9Hg=
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM=
-github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
@@ -262,49 +140,27 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
-github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
-github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
-github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
-github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v0.3.0-java/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/envoyproxy/protoc-gen-validate v0.6.1/go.mod h1:txg5va2Qkip90uYoSKH+nkAAmXrb2j3iq4FLwdrCbXQ=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
-github.com/etcd-io/gofail v0.0.0-20190801230047-ad7f989257ca/go.mod h1:49H/RkXP8pKaZy4h0d+NW16rSLhyVBt4o6VLJbmOqDE=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
-github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
-github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
-github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
-github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
-github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
-github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
-github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fullstorydev/grpcurl v1.8.0/go.mod h1:Mn2jWbdMrQGJQ8UD62uNyMumT2acsZUCkZIqFxsQf1o=
-github.com/fullstorydev/grpcurl v1.8.1/go.mod h1:3BWhvHZwNO7iLXaQlojdg5NA6SxUDePli4ecpK1N7gw=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
-github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg=
github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
github.com/gin-contrib/gzip v1.2.4 h1:yNz4EhPC2kHSZJD1oc1zwp7MLEhEZ3goQeGM3a1b6jU=
@@ -319,7 +175,6 @@ github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2/go.mod h1:VhW/C
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
-github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -328,15 +183,10 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-ini/ini v1.50.0 h1:ogX6RS8VstVN8MJcwhEP78hHhWaI3klN02+97bByabY=
github.com/go-ini/ini v1.50.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -355,8 +205,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.8.0/go.mod h1:9JhgTzTaE31GZDpH/HSvHiRJrJ3iKAgqqH0Bl/Ocjdk=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
-github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
-github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -374,17 +222,10 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
@@ -394,13 +235,9 @@ github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgR
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
@@ -425,16 +262,11 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
-github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
-github.com/google/certificate-transparency-go v1.1.2-0.20210422104406-9f33727a7a18/go.mod h1:6CKh9dscIRoqc2kC6YUFICHZMT9NrClyPrRVFrdw1QQ=
-github.com/google/certificate-transparency-go v1.1.2-0.20210511102531-373a877eec92/go.mod h1:kXWPsHVPSKVuxPPG69BRtumCbAW537FydV/GH89oBhM=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -448,21 +280,15 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM=
-github.com/google/go-licenses v0.0.0-20210329231322-ce1d9163b77d/go.mod h1:+TYOmkVoJOpwnS0wfdsJCV9CoD5nJYsHoFk/0CrTK4M=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
-github.com/google/go-replayers/grpcreplay v0.1.0/go.mod h1:8Ig2Idjpr6gifRd6pNVggX6TC1Zw6Jx74AKp7QNH2QE=
-github.com/google/go-replayers/httpreplay v0.1.0/go.mod h1:YKZViNhiGgqdBlUbI2MwGpq4pXxNmhJLPHQ7cv2b5no=
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/licenseclassifier v0.0.0-20210325184830-bb04aff29e72/go.mod h1:qsqn2hxC+vURpyBRygGUuinTO42MFRLcsmQ/P8v94+M=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -479,109 +305,42 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg=
-github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
-github.com/google/trillian v1.3.14-0.20210409160123-c5ea3abd4a41/go.mod h1:1dPv0CUjNQVFEDuAUFhZql16pw/VlPgaX8qj+g5pVzQ=
-github.com/google/trillian v1.3.14-0.20210428093031-b4ddea2e86b1/go.mod h1:FdIJX+NoDk/dIN2ZxTyz5nAJWgf+NSSSriPAMThChTY=
-github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.3.0/go.mod h1:i1DMg/Lu8Sz5yYl25iOdmc5CT5qusaa+zmRWs16741s=
-github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
-github.com/goreleaser/goreleaser v0.134.0/go.mod h1:ZT6Y2rSYa6NxQzIsdfWWNWAlYGXGbreo66NmE+3X3WQ=
-github.com/goreleaser/nfpm v1.2.1/go.mod h1:TtWrABZozuLOttX2uDlYyECfQX7x5XYkVxhjYcR6G9w=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
-github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI=
-github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/grpc-ecosystem/grpc-gateway v1.9.2/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw=
-github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
-github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
-github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
-github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
-github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc=
github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
-github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
-github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
-github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible h1:/2MdLc7zHJqzV7J2uVGaoGymVobB/OHC8wmEyWRaK68=
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s=
-github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
-github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/imdario/mergo v0.3.4/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
-github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
-github.com/jhump/protoreflect v1.6.1/go.mod h1:RZQ/lnuN+zqeRVpQigTwO6o0AJUkxbnSnpuG7toUTG4=
-github.com/jhump/protoreflect v1.8.2/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg=
github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE=
github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -589,24 +348,11 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
-github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
-github.com/jmhodges/clock v0.0.0-20160418191101-880ee4c33548/go.mod h1:hGT6jSUVzF6no3QaDSMLGLEHtHSBSefs+MgcDWnmhmo=
-github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
-github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
-github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -616,17 +362,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/juju/ratelimit v1.0.1 h1:+7AIFJVQ0EQgq/K9+0Krm7m530Du7tIz0METWzN0RgY=
github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
-github.com/kisom/goutils v1.4.3/go.mod h1:Lp5qrquG7yhYnWzZCI/68Pa/GpFynw//od6EkGnWpac=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
@@ -635,8 +372,6 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -644,208 +379,83 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ks3sdklib/aws-sdk-go v1.6.2 h1:nxtaaU3hDD5x6gmoxs/qijSJqZrjFapYYuTiVCEgobA=
github.com/ks3sdklib/aws-sdk-go v1.6.2/go.mod h1:jGcsV0dJgMmStAyqjkKVUu6F167pAXYZAS3LqoZMmtM=
-github.com/kylelemons/go-gypsy v1.0.0/go.mod h1:chkXM0zjdpXOiqkCW1XcCHDfjfk14PH2KKkQWxfJUcU=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
-github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
-github.com/lyft/protoc-gen-star v0.5.1/go.mod h1:9toiA3cC7z5uVbODF7kEQ91Xn7XNFkVUl+SrEe+ZORU=
-github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
-github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
-github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
-github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI=
github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78=
-github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458=
-github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
-github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
-github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
-github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
-github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
-github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2 h1:daZqE/T/yEoKIQNd3rwNeLsiS0VpZFfJulR0t/rtgAE=
github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w=
github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA=
-github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007/go.mod h1:m2XC9Qq0AlmmVksL6FktJCdTYyLk7V3fKyp0sl1yWQo=
-github.com/mwitkow/go-proto-validators v0.2.0/go.mod h1:ZfA1hW+UH/2ZHOWvQ3HnQaU0DtnpXu850MZiy+YUgcc=
-github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
-github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
-github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
-github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
-github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso=
-github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc=
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
-github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
-github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
-github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
-github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
-github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
-github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
-github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
-github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
-github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
-github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
-github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
-github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
-github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
-github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
-github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
-github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
-github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
-github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
-github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
-github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
-github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
-github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok=
github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
-github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
-github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
-github.com/prometheus/common v0.24.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/pseudomuto/protoc-gen-doc v1.4.1/go.mod h1:exDTOVwqpp30eV/EDPFLZy3Pwr2sn6hBC1WIYH/UbIg=
-github.com/pseudomuto/protokit v0.2.0/go.mod h1:2PdH30hxVHsup8KpBTOXTBeMVhJZVio3Q8ViKSAXT0Q=
github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
github.com/qiniu/go-sdk/v7 v7.19.0 h1:k3AzDPil8QHIQnki6xXt4YRAjE52oRoBUXQ4bV+Wc5U=
github.com/qiniu/go-sdk/v7 v7.19.0/go.mod h1:nqoYCNo53ZlGA521RvRethvxUDvXKt4gtYXOwye868w=
@@ -859,155 +469,75 @@ github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1/go.mod h1:J
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
-github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
-github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
-github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
-github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.0.0 h1:UVQPSSmc3qtTi+zPPkCXvZX9VvW/xT/NsRvKfwY81a8=
github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
-github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/soheilhy/cmux v0.1.5-0.20210205191134-5ec6847320e5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
-github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
-github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
-github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
-github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
-github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
-github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
-github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
-github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
-github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
-github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
-github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
-github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.54 h1:FRamEhNBbSeggyYfWfzFejTLftgbICocSYFk4PKTSV4=
github.com/tencentyun/cos-go-sdk-v5 v0.7.54/go.mod h1:UN+VdbCl1hg+kKi5RXqZgaP+Boqfmk+D04GRc4XFk70=
-github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
-github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
-github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
-github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20200427203606-3cfed13b9966/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 h1:rB0J+hLNltG1Qv+UF+MkdFz89XMps5BOAFJN4xWjc+s=
github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E=
-github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
-github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
-github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
-github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
-github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0=
github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs=
-github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
-github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
-github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
-github.com/xanzy/go-gitlab v0.31.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
-github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
-github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -1017,28 +547,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA=
github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
-github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
-github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
-github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is=
-github.com/zmap/zcrypto v0.0.0-20210123152837-9cf5beac6d91/go.mod h1:R/deQh6+tSWlgI9tb4jNmXxn8nSCabl5ZQsBX9//I/E=
-github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
-github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
-go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
-go.etcd.io/etcd/api/v3 v3.5.0-alpha.0/go.mod h1:mPcW6aZJukV6Aa81LSKpBjQXTWlXB5r74ymPoSWa3Sw=
-go.etcd.io/etcd/client/v2 v2.305.0-alpha.0/go.mod h1:kdV+xzCJ3luEBSIeQyB/OEKkWKd8Zkux4sbDeANrosU=
-go.etcd.io/etcd/client/v3 v3.5.0-alpha.0/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8=
-go.etcd.io/etcd/etcdctl/v3 v3.5.0-alpha.0/go.mod h1:YPwSaBciV5G6Gpt435AasAG3ROetZsKNUzibRa/++oo=
-go.etcd.io/etcd/pkg/v3 v3.5.0-alpha.0/go.mod h1:tV31atvwzcybuqejDoY3oaNRTtlD2l/Ot78Pc9w7DMY=
-go.etcd.io/etcd/raft/v3 v3.5.0-alpha.0/go.mod h1:FAwse6Zlm5v4tEWZaTjmNhe17Int4Oxbu7+2r0DiD3w=
-go.etcd.io/etcd/server/v3 v3.5.0-alpha.0/go.mod h1:tsKetYpt980ZTpzl/gb+UOJj9RkIyCb1u4wjzMg90BQ=
-go.etcd.io/etcd/tests/v3 v3.5.0-alpha.0/go.mod h1:HnrHxjyCuZ8YDt8PYVyQQ5d1ZQfzJVEtQWllr5Vp/30=
-go.etcd.io/etcd/v3 v3.5.0-alpha.0/go.mod h1:JZ79d3LV6NUfPjUxXrpiFAYcjhT+06qqw+i28snx8To=
-go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
-go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -1046,46 +555,20 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
-go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
-go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
-go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
-go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
-go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
-go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
-gocloud.dev v0.19.0/go.mod h1:SmKwiR8YwIMMJvQBKLsC3fHNyMwXLw3PMDO+VVteJMI=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191117063200-497ca9f6d64f/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
-golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
@@ -1101,7 +584,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
@@ -1120,7 +602,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -1131,18 +612,13 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1150,17 +626,10 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191119073136-fc4aabc6c914/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -1169,7 +638,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -1180,12 +648,10 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
@@ -1193,9 +659,7 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1205,13 +669,10 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1222,36 +683,23 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190620070143-6f217b454f45/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191119060738-e882bf8e40c2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1260,31 +708,21 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210511113859-b0526f3d8744/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1297,7 +735,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1315,48 +752,35 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
-golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191118222007-07fc4c7f2b98/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
@@ -1368,24 +792,18 @@ golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200426102838-f3a5411a4c3b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
@@ -1396,12 +814,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.6.0/go.mod h1:btoxGiFvQNVUZQ8W08zLtrVS08CNpINPEfxXxgJL1Q4=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.10.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
@@ -1419,28 +834,19 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20181107211654-5fc9ac540362/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
-google.golang.org/genproto v0.0.0-20190620144150-6af8c5fc6601/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
@@ -1457,10 +863,8 @@ google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfG
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
@@ -1476,20 +880,12 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210331142528-b7513248f0ba/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/genproto v0.0.0-20210510173355-fb37daa5cd7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@@ -1499,15 +895,11 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1518,7 +910,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
@@ -1529,29 +920,17 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
-gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
-gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
-gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=
-gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@@ -1567,7 +946,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.1.4/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
@@ -1594,10 +972,6 @@ modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-pack.ag/amqp v0.11.2/go.mod h1:4/cbmt4EJXSKlG6LCfWHoqmN0uFdy5i/+YFz+fTfhV4=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
-sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
-sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
diff --git a/inventory/ban_expires_test.go b/inventory/ban_expires_test.go
new file mode 100644
index 00000000..20691a5d
--- /dev/null
+++ b/inventory/ban_expires_test.go
@@ -0,0 +1,98 @@
+package inventory
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLiftExpiredBan(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ past := time.Now().Add(-time.Hour)
+ future := time.Now().Add(time.Hour)
+ uc := NewUserClient(client)
+
+ t.Run("expired manual ban lifted", func(t *testing.T) {
+ u := client.User.Create().SetEmail("lift-exp@example.com").SetNick("u").
+ SetStatus(entuser.StatusManualBanned).SetBanExpires(past).SetGroup(group).SaveX(ctx)
+ out, err := uc.LiftExpiredBan(ctx, u)
+ require.NoError(t, err)
+ require.Equal(t, entuser.StatusActive, out.Status)
+ require.Nil(t, client.User.GetX(ctx, u.ID).BanExpires)
+ })
+
+ t.Run("expired sys ban lifted", func(t *testing.T) {
+ u := client.User.Create().SetEmail("lift-sys@example.com").SetNick("u").
+ SetStatus(entuser.StatusSysBanned).SetBanExpires(past).SetGroup(group).SaveX(ctx)
+ out, err := uc.LiftExpiredBan(ctx, u)
+ require.NoError(t, err)
+ require.Equal(t, entuser.StatusActive, out.Status)
+ })
+
+ t.Run("future ban untouched", func(t *testing.T) {
+ u := client.User.Create().SetEmail("lift-future@example.com").SetNick("u").
+ SetStatus(entuser.StatusManualBanned).SetBanExpires(future).SetGroup(group).SaveX(ctx)
+ out, err := uc.LiftExpiredBan(ctx, u)
+ require.NoError(t, err)
+ require.Equal(t, entuser.StatusManualBanned, out.Status)
+ require.NotNil(t, client.User.GetX(ctx, u.ID).BanExpires)
+ })
+
+ t.Run("permanent ban untouched", func(t *testing.T) {
+ u := client.User.Create().SetEmail("lift-perm@example.com").SetNick("u").
+ SetStatus(entuser.StatusManualBanned).SetGroup(group).SaveX(ctx)
+ out, err := uc.LiftExpiredBan(ctx, u)
+ require.NoError(t, err)
+ require.Equal(t, entuser.StatusManualBanned, out.Status)
+ })
+
+ t.Run("active user untouched", func(t *testing.T) {
+ u := client.User.Create().SetEmail("lift-active@example.com").SetNick("u").
+ SetStatus(entuser.StatusActive).SetGroup(group).SaveX(ctx)
+ out, err := uc.LiftExpiredBan(ctx, u)
+ require.NoError(t, err)
+ require.Equal(t, entuser.StatusActive, out.Status)
+ })
+}
+
+func TestUpsertBanExpires(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ uc := NewUserClient(client)
+ future := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
+
+ u := client.User.Create().SetEmail("upsert-ban@example.com").SetNick("u").
+ SetStatus(entuser.StatusActive).SetGroup(group).SaveX(ctx)
+
+ // Ban with expiry and reason
+ u.Status = entuser.StatusManualBanned
+ u.BanExpires = &future
+ u.BanReason = "spam"
+ _, err := uc.Upsert(ctx, u, "", "")
+ require.NoError(t, err)
+ got := client.User.GetX(ctx, u.ID)
+ require.Equal(t, future, got.BanExpires.UTC())
+ require.Equal(t, "spam", got.BanReason)
+
+ // Unban clears the expiry and reason
+ u.Status = entuser.StatusActive
+ u.BanExpires = nil
+ u.BanReason = ""
+ _, err = uc.Upsert(ctx, u, "", "")
+ require.NoError(t, err)
+ got = client.User.GetX(ctx, u.ID)
+ require.Nil(t, got.BanExpires)
+ require.Equal(t, "", got.BanReason)
+}
diff --git a/inventory/client.go b/inventory/client.go
index 2a1ac1f4..223386c3 100644
--- a/inventory/client.go
+++ b/inventory/client.go
@@ -5,6 +5,7 @@ import (
rawsql "database/sql"
"database/sql/driver"
"fmt"
+ "strings"
"time"
"entgo.io/ent/dialect/sql"
@@ -37,6 +38,12 @@ func InitializeDBClient(l logging.Logger,
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
} else {
+ // Version is current, but additive schema changes (new columns)
+ // still need to reach existing databases between releases.
+ RepairHeatWaveNotSecondary(l, client, ctx)
+ if err := client.Schema.Create(ctx); err != nil {
+ return nil, fmt.Errorf("failed to apply additive schema changes: %w", err)
+ }
l.Info("Database schema is up to date.")
}
@@ -68,7 +75,7 @@ func NewRawEntClient(l logging.Logger, config conf.ConfigProvider) (*ent.Client,
// If Database connection string provided, use it directly.
if dbConfig.DatabaseURL != "" {
l.Info("Connect to database with connection string")
- client, err = sql.Open(string(confDBType), dbConfig.DatabaseURL)
+ client, err = sql.Open(string(confDBType), ensureMySQLParseTime(confDBType, dbConfig.DatabaseURL))
} else {
switch confDBType {
@@ -136,6 +143,25 @@ func NewRawEntClient(l logging.Logger, config conf.ConfigProvider) (*ent.Client,
return ent.NewClient(driverOpt), nil
}
+// ensureMySQLParseTime appends parseTime=True to a user-provided MySQL DSN
+// when absent. Without it the driver returns DATETIME columns as strings,
+// which fail to scan into time.Time fields (cloudreve/cloudreve#2872).
+func ensureMySQLParseTime(dbType conf.DBType, dsn string) string {
+ if dbType != conf.MySqlDB {
+ return dsn
+ }
+ idx := strings.IndexByte(dsn, '?')
+ if idx < 0 {
+ return dsn + "?parseTime=True"
+ }
+ for _, p := range strings.Split(dsn[idx+1:], "&") {
+ if strings.HasPrefix(strings.ToLower(p), "parsetime=") {
+ return dsn
+ }
+ }
+ return dsn + "&parseTime=True"
+}
+
type sqlite3Driver struct {
*sqlite.Driver
}
@@ -149,9 +175,18 @@ func (d sqlite3Driver) Open(name string) (conn driver.Conn, err error) {
if err != nil {
return
}
- _, err = conn.(sqlite3DriverConn).Exec("PRAGMA foreign_keys = ON;", nil)
- if err != nil {
- _ = conn.Close()
+ // WAL lets readers coexist with the writer; busy_timeout makes a brief
+ // lock wait instead of failing instantly with SQLITE_BUSY (#2917).
+ for _, pragma := range []string{
+ "PRAGMA journal_mode = WAL;",
+ "PRAGMA synchronous = NORMAL;",
+ "PRAGMA busy_timeout = 5000;",
+ "PRAGMA foreign_keys = ON;",
+ } {
+ if _, err = conn.(sqlite3DriverConn).Exec(pragma, nil); err != nil {
+ _ = conn.Close()
+ return
+ }
}
return
}
diff --git a/inventory/dav_account.go b/inventory/dav_account.go
index d2f7e3cc..3c069531 100644
--- a/inventory/dav_account.go
+++ b/inventory/dav_account.go
@@ -10,6 +10,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/samber/lo"
+ "strings"
)
type (
@@ -25,6 +26,11 @@ type (
GetByIDAndUserID(ctx context.Context, id, userID int) (*ent.DavAccount, error)
// Delete deletes the dav account.
Delete(ctx context.Context, id int) error
+ // UpdateURIPrefix rewrites the mount URIs of a user's dav accounts after
+ // a file rename or move: an account mounting exactly oldURI, or any path
+ // under it, is repointed to newURI. This keeps mounts working when the
+ // bound folder changes location.
+ UpdateURIPrefix(ctx context.Context, userID int, oldURI, newURI string) error
}
ListDavAccountArgs struct {
@@ -96,6 +102,35 @@ func (c *davAccountClient) Delete(ctx context.Context, id int) error {
return c.client.DavAccount.DeleteOneID(id).Exec(ctx)
}
+func (c *davAccountClient) UpdateURIPrefix(ctx context.Context, userID int, oldURI, newURI string) error {
+ oldURI = strings.TrimSuffix(oldURI, "/")
+ newURI = strings.TrimSuffix(newURI, "/")
+ if oldURI == newURI || userID == 0 {
+ return nil
+ }
+
+ accounts, err := c.client.DavAccount.Query().
+ Where(
+ davaccount.OwnerIDEQ(userID),
+ davaccount.Or(
+ davaccount.URIEQ(oldURI),
+ davaccount.URIHasPrefix(oldURI+"/"),
+ ),
+ ).
+ All(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to query dav accounts for URI rewrite: %w", err)
+ }
+
+ for _, account := range accounts {
+ updated := strings.TrimSuffix(newURI+strings.TrimPrefix(account.URI, oldURI), "/")
+ if err := c.client.DavAccount.UpdateOneID(account.ID).SetURI(updated).Exec(ctx); err != nil {
+ return fmt.Errorf("failed to rewrite dav account URI: %w", err)
+ }
+ }
+ return nil
+}
+
func (c *davAccountClient) List(ctx context.Context, args *ListDavAccountArgs) (*ListDavAccountResult, error) {
query := c.listQuery(args)
diff --git a/inventory/dav_account_test.go b/inventory/dav_account_test.go
new file mode 100644
index 00000000..204e94f2
--- /dev/null
+++ b/inventory/dav_account_test.go
@@ -0,0 +1,67 @@
+package inventory
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateURIPrefix(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ owner := client.User.Create().SetEmail("dav@example.com").SetNick("dav").SetGroup(group).SaveX(ctx)
+ other := client.User.Create().SetEmail("other@example.com").SetNick("other").SetGroup(group).SaveX(ctx)
+
+ seq := 0
+ mk := func(userID int, uri string) {
+ seq++
+ client.DavAccount.Create().
+ SetName("mount").
+ SetURI(uri).
+ SetPassword(fmt.Sprintf("pw-%d", seq)).
+ SetOptions(&boolset.BooleanSet{}).
+ SetOwnerID(userID).
+ SaveX(ctx)
+ }
+ getURIs := func(userID int) []string {
+ accounts, err := client.DavAccount.Query().All(ctx)
+ require.NoError(t, err)
+ var uris []string
+ for _, a := range accounts {
+ if a.OwnerID == userID {
+ uris = append(uris, a.URI)
+ }
+ }
+ return uris
+ }
+
+ mk(owner.ID, "my://hash/dir")
+ mk(owner.ID, "my://hash/dir/sub")
+ mk(owner.ID, "my://hash/dir-other")
+ mk(other.ID, "my://hash/dir")
+
+ c := NewDavAccountClient(client, "sqlite3", nil)
+ require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/dir", "my://hash/moved/dir"))
+
+ got := getURIs(owner.ID)
+ require.ElementsMatch(t, []string{"my://hash/moved/dir", "my://hash/moved/dir/sub", "my://hash/dir-other"}, got)
+ // Other user's account untouched.
+ require.Equal(t, []string{"my://hash/dir"}, getURIs(other.ID))
+
+ // Trailing-slash stored URI is normalized and rewritten.
+ mk(owner.ID, "my://hash/base/")
+ require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/base/", "my://hash/base2"))
+ require.Contains(t, getURIs(owner.ID), "my://hash/base2")
+
+ // Same-URI no-op and empty results are clean.
+ require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/x", "my://hash/x"))
+ require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/missing", "my://hash/y"))
+ require.NoError(t, c.UpdateURIPrefix(ctx, 0, "my://hash/dir", "my://hash/z"))
+}
diff --git a/inventory/debug/debug.go b/inventory/debug/debug.go
index e327f6d8..25934db5 100644
--- a/inventory/debug/debug.go
+++ b/inventory/debug/debug.go
@@ -5,7 +5,7 @@ import (
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql"
"fmt"
- "github.com/google/uuid"
+ "github.com/gofrs/uuid"
"time"
)
@@ -85,7 +85,7 @@ func (d *DebugDriver) Tx(ctx context.Context) (dialect.Tx, error) {
if err != nil {
return nil, err
}
- id := uuid.New().String()
+ id := uuid.Must(uuid.NewV4()).String()
d.log(ctx, fmt.Sprintf("driver.Tx(%s): started", id))
return &DebugTx{tx, id, d.log, ctx}, nil
}
@@ -102,7 +102,7 @@ func (d *DebugDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (dialect
if err != nil {
return nil, err
}
- id := uuid.New().String()
+ id := uuid.Must(uuid.NewV4()).String()
d.log(ctx, fmt.Sprintf("driver.BeginTx(%s): started", id))
return &DebugTx{tx, id, d.log, ctx}, nil
}
diff --git a/inventory/entity_props_test.go b/inventory/entity_props_test.go
new file mode 100644
index 00000000..c67c01ed
--- /dev/null
+++ b/inventory/entity_props_test.go
@@ -0,0 +1,52 @@
+package inventory
+
+import (
+ "context"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateEntityProps(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ policy := client.StoragePolicy.Create().
+ SetName("local").
+ SetType(string(types.PolicyTypeLocal)).
+ SetBucketName("bucket").
+ SetAccessKey("ak").
+ SetSecretKey("sk").
+ SetMaxSize(0).
+ SetDirNameRule("d").
+ SetFileNameRule("f").
+ SaveX(ctx)
+
+ e := client.Entity.Create().
+ SetType(int(types.EntityTypeVersion)).
+ SetSource("uploads/1/test.bin").
+ SetSize(100).
+ SetStoragePolicyEntities(policy.ID).
+ SaveX(ctx)
+
+ c := NewFileClient(client, "sqlite3", nil)
+
+ e.Props = &types.EntityProps{RecycleFailCount: 3}
+ require.NoError(t, c.UpdateEntityProps(ctx, e))
+
+ got := client.Entity.GetX(ctx, e.ID)
+ require.NotNil(t, got.Props)
+ require.Equal(t, 3, got.Props.RecycleFailCount)
+
+ // Other props survive a partial update.
+ e.Props.RecycleFailCount = 5
+ e.Props.UnlinkOnly = true
+ require.NoError(t, c.UpdateEntityProps(ctx, e))
+
+ got = client.Entity.GetX(ctx, e.ID)
+ require.Equal(t, 5, got.Props.RecycleFailCount)
+ require.True(t, got.Props.UnlinkOnly)
+}
diff --git a/inventory/entity_refcount_test.go b/inventory/entity_refcount_test.go
new file mode 100644
index 00000000..71923dcb
--- /dev/null
+++ b/inventory/entity_refcount_test.go
@@ -0,0 +1,79 @@
+package inventory
+
+import (
+ "context"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseReferenceCountFilter(t *testing.T) {
+ f, err := ParseReferenceCountFilter("stale")
+ require.NoError(t, err)
+ require.True(t, f.Stale)
+
+ f, err = ParseReferenceCountFilter("gt:2")
+ require.NoError(t, err)
+ require.Equal(t, "gt", f.Op)
+ require.Equal(t, 2, f.Value)
+
+ f, err = ParseReferenceCountFilter("eq:0")
+ require.NoError(t, err)
+ require.Equal(t, "eq", f.Op)
+
+ for _, bad := range []string{"", "gt", "xx:1", "gt:abc", "1"} {
+ _, err := ParseReferenceCountFilter(bad)
+ require.Error(t, err, "expr %q should be rejected", bad)
+ }
+}
+
+func TestListEntitiesReferenceCountFilter(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ p := client.StoragePolicy.Create().SetName("p").SetType("local").
+ SetStatus(storagepolicy.StatusActive).SaveX(ctx)
+ mk := func(name string, ref int) {
+ client.Entity.Create().SetType(0).SetSource(name).SetSize(1).
+ SetReferenceCount(ref).SetStoragePolicyEntities(p.ID).SaveX(ctx)
+ }
+ mk("a", 0)
+ mk("b", -1)
+ mk("c", 1)
+ mk("d", 3)
+
+ fc := NewFileClient(client, conf.SQLite3DB, nil)
+ args := &ListEntityParameters{PaginationArgs: &PaginationArgs{Page: 0, PageSize: 50}}
+
+ res, err := fc.ListEntities(ctx, args)
+ require.NoError(t, err)
+ require.Equal(t, 4, res.TotalItems)
+
+ stale, _ := ParseReferenceCountFilter("stale")
+ res, err = fc.ListEntities(ctx, &ListEntityParameters{
+ PaginationArgs: args.PaginationArgs, ReferenceCount: stale})
+ require.NoError(t, err)
+ require.Equal(t, 2, res.TotalItems)
+
+ gt, _ := ParseReferenceCountFilter("gt:1")
+ res, err = fc.ListEntities(ctx, &ListEntityParameters{
+ PaginationArgs: args.PaginationArgs, ReferenceCount: gt})
+ require.NoError(t, err)
+ require.Equal(t, 1, res.TotalItems)
+
+ lt, _ := ParseReferenceCountFilter("lt:1")
+ res, err = fc.ListEntities(ctx, &ListEntityParameters{
+ PaginationArgs: args.PaginationArgs, ReferenceCount: lt})
+ require.NoError(t, err)
+ require.Equal(t, 2, res.TotalItems)
+
+ eq, _ := ParseReferenceCountFilter("eq:1")
+ res, err = fc.ListEntities(ctx, &ListEntityParameters{
+ PaginationArgs: args.PaginationArgs, ReferenceCount: eq})
+ require.NoError(t, err)
+ require.Equal(t, 1, res.TotalItems)
+}
diff --git a/inventory/file.go b/inventory/file.go
index 3b344bd9..5f1abfde 100644
--- a/inventory/file.go
+++ b/inventory/file.go
@@ -3,6 +3,8 @@ package inventory
import (
"context"
"fmt"
+ "strconv"
+ "strings"
"time"
"entgo.io/ent/dialect/sql"
@@ -19,7 +21,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/gofrs/uuid"
"github.com/samber/lo"
- "golang.org/x/tools/container/intsets"
+ "math"
)
const (
@@ -60,6 +62,9 @@ type (
HasMetadata string
Shared bool
HasDirectLink bool
+ // Deleted filters by soft-delete state: nil lists everything (current
+ // behavior), true lists only trash, false only live files.
+ Deleted *bool
}
MetadataFilter struct {
@@ -90,6 +95,16 @@ type (
EntityType *types.EntityType
UserID int
StoragePolicyID int
+ ReferenceCount *ReferenceCountFilter
+ }
+
+ // ReferenceCountFilter compares entity reference_count against a value.
+ // Stale selects entities awaiting recycle (reference_count <= 0) and
+ // ignores Value.
+ ReferenceCountFilter struct {
+ Op string
+ Value int
+ Stale bool
}
ListEntityResult struct {
@@ -187,6 +202,8 @@ type FileClient interface {
RemoveStaleEntities(ctx context.Context, file *ent.File) (StorageDiff, error)
// RemoveEntitiesByID hard-delete entities by IDs.
RemoveEntitiesByID(ctx context.Context, ids ...int) (map[int]int64, error)
+ // UpdateEntityProps persists mutated EntityProps back to the given entities.
+ UpdateEntityProps(ctx context.Context, entities ...*ent.Entity) error
// CapEntities caps the number of entities of a given file. The oldest entities will be unlinked
// if entity count exceed limit.
CapEntities(ctx context.Context, file *ent.File, owner *ent.User, max int, entityType types.EntityType) (StorageDiff, error)
@@ -400,7 +417,7 @@ func (f *fileClient) CreateDirectLink(ctx context.Context, file int, name string
func (f *fileClient) GetByHashID(ctx context.Context, hashID string) (*ent.File, error) {
id, err := f.hasher.Decode(hashID, hashid.FileID)
if err != nil {
- return nil, fmt.Errorf("ailed to decode hash id %q: %w", hashID, err)
+ return nil, fmt.Errorf("failed to decode hash id %q: %w", hashID, err)
}
return withFileEagerLoading(ctx, f.client.File.Query().Where(file.ID(id))).First(ctx)
@@ -421,7 +438,7 @@ func (f *fileClient) SoftDelete(ctx context.Context, file *ent.File) error {
}
func (f *fileClient) RemoveEntitiesByID(ctx context.Context, ids ...int) (map[int]int64, error) {
- groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids)
+ groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids)
// storageReduced stores the relation between owner ID and storage reduced.
storageReduced := make(map[int]int64)
@@ -458,11 +475,20 @@ func (f *fileClient) RemoveEntitiesByID(ctx context.Context, ids ...int) (map[in
return storageReduced, nil
}
+func (f *fileClient) UpdateEntityProps(ctx context.Context, entities ...*ent.Entity) error {
+ for _, e := range entities {
+ if err := f.client.Entity.UpdateOne(e).SetProps(e.Props).Exec(ctx); err != nil {
+ return fmt.Errorf("failed to update props of entity %d: %w", e.ID, err)
+ }
+ }
+ return nil
+}
+
func (f *fileClient) StaleEntities(ctx context.Context, ids ...int) ([]*ent.Entity, error) {
res := make([]*ent.Entity, 0, len(ids))
if len(ids) > 0 {
// If explicit IDs are given, we can query them directly
- groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids)
+ groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids)
for _, group := range groups {
entities, err := f.client.Entity.Query().
Where(group).
@@ -490,7 +516,7 @@ func (f *fileClient) StaleEntities(ctx context.Context, ids ...int) ([]*ent.Enti
}
func (f *fileClient) DeleteByUser(ctx context.Context, uid int) error {
- batchSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10)
+ batchSize := capPageSize(f.maxSQlParam, math.MaxInt, 10)
for {
files, err := f.client.File.Query().
WithEntities().
@@ -538,7 +564,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ
})
for ref, entityGroup := range entitiesGrouped {
- entityPageGroup, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, entityGroup)
+ entityPageGroup, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, entityGroup)
for _, group := range entityPageGroup {
if err := f.client.Entity.Update().
Where(group).
@@ -550,7 +576,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ
}
// 2. Filter out entities with <=0 reference count, Update recycle options for above entities;
- entityGroup, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, uniqueEntities)
+ entityGroup, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, uniqueEntities)
toBeRecycled := make([]*ent.Entity, 0, len(entities))
for _, group := range entityGroup {
e, err := f.client.Entity.Query().Where(group).Where(entity.ReferenceCountLTE(0)).All(ctx)
@@ -562,7 +588,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ
}
// 3. Update recycle options for above entities;
- pageSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10)
+ pageSize := capPageSize(f.maxSQlParam, math.MaxInt, 10)
chunks := lo.Chunk(lo.Map(toBeRecycled, func(item *ent.Entity, index int) int {
return item.ID
}), max(pageSize, 1))
@@ -576,7 +602,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ
}
hardDeleteCtx := schema.SkipSoftDelete(ctx)
- fileGroups, chunks := f.batchInCondition(intsets.MaxInt, 10, 1,
+ fileGroups, chunks := f.batchInCondition(math.MaxInt, 10, 1,
lo.Map(files, func(file *ent.File, index int) int {
return file.ID
}),
@@ -608,7 +634,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ
func (f *fileClient) Copy(ctx context.Context, args *CopyParameter) (map[int][]*ent.File, StorageDiff, error) {
files := args.Files
dstMap := args.DstMap
- pageSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10)
+ pageSize := capPageSize(f.maxSQlParam, math.MaxInt, 10)
// 1. Copy files and metadata
copyFileStm := lo.Map(files, func(file *ent.File, index int) *ent.FileCreate {
@@ -672,7 +698,7 @@ func (f *fileClient) Copy(ctx context.Context, args *CopyParameter) (map[int][]*
return entity.ID, true
})
- entityBatch, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids)
+ entityBatch, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids)
entityStm = append(entityStm, lo.Map(entityBatch, func(batch predicate.Entity, index int) *ent.EntityUpdate {
return f.client.Entity.Update().Where(batch).AddReferenceCount(1).AddFile(newFile)
})...)
@@ -740,7 +766,7 @@ func (f *fileClient) RemoveMetadata(ctx context.Context, file *ent.File, keys ..
return nil
}
ctx = schema.SkipSoftDelete(ctx)
- groups, _ := f.batchInConditionMetadataName(intsets.MaxInt, 10, 1, keys)
+ groups, _ := f.batchInConditionMetadataName(math.MaxInt, 10, 1, keys)
for _, group := range groups {
if _, err := f.client.Metadata.Delete().Where(metadata.FileID(file.ID), group).Exec(ctx); err != nil {
return fmt.Errorf("failed to remove metadata: %v", err)
@@ -982,7 +1008,7 @@ func (f *fileClient) CreateEntity(ctx context.Context, file *ent.File, args *Ent
}
func (f *fileClient) SetParent(ctx context.Context, files []*ent.File, parent *ent.File) error {
- groups, _ := f.batchInCondition(intsets.MaxInt, 10, 1, lo.Map(files, func(file *ent.File, index int) int {
+ groups, _ := f.batchInCondition(math.MaxInt, 10, 1, lo.Map(files, func(file *ent.File, index int) int {
return file.ID
}))
for _, group := range groups {
@@ -1107,7 +1133,7 @@ func (f *fileClient) Rename(ctx context.Context, original *ent.File, newName str
}
func (f *fileClient) GetEntitiesByIDs(ctx context.Context, ids []int, page int) ([]*ent.Entity, int, error) {
- groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids)
+ groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids)
if page >= len(groups) || page < 0 {
return nil, -1, fmt.Errorf("page out of range")
}
@@ -1133,7 +1159,7 @@ func (f *fileClient) GetByID(ctx context.Context, ids int) (*ent.File, error) {
}
func (f *fileClient) GetByIDs(ctx context.Context, ids []int, page int) ([]*ent.File, int, error) {
- groups, _ := f.batchInCondition(intsets.MaxInt, 10, 1, ids)
+ groups, _ := f.batchInCondition(math.MaxInt, 10, 1, ids)
if page >= len(groups) || page < 0 {
return nil, -1, fmt.Errorf("page out of range")
}
@@ -1177,6 +1203,15 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile
query = query.Where(file.HasDirectLinksWith(directlink.DeletedAtIsNil()))
}
+ if args.Deleted != nil {
+ if *args.Deleted {
+ // Trash = files detached from their parent by soft-delete.
+ query = query.Where(file.Not(file.HasParent()))
+ } else {
+ query = query.Where(file.HasParent())
+ }
+ }
+
query.Order(getFileOrderOption(&ListFileParameters{
PaginationArgs: args.PaginationArgs,
})...)
@@ -1202,6 +1237,25 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile
}, nil
}
+// ParseReferenceCountFilter parses a reference-count filter expression:
+// "stale" for entities awaiting recycle, or "gt:N" / "lt:N" / "eq:N".
+func ParseReferenceCountFilter(expr string) (*ReferenceCountFilter, error) {
+ if expr == "stale" {
+ return &ReferenceCountFilter{Stale: true}, nil
+ }
+
+ op, valStr, ok := strings.Cut(expr, ":")
+ if !ok || (op != "gt" && op != "lt" && op != "eq") {
+ return nil, fmt.Errorf("invalid reference count filter %q", expr)
+ }
+
+ v, err := strconv.Atoi(valStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid reference count filter %q: %w", expr, err)
+ }
+ return &ReferenceCountFilter{Op: op, Value: v}, nil
+}
+
func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameters) (*ListEntityResult, error) {
query := f.client.Entity.Query()
if args.EntityType != nil {
@@ -1216,6 +1270,19 @@ func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameter
query = query.Where(entity.StoragePolicyEntities(args.StoragePolicyID))
}
+ if args.ReferenceCount != nil {
+ switch {
+ case args.ReferenceCount.Stale:
+ query = query.Where(entity.ReferenceCountLTE(0))
+ case args.ReferenceCount.Op == "gt":
+ query = query.Where(entity.ReferenceCountGT(args.ReferenceCount.Value))
+ case args.ReferenceCount.Op == "lt":
+ query = query.Where(entity.ReferenceCountLT(args.ReferenceCount.Value))
+ case args.ReferenceCount.Op == "eq":
+ query = query.Where(entity.ReferenceCountEQ(args.ReferenceCount.Value))
+ }
+ }
+
query.Order(getEntityOrderOption(args)...)
// Count total items
diff --git a/inventory/heatwave_test.go b/inventory/heatwave_test.go
new file mode 100644
index 00000000..780afe1a
--- /dev/null
+++ b/inventory/heatwave_test.go
@@ -0,0 +1,43 @@
+package inventory
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+const heatWaveDDL = "CREATE TABLE `groups` (\n" +
+ " `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+ " `created_at` datetime(3) NULL,\n" +
+ " `name` varchar(255) NOT NULL,\n" +
+ " `permissions` blob NOT NULL COMMENT 'json blob' NOT SECONDARY,\n" +
+ " `note` varchar(255) NULL DEFAULT 'not secondary',\n" +
+ " PRIMARY KEY (`id`),\n" +
+ " UNIQUE KEY `groups_name` (`name`),\n" +
+ " KEY `groups_created_at` (`created_at`)\n" +
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
+
+func TestExtractColumnDef(t *testing.T) {
+ t.Run("strips NOT SECONDARY keeping the rest", func(t *testing.T) {
+ def, err := extractColumnDef(heatWaveDDL, "permissions")
+ require.NoError(t, err)
+ require.Equal(t, "`permissions` blob NOT NULL COMMENT 'json blob'", def)
+ })
+
+ t.Run("leaves unrelated columns intact", func(t *testing.T) {
+ def, err := extractColumnDef(heatWaveDDL, "note")
+ require.NoError(t, err)
+ // The string 'not secondary' inside a DEFAULT literal must survive.
+ require.Equal(t, "`note` varchar(255) NULL DEFAULT 'not secondary'", def)
+ })
+
+ t.Run("index lines are not matched", func(t *testing.T) {
+ _, err := extractColumnDef(heatWaveDDL, "groups_name")
+ require.Error(t, err)
+ })
+
+ t.Run("missing column errors", func(t *testing.T) {
+ _, err := extractColumnDef(heatWaveDDL, "nope")
+ require.Error(t, err)
+ })
+}
diff --git a/inventory/migration.go b/inventory/migration.go
index 993e71ef..a03c254c 100644
--- a/inventory/migration.go
+++ b/inventory/migration.go
@@ -41,6 +41,8 @@ func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion st
func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver, requiredDbVersion string) error {
l.Info("Start initializing database schema...")
l.Info("Creating basic table schema...")
+ repairLegacyUserGroupColumn(l, client, ctx)
+ RepairHeatWaveNotSecondary(l, client, ctx)
if err := client.Schema.Create(ctx); err != nil {
return fmt.Errorf("Failed creating schema resources: %w", err)
}
@@ -67,6 +69,154 @@ func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache
return nil
}
+// repairLegacyUserGroupColumn backfills users.group_users before ent's
+// auto-migration. v3 bound user groups via a nullable group_id column (or no
+// column at all for groupless users); the v4 schema requires NOT NULL
+// group_users, so the schema copy fails mid-upgrade on old databases
+// (upstream #2934). All statements are error-tolerant: on a fresh install
+// the users/groups tables simply do not exist yet and every statement no-ops.
+func repairLegacyUserGroupColumn(l logging.Logger, client *ent.Client, ctx context.Context) {
+ if _, err := client.ExecContext(ctx, `ALTER TABLE users ADD COLUMN group_users INTEGER`); err != nil {
+ l.Debug("Skip adding group_users column: %s", err)
+ }
+
+ // Prefer the legacy group_id binding when present; otherwise fall back to
+ // the first non-admin group (id 1 is the seeded admin group), then any group.
+ queries := []string{
+ `UPDATE users SET group_users = COALESCE(group_id, (SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`,
+ `UPDATE users SET group_users = COALESCE((SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`,
+ }
+ for _, q := range queries {
+ _, err := client.ExecContext(ctx, q)
+ if err == nil {
+ return
+ }
+ l.Debug("Skip legacy group_users backfill: %s", err)
+ }
+}
+
+// RepairHeatWaveNotSecondary strips the MySQL HeatWave `NOT SECONDARY` column
+// attribute before ent's schema migration. Atlas treats it as an unknown EXTRA
+// value and aborts schema inspection (upstream #3452). For every flagged column
+// we replay its own definition from SHOW CREATE TABLE minus the attribute via
+// ALTER TABLE ... MODIFY COLUMN, which clears it without touching the logical
+// schema. On non-MySQL backends the INFORMATION_SCHEMA query fails and the whole
+// function is a no-op.
+func RepairHeatWaveNotSecondary(l logging.Logger, client *ent.Client, ctx context.Context) {
+ rows, err := client.QueryContext(ctx,
+ `SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND UPPER(EXTRA) LIKE '%NOT SECONDARY%'`)
+ if err != nil {
+ l.Debug("Skip HeatWave NOT SECONDARY repair: %s", err)
+ return
+ }
+
+ var flagged [][2]string
+ for rows.Next() {
+ var table, column string
+ if err := rows.Scan(&table, &column); err != nil {
+ l.Debug("Skip HeatWave NOT SECONDARY repair scan: %s", err)
+ rows.Close()
+ return
+ }
+ flagged = append(flagged, [2]string{table, column})
+ }
+ rows.Close()
+
+ for _, tc := range flagged {
+ def, err := heatWaveColumnDef(client, ctx, tc[0], tc[1])
+ if err != nil {
+ l.Warning("Skip NOT SECONDARY repair for %s.%s: %s", tc[0], tc[1], err)
+ continue
+ }
+
+ stm := fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s",
+ strings.ReplaceAll(tc[0], "`", "``"), def)
+ if _, err := client.ExecContext(ctx, stm); err != nil {
+ l.Warning("Failed to strip NOT SECONDARY on %s.%s: %s", tc[0], tc[1], err)
+ continue
+ }
+ l.Info("Stripped HeatWave NOT SECONDARY attribute on %s.%s", tc[0], tc[1])
+ }
+}
+
+// heatWaveColumnDef extracts a column's full definition from SHOW CREATE TABLE
+// and returns it without the `NOT SECONDARY` attribute, ready for use after
+// ALTER TABLE ... MODIFY COLUMN.
+func heatWaveColumnDef(client *ent.Client, ctx context.Context, table, column string) (string, error) {
+ rows, err := client.QueryContext(ctx,
+ fmt.Sprintf("SHOW CREATE TABLE `%s`", strings.ReplaceAll(table, "`", "``")))
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+
+ var name, ddl string
+ if !rows.Next() {
+ return "", fmt.Errorf("table %s not found", table)
+ }
+ if err := rows.Scan(&name, &ddl); err != nil {
+ return "", err
+ }
+
+ return extractColumnDef(ddl, column)
+}
+
+// extractColumnDef finds `column`'s definition line in SHOW CREATE TABLE output
+// and returns it without the `NOT SECONDARY` attribute.
+func extractColumnDef(ddl, column string) (string, error) {
+ quoted := "`" + strings.ReplaceAll(column, "`", "``") + "`"
+ for _, line := range strings.Split(ddl, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, quoted+" ") {
+ continue
+ }
+ def := stripNotSecondary(strings.TrimSuffix(line, ","))
+ if findNotSecondary(def) >= 0 {
+ return "", fmt.Errorf("failed to strip NOT SECONDARY from column %s", column)
+ }
+ return def, nil
+ }
+ return "", fmt.Errorf("column %s not found in table definition", column)
+}
+
+func stripNotSecondary(def string) string {
+ // Remove the `NOT SECONDARY` attribute clause, but not the same words
+ // inside a quoted literal (e.g. DEFAULT 'not secondary').
+ out := def
+ for {
+ idx := findNotSecondary(out)
+ if idx < 0 {
+ return strings.TrimSpace(out)
+ }
+ out = out[:idx] + out[idx+len("NOT SECONDARY"):]
+ }
+}
+
+// findNotSecondary returns the index of the first `NOT SECONDARY` occurrence
+// outside single-quoted string literals, or -1.
+func findNotSecondary(def string) int {
+ const marker = "NOT SECONDARY"
+ upper := strings.ToUpper(def)
+ inQuote := false
+ for i := 0; i+len(marker) <= len(upper); i++ {
+ if upper[i] == '\'' {
+ if inQuote && i+1 < len(upper) && upper[i+1] == '\'' {
+ i++ // escaped '' inside a literal
+ continue
+ }
+ inQuote = !inQuote
+ }
+ if !inQuote && strings.HasPrefix(upper[i:], marker) {
+ end := i + len(marker)
+ if (i == 0 || upper[i-1] == ' ') && (end == len(upper) || upper[end] == ' ' || upper[end] == ',') {
+ return i
+ }
+ }
+ }
+ return -1
+}
+
func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver) {
// clean kv cache
if err := kv.DeleteAll(); err != nil {
@@ -288,11 +438,9 @@ func migrateMasterNode(l logging.Logger, client *ent.Client, ctx context.Context
const (
OAuthClientDesktopGUID = "393a1839-f52e-498e-9972-e77cc2241eee"
- OAuthClientDesktopSecret = "8GaQIu3lOSdqYoDHi9cR8IZ4pvuMH8ya"
OAuthClientDesktopName = "application:oauth.desktop"
OAuthClientDesktopRedirectURI = "/callback/desktop"
OAuthClientiOSGUID = "220db97a-44a3-44f7-99b6-d767262b4daa"
- OAuthClientiOSSecret = "1kxOW4IyVOkPlsKCnTwzfHyP8XrbpfaF"
OAuthClientiOSName = "application:setting.iOSApp"
OAuthClientiOSRedirectURI = "/callback/ios"
)
@@ -316,7 +464,7 @@ func migrateOAuthClientiOS(l logging.Logger, client *ent.Client, ctx context.Con
}
if _, err := client.OAuthClient.Create().
SetGUID(OAuthClientiOSGUID).
- SetSecret(OAuthClientiOSSecret).
+ SetSecret("").
SetName(OAuthClientiOSName).
SetRedirectUris([]string{OAuthClientiOSRedirectURI}).
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "UserSecurityInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write", "Finance.Write", "DavAccount.Write"}).
@@ -337,7 +485,7 @@ func migrateOAuthClientDesktop(l logging.Logger, client *ent.Client, ctx context
if _, err := client.OAuthClient.Create().
SetGUID(OAuthClientDesktopGUID).
- SetSecret(OAuthClientDesktopSecret).
+ SetSecret("").
SetName(OAuthClientDesktopName).
SetRedirectUris([]string{OAuthClientDesktopRedirectURI}).
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write"}).
@@ -578,6 +726,47 @@ var patches = []Patch{
return nil
},
},
+ {
+ Name: "apply_default_model3d_viewer",
+ EndVersion: "4.20.0",
+ Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
+ fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to query file_viewers setting: %w", err)
+ }
+
+ var fileViewers []types.ViewerGroup
+ if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil {
+ return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err)
+ }
+
+ for _, viewer := range fileViewers[0].Viewers {
+ if viewer.ID == "model3d" {
+ return nil
+ }
+ }
+
+ var defaultModelViewer types.Viewer
+ for _, viewer := range defaultFileViewers[0].Viewers {
+ if viewer.ID == "model3d" {
+ defaultModelViewer = viewer
+ break
+ }
+ }
+
+ fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultModelViewer)
+ newFileViewersSetting, err := json.Marshal(fileViewers)
+ if err != nil {
+ return fmt.Errorf("failed to marshal file_viewers setting: %w", err)
+ }
+
+ if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil {
+ return fmt.Errorf("failed to update file_viewers setting: %w", err)
+ }
+
+ return nil
+ },
+ },
{
Name: "reset_secret_key",
EndVersion: "4.13.0",
@@ -588,6 +777,23 @@ var patches = []Patch{
return fmt.Errorf("failed to update secret_key setting: %w", err)
}
+ return nil
+ },
+ },
+ {
+ // Built-in desktop/iOS clients are public clients shipped in binaries —
+ // a shared hardcoded secret authenticates nothing. Blank it so token
+ // exchange relies on PKCE (RFC 8252) instead.
+ Name: "oauth_builtin_public_clients",
+ EndVersion: "4.20.0",
+ Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
+ if _, err := client.OAuthClient.Update().
+ Where(oauthclient.GUIDIn(OAuthClientDesktopGUID, OAuthClientiOSGUID)).
+ SetSecret("").
+ Save(ctx); err != nil {
+ return fmt.Errorf("failed to clear built-in OAuth client secrets: %w", err)
+ }
+
return nil
},
},
@@ -620,7 +826,7 @@ func applyPatches(l logging.Logger, client *ent.Client, ctx context.Context, req
return fmt.Errorf("failed to parse required version %s: %w", requiredDbVersion, err)
}
- if latestAppliedVersion == nil || requiredVersion.Compare(requiredVersion) > 0 {
+ if latestAppliedVersion == nil || requiredVersion.Compare(latestAppliedVersion) > 0 {
latestAppliedVersion = requiredVersion
}
diff --git a/inventory/migration_test.go b/inventory/migration_test.go
new file mode 100644
index 00000000..01224345
--- /dev/null
+++ b/inventory/migration_test.go
@@ -0,0 +1,97 @@
+package inventory
+
+import (
+ "context"
+ rawsql "database/sql"
+ "path/filepath"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/stretchr/testify/require"
+)
+
+// legacyV3DB builds a minimal v3-shaped sqlite database: users bind their
+// group via a nullable group_id column, and groupless users carry NULL.
+func legacyV3DB(t *testing.T, dbPath string) {
+ t.Helper()
+ raw, err := rawsql.Open("sqlite3", "file:"+dbPath)
+ require.NoError(t, err)
+ defer raw.Close()
+
+ stmts := []string{
+ `CREATE TABLE groups (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, name TEXT, permissions BLOB)`,
+ `INSERT INTO groups (id, created_at, updated_at, name, permissions) VALUES
+ (1, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'Admin', X'00'),
+ (2, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'User', X'00')`,
+ `CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ created_at DATETIME,
+ updated_at DATETIME,
+ deleted_at DATETIME,
+ email VARCHAR(100),
+ nick VARCHAR(100),
+ password VARCHAR(255),
+ status VARCHAR(20),
+ storage BIGINT,
+ group_id INTEGER
+ )`,
+ `INSERT INTO users (created_at, updated_at, email, nick, status, storage, group_id) VALUES
+ ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'admin@x', 'admin', 'active', 0, 1),
+ ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'user@x', 'user', 'active', 0, 2),
+ ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'groupless@x', 'groupless', 'active', 0, NULL)`,
+ }
+ for _, q := range stmts {
+ _, err := raw.Exec(q)
+ require.NoError(t, err, q)
+ }
+}
+
+// TestRepairLegacyUserGroupColumn reproduces upstream #2934: upgrading a v3
+// database fails inside ent's copy-migration because group_users is NOT NULL
+// in v4 while v3 rows can be groupless. The repair backfills the column so
+// Schema.Create succeeds.
+func TestRepairLegacyUserGroupColumn(t *testing.T) {
+ ctx := context.Background()
+ l := logging.NewConsoleLogger(logging.LevelError)
+
+ openClient := func(t *testing.T) *ent.Client {
+ dbPath := filepath.Join(t.TempDir(), "legacy.db")
+ legacyV3DB(t, dbPath)
+ client, err := ent.Open("sqlite3", "file:"+dbPath)
+ require.NoError(t, err)
+ return client
+ }
+
+ t.Run("schema create fails without repair", func(t *testing.T) {
+ client := openClient(t)
+ defer client.Close()
+ err := client.Schema.Create(ctx)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "group_users")
+ })
+
+ t.Run("repair backfills and migration succeeds", func(t *testing.T) {
+ client := openClient(t)
+ defer client.Close()
+
+ repairLegacyUserGroupColumn(l, client, ctx)
+ require.NoError(t, client.Schema.Create(ctx))
+
+ rows, err := client.QueryContext(ctx, `SELECT id, group_users FROM users ORDER BY id`)
+ require.NoError(t, err)
+ defer rows.Close()
+
+ got := map[int]int{}
+ for rows.Next() {
+ var id, gid int
+ require.NoError(t, rows.Scan(&id, &gid))
+ got[id] = gid
+ }
+ require.NoError(t, rows.Err())
+
+ // group_id bindings preserved; groupless user lands on the
+ // non-admin group (id 2), not the admin group.
+ require.Equal(t, map[int]int{1: 1, 2: 2, 3: 2}, got)
+ })
+}
diff --git a/inventory/mysql_dsn_test.go b/inventory/mysql_dsn_test.go
new file mode 100644
index 00000000..1ae5e959
--- /dev/null
+++ b/inventory/mysql_dsn_test.go
@@ -0,0 +1,27 @@
+package inventory
+
+import (
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnsureMySQLParseTime(t *testing.T) {
+ dsn := "user:pass@tcp(127.0.0.1:3306)/cloudreve"
+ require.Equal(t, dsn+"?parseTime=True", ensureMySQLParseTime(conf.MySqlDB, dsn))
+
+ withParams := dsn + "?charset=utf8mb4"
+ require.Equal(t, withParams+"&parseTime=True", ensureMySQLParseTime(conf.MySqlDB, withParams))
+
+ existing := dsn + "?charset=utf8mb4&parseTime=false"
+ require.Equal(t, existing, ensureMySQLParseTime(conf.MySqlDB, existing))
+
+ urlForm := "mysql://user:pass@host:3306/cloudreve?tls=true"
+ require.Equal(t, urlForm+"&parseTime=True", ensureMySQLParseTime(conf.MySqlDB, urlForm))
+
+ // Non-MySQL DSNs pass through untouched.
+ pg := "postgresql://user:pass@host:5432/cloudreve?sslmode=require"
+ require.Equal(t, pg, ensureMySQLParseTime(conf.PostgresDB, pg))
+ require.Equal(t, dsn, ensureMySQLParseTime(conf.SQLiteDB, dsn))
+}
diff --git a/inventory/policy.go b/inventory/policy.go
index 285c01b1..9efc14c4 100644
--- a/inventory/policy.go
+++ b/inventory/policy.go
@@ -125,6 +125,11 @@ func (c *storagePolicyClient) Upsert(ctx context.Context, policy *ent.StoragePol
SetSettings(policy.Settings).
SetNillableNodeID(nodeId)
+ // Status is optional in the update payload; empty keeps the stored value.
+ if policy.Status != "" {
+ updateQuery.SetStatus(policy.Status)
+ }
+
if policy.Type != types.PolicyTypeOd {
updateQuery.SetAccessKey(policy.AccessKey)
}
@@ -146,7 +151,9 @@ func (c *storagePolicyClient) GetByGroup(ctx context.Context, group *ent.Group)
val, skipCache := ctx.Value(SkipStoragePolicyCache{}).(bool)
skipCache = skipCache && val
- res, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryStoragePolicies(group)).WithNode().First(ctx)
+ res, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryStoragePolicies(group)).
+ Where(storagepolicy.StatusEQ(storagepolicy.StatusActive)).
+ WithNode().First(ctx)
if err != nil {
return nil, fmt.Errorf("get storage policies: %w", err)
}
diff --git a/inventory/policy_suspend_test.go b/inventory/policy_suspend_test.go
new file mode 100644
index 00000000..50f6f300
--- /dev/null
+++ b/inventory/policy_suspend_test.go
@@ -0,0 +1,42 @@
+package inventory
+
+import (
+ "context"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetByGroupSkipsSuspended(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ pc := NewStoragePolicyClient(client, nil)
+
+ t.Run("suspended group policy yields not found", func(t *testing.T) {
+ p := client.StoragePolicy.Create().
+ SetName("suspended").SetType("local").
+ SetStatus(storagepolicy.StatusSuspended).SaveX(ctx)
+ group := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).
+ SetStoragePolicies(p).SaveX(ctx)
+
+ _, err := pc.GetByGroup(ctx, group)
+ require.Error(t, err)
+ })
+
+ t.Run("active group policy returned", func(t *testing.T) {
+ p := client.StoragePolicy.Create().
+ SetName("active").SetType("local").
+ SetStatus(storagepolicy.StatusActive).SaveX(ctx)
+ group := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).
+ SetStoragePolicies(p).SaveX(ctx)
+
+ got, err := pc.GetByGroup(ctx, group)
+ require.NoError(t, err)
+ require.Equal(t, "active", got.Name)
+ })
+}
diff --git a/inventory/setting.go b/inventory/setting.go
index 064a6d2d..0b6a048a 100644
--- a/inventory/setting.go
+++ b/inventory/setting.go
@@ -26,7 +26,7 @@ type (
Get(ctx context.Context, name string) (string, error)
// Set sets a setting value to DB.
Set(ctx context.Context, settings map[string]string) error
- // Gets gets multiple setting values from DB, returns error if any setting cannot be found.
+ // Gets gets multiple setting values from DB, returns a map of found settings; names with no matching setting are omitted.
Gets(ctx context.Context, names []string) (map[string]string, error)
}
)
@@ -294,6 +294,12 @@ var (
DisplayName: "fileManager.imageViewer",
Exts: []string{"bmp", "png", "gif", "jpg", "jpeg", "svg", "webp", "heic", "heif"},
},
+ {
+ ID: "model3d",
+ Type: types.ViewerTypeBuiltin,
+ DisplayName: "fileManager.modelViewer",
+ Exts: []string{"fbx", "obj", "gltf", "glb"},
+ },
{
ID: "monaco",
Type: types.ViewerTypeBuiltin,
@@ -558,6 +564,8 @@ var DefaultSettings = map[string]string{
"sso_client_secret": "",
"sso_scopes": "",
"sso_register_enabled": "1",
+ "sso_auto_redirect": "0",
+ "download_cdn_routes": "",
"email_filter_mode": "0",
"email_filter_list": "",
"email_disable_subaddress": "0",
diff --git a/inventory/task.go b/inventory/task.go
index 81edf774..0df40883 100644
--- a/inventory/task.go
+++ b/inventory/task.go
@@ -41,10 +41,15 @@ type TaskClient interface {
GetTaskByID(ctx context.Context, taskID int) (*ent.Task, error)
// SetCompleteByID sets the task with the given ID to complete.
SetCompleteByID(ctx context.Context, taskID int) error
+ // SetStatusByID sets the status of the task with the given ID.
+ SetStatusByID(ctx context.Context, taskID int, status task.Status) error
// List returns a list of tasks with the given args.
List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error)
// DeleteByIDs deletes the tasks with the given IDs.
DeleteByIDs(ctx context.Context, ids ...int) error
+
+ // HideByIDs marks tasks owned by the given user as hidden.
+ HideByIDs(ctx context.Context, ownerID int, ids ...int) error
// DeleteBy deletes the tasks with the given args.
DeleteBy(ctx context.Context, args *DeleteTaskArgs) error
}
@@ -56,6 +61,8 @@ type (
Status []task.Status
UserID int
CorrelationID *uuid.UUID
+ // ExcludeHidden filters out tasks hidden by their owner.
+ ExcludeHidden bool
}
ListTaskResult struct {
@@ -122,6 +129,15 @@ func (c *taskClient) DeleteByIDs(ctx context.Context, ids ...int) error {
return err
}
+func (c *taskClient) HideByIDs(ctx context.Context, ownerID int, ids ...int) error {
+ _, err := c.client.Task.
+ Update().
+ Where(task.IDIn(ids...), task.UserTasks(ownerID)).
+ SetHidden(true).
+ Save(ctx)
+ return err
+}
+
func (c *taskClient) DeleteBy(ctx context.Context, args *DeleteTaskArgs) error {
query := c.client.Task.
Delete().
@@ -199,6 +215,13 @@ func (c *taskClient) SetCompleteByID(ctx context.Context, taskID int) error {
return err
}
+func (c *taskClient) SetStatusByID(ctx context.Context, taskID int, status task.Status) error {
+ _, err := c.client.Task.UpdateOneID(taskID).
+ SetStatus(status).
+ Save(ctx)
+ return err
+}
+
func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error) {
q := c.client.Task.Query()
if args.UserID != 0 {
@@ -217,6 +240,10 @@ func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskRes
q.Where(task.CorrelationID(*args.CorrelationID))
}
+ if args.ExcludeHidden {
+ q.Where(task.Hidden(false))
+ }
+
q = withTaskEagerLoading(ctx, q)
var (
tasks []*ent.Task
diff --git a/inventory/types/types.go b/inventory/types/types.go
index d6162ceb..3787b739 100644
--- a/inventory/types/types.go
+++ b/inventory/types/types.go
@@ -17,6 +17,17 @@ type (
DisableViewSync bool `json:"disable_view_sync,omitempty"`
FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"`
ShareLinksInProfile ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
+ // ShareDefaultPrivate overrides the site-wide private-share default
+ // for this user. nil means inherit the site default.
+ ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
+ // PreferredViewers maps file extensions (without dot, lowercase) to
+ // viewer IDs chosen via "always open with". Synced server-side so the
+ // preference follows the user across devices.
+ PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
+ // TrashRetention overrides the group's trash retention in seconds.
+ // 0 means inherit the group setting. Applied when a file is moved to
+ // trash — already-trashed files keep their original expiry.
+ TrashRetention int `json:"trash_retention,omitempty"`
}
ShareLinksInProfileLevel string
@@ -36,6 +47,9 @@ type (
MaxWalkedFiles int `json:"max_walked_files,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
RedirectedSource bool `json:"redirected_source,omitempty"`
+ // LoginIPWhitelist restricts sign-in to the given IPs/CIDR ranges.
+ // Empty means no restriction.
+ LoginIPWhitelist []string `json:"login_ip_whitelist,omitempty"`
}
// PolicySetting 非公有的存储策略属性
@@ -50,6 +64,10 @@ type (
NameRegexp string `json:"file_regexp,omitempty"`
// IsNameRegexp Whether above regexp is a deny list.
IsNameRegexpDenyList bool `json:"is_name_regexp_deny_list,omitempty"`
+ // AllowNativeName permits characters only illegal on Windows
+ // filesystems (:*?"<>|) in file names. Path separators and dot-names
+ // stay illegal (#3065).
+ AllowNativeName bool `json:"allow_native_name,omitempty"`
// OauthRedirect Oauth 重定向地址
OauthRedirect string `json:"od_redirect,omitempty"`
// CustomProxy whether to use custom-proxy to get file content
@@ -179,6 +197,10 @@ type (
EntityProps struct {
UnlinkOnly bool `json:"unlink_only,omitempty"`
EncryptMetadata *EncryptMetadata `json:"encrypt_metadata,omitempty"`
+ // RecycleFailCount tracks consecutive driver-delete failures during
+ // entity recycling. Entities reaching the threshold are force-removed
+ // from the DB so one un-deletable blob cannot stall the sweep forever.
+ RecycleFailCount int `json:"recycle_fail_count,omitempty"`
}
Cipher string
@@ -233,6 +255,9 @@ type (
PreviewOnly bool `json:"preview_only,omitempty"`
// Whether share visitors can upload but cannot list or download (drop box)
UploadOnly bool `json:"upload_only,omitempty"`
+ // Owner-defined note/alias for identifying the share in My Shares;
+ // never exposed to share visitors (#3570).
+ Note string `json:"note,omitempty"`
}
OAuthClientProps struct {
@@ -269,8 +294,48 @@ const (
GroupPermissionSetExplicitUser_placeholder
GroupPermissionIgnoreFileOwnership // not used
GroupPermissionUniqueRedirectDirectLink
+ // GroupPermissionWebDAVReadOnly restricts the group's WebDAV access to
+ // read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK,
+ // PROPPATCH) are rejected even if the group's WebDAV access is enabled.
+ GroupPermissionWebDAVReadOnly
+ // Delegated admin section permissions. A group with any of these bits —
+ // but without GroupPermissionIsAdmin — is a delegated administrator that
+ // can only access the corresponding admin sections. GroupPermissionIsAdmin
+ // implies all sections.
+ GroupPermissionAdminUsers
+ GroupPermissionAdminGroups
+ GroupPermissionAdminFiles
+ GroupPermissionAdminShares
+ GroupPermissionAdminStorage
+ GroupPermissionAdminQueue
+ GroupPermissionAdminSettings
+ GroupPermissionAdminPayment
+ GroupPermissionAdminEvents
+ GroupPermissionAdminReports
)
+// DelegatedAdminPermissions lists every per-section admin permission bit.
+// GroupPermissionIsAdmin implies all of them.
+func DelegatedAdminPermissions() []GroupPermission {
+ return []GroupPermission{
+ GroupPermissionAdminUsers,
+ GroupPermissionAdminGroups,
+ GroupPermissionAdminFiles,
+ GroupPermissionAdminShares,
+ GroupPermissionAdminStorage,
+ GroupPermissionAdminQueue,
+ GroupPermissionAdminSettings,
+ GroupPermissionAdminPayment,
+ GroupPermissionAdminEvents,
+ GroupPermissionAdminReports,
+ }
+}
+
+// AdminPermissionBits returns all admin-capable bits, including IsAdmin.
+func AdminPermissionBits() []GroupPermission {
+ return append([]GroupPermission{GroupPermissionIsAdmin}, DelegatedAdminPermissions()...)
+}
+
const (
NodeCapabilityNone NodeCapability = iota
NodeCapabilityCreateArchive
@@ -394,6 +459,11 @@ const (
ProfilePublicShareOnly = ShareLinksInProfileLevel("")
ProfileAllShare = ShareLinksInProfileLevel("all_share")
ProfileHideShare = ShareLinksInProfileLevel("hide_share")
+ // ProfileSharePublic is the explicit "password-free shares only" choice.
+ // The empty value (ProfilePublicShareOnly) doubles as "inherit the
+ // site-wide default"; ProfileSharePublic lets a user force public-only
+ // visibility even when the site default differs (#3390).
+ ProfileSharePublic = ShareLinksInProfileLevel("public_share")
)
const (
diff --git a/inventory/user.go b/inventory/user.go
index 991ba1ae..d312f084 100644
--- a/inventory/user.go
+++ b/inventory/user.go
@@ -61,6 +61,9 @@ type (
GetActiveByID(ctx context.Context, id int) (*ent.User, error)
// SetStatus Set user to given status
SetStatus(ctx context.Context, u *ent.User, status user.Status) (*ent.User, error)
+ // LiftExpiredBan restores a banned user whose ban_expires has passed.
+ // It returns the (possibly updated) user; permanent bans are untouched.
+ LiftExpiredBan(ctx context.Context, u *ent.User) (*ent.User, error)
// AnonymousUser returns the anonymous user.
AnonymousUser(ctx context.Context) (*ent.User, error)
// GetLoginUserByID returns the login user by its ID. It emits some errors and fallback to anonymous user.
@@ -104,6 +107,10 @@ type (
ListUsers(ctx context.Context, args *ListUserParameters) (*ListUserResult, error)
// Upsert upserts a user.
Upsert(ctx context.Context, u *ent.User, password, twoFa string) (*ent.User, error)
+ // UpdateLastLogin stamps the user's last successful sign-in time.
+ UpdateLastLogin(ctx context.Context, uid int) error
+ // BatchUpdate applies a status and/or group change to the given users.
+ BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error)
// Delete deletes a user.
Delete(ctx context.Context, uid int) error
// CalculateStorage calculate user's storage from scratch and update user's storage.
@@ -115,6 +122,7 @@ type (
Status user.Status
Nick string
Email string
+ IDs []int
}
ListUserResult struct {
*PaginationResults
@@ -364,6 +372,46 @@ func (c *userClient) SetStatus(ctx context.Context, u *ent.User, status user.Sta
return c.client.User.UpdateOne(u).SetStatus(status).Save(ctx)
}
+func (c *userClient) UpdateLastLogin(ctx context.Context, uid int) error {
+ return c.client.User.UpdateOneID(uid).SetLastLogin(time.Now()).Exec(ctx)
+}
+
+// BatchUpdate applies a status and/or group change to the given user IDs and
+// returns the number of updated rows. Ban lifts/expiry fields are cleared when
+// a user is (re)activated or banned through this path.
+func (c *userClient) BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error) {
+ if len(ids) == 0 {
+ return 0, nil
+ }
+
+ stm := c.client.User.Update().Where(user.IDIn(ids...))
+ if status != nil {
+ stm.SetStatus(*status).ClearBanExpires().ClearBanReason()
+ }
+ if groupID > 0 {
+ stm.SetGroupUsers(groupID)
+ }
+ return stm.Save(ctx)
+}
+
+func (c *userClient) LiftExpiredBan(ctx context.Context, u *ent.User) (*ent.User, error) {
+ banned := u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned
+ if !banned || u.BanExpires == nil || u.BanExpires.After(time.Now()) {
+ return u, nil
+ }
+
+ if err := c.client.User.UpdateOneID(u.ID).
+ SetStatus(user.StatusActive).
+ ClearBanExpires().
+ Exec(ctx); err != nil {
+ return nil, err
+ }
+
+ u.Status = user.StatusActive
+ u.BanExpires = nil
+ return u, nil
+}
+
func (c *userClient) Create(ctx context.Context, args *NewUserArgs) (*ent.User, error) {
// Try to check if there's user with same email.
if existedUser, err := c.GetByEmail(ctx, args.Email); err == nil {
@@ -527,6 +575,9 @@ func (c *userClient) ListUsers(ctx context.Context, args *ListUserParameters) (*
if args.Email != "" {
query = query.Where(user.EmailContainsFold(args.Email))
}
+ if len(args.IDs) > 0 {
+ query = query.Where(user.IDIn(args.IDs...))
+ }
query.Order(getUserOrderOption(args)...)
// Count total items
@@ -579,6 +630,14 @@ func (c *userClient) Upsert(ctx context.Context, u *ent.User, password, twoFa st
SetStatus(u.Status).
SetGroupID(u.GroupUsers)
+ if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
+ q.SetNillableBanExpires(u.BanExpires)
+ q.SetBanReason(u.BanReason)
+ } else {
+ q.ClearBanExpires()
+ q.ClearBanReason()
+ }
+
if password != "" {
pwdDigest, err := digestPassword(password)
if err != nil {
@@ -605,6 +664,8 @@ func getUserOrderOption(args *ListUserParameters) []user.OrderOption {
return []user.OrderOption{user.ByEmail(orderTerm), user.ByID(orderTerm)}
case user.FieldUpdatedAt:
return []user.OrderOption{user.ByUpdatedAt(orderTerm), user.ByID(orderTerm)}
+ case user.FieldLastLogin:
+ return []user.OrderOption{user.ByLastLogin(orderTerm), user.ByID(orderTerm)}
default:
return []user.OrderOption{user.ByID(orderTerm)}
}
diff --git a/inventory/user_batch_test.go b/inventory/user_batch_test.go
new file mode 100644
index 00000000..7c884b02
--- /dev/null
+++ b/inventory/user_batch_test.go
@@ -0,0 +1,97 @@
+package inventory
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBatchUpdateUsers(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ g1 := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ g2 := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ uc := NewUserClient(client)
+
+ u1 := client.User.Create().SetEmail("b1@example.com").SetNick("u1").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
+ u2 := client.User.Create().SetEmail("b2@example.com").SetNick("u2").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
+ u3 := client.User.Create().SetEmail("b3@example.com").SetNick("u3").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
+
+ t.Run("status change clears ban fields", func(t *testing.T) {
+ banned := client.User.Create().SetEmail("banned@example.com").SetNick("ub").
+ SetStatus(entuser.StatusManualBanned).SetBanExpires(time.Now().Add(time.Hour)).
+ SetBanReason("spam").SetGroup(g1).SaveX(ctx)
+
+ st := entuser.StatusInactive
+ n, err := uc.BatchUpdate(ctx, []int{banned.ID}, &st, 0)
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+
+ got := client.User.GetX(ctx, banned.ID)
+ require.Equal(t, entuser.StatusInactive, got.Status)
+ require.Nil(t, got.BanExpires)
+ require.Equal(t, "", got.BanReason)
+ })
+
+ t.Run("group change", func(t *testing.T) {
+ n, err := uc.BatchUpdate(ctx, []int{u1.ID, u2.ID}, nil, g2.ID)
+ require.NoError(t, err)
+ require.Equal(t, 2, n)
+
+ require.Equal(t, g2.ID, client.User.GetX(ctx, u1.ID).QueryGroup().OnlyIDX(ctx))
+ require.Equal(t, g2.ID, client.User.GetX(ctx, u2.ID).QueryGroup().OnlyIDX(ctx))
+ require.Equal(t, g1.ID, client.User.GetX(ctx, u3.ID).QueryGroup().OnlyIDX(ctx))
+ })
+
+ t.Run("empty id list is no-op", func(t *testing.T) {
+ st := entuser.StatusInactive
+ n, err := uc.BatchUpdate(ctx, nil, &st, g2.ID)
+ require.NoError(t, err)
+ require.Equal(t, 0, n)
+ })
+}
+
+func TestListUsersByIDs(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ uc := NewUserClient(client)
+
+ u1 := client.User.Create().SetEmail("id1@example.com").SetNick("u1").SetGroup(group).SaveX(ctx)
+ u2 := client.User.Create().SetEmail("id2@example.com").SetNick("u2").SetGroup(group).SaveX(ctx)
+ client.User.Create().SetEmail("id3@example.com").SetNick("u3").SetGroup(group).SaveX(ctx)
+
+ res, err := uc.ListUsers(ctx, &ListUserParameters{
+ PaginationArgs: &PaginationArgs{Page: 0, PageSize: 10},
+ IDs: []int{u1.ID, u2.ID},
+ })
+ require.NoError(t, err)
+ require.Equal(t, 2, res.PaginationResults.TotalItems)
+ require.Len(t, res.Users, 2)
+}
+
+func TestUpdateLastLogin(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ uc := NewUserClient(client)
+
+ u := client.User.Create().SetEmail("ll@example.com").SetNick("u").SetGroup(group).SaveX(ctx)
+ require.Nil(t, client.User.GetX(ctx, u.ID).LastLogin)
+
+ require.NoError(t, uc.UpdateLastLogin(ctx, u.ID))
+ got := client.User.GetX(ctx, u.ID)
+ require.NotNil(t, got.LastLogin)
+ require.WithinDuration(t, time.Now(), *got.LastLogin, time.Minute)
+}
diff --git a/main.go b/main.go
index d9385366..ffa1b2c5 100644
--- a/main.go
+++ b/main.go
@@ -19,20 +19,8 @@ func init() {
flag.BoolVar(&util.UseWorkingDir, "use-working-dir", false, "Use working directory, instead of executable directory")
flag.StringVar(&confPath, "c", util.RelativePath("conf.ini"), "Path to the config file.")
flag.StringVar(&scriptName, "database-script", "", "Name of database util script.")
- //flag.Parse()
-
- //staticFS = bootstrap.NewFS(staticZip)
- //bootstrap.Init(confPath, staticFS)
}
func main() {
cmd.Execute()
- return
- // 关闭数据库连接
-
- //if scriptName != "" {
- // // 开始运行助手数据库脚本
- // bootstrap.RunScript(scriptName)
- // return
- //}
}
diff --git a/middleware/adminsection_test.go b/middleware/adminsection_test.go
new file mode 100644
index 00000000..703c9362
--- /dev/null
+++ b/middleware/adminsection_test.go
@@ -0,0 +1,109 @@
+package middleware
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+)
+
+func perms(bits ...types.GroupPermission) *boolset.BooleanSet {
+ b := &boolset.BooleanSet{}
+ for _, p := range bits {
+ boolset.Set(int(p), true, b)
+ }
+ return b
+}
+
+func newAdminRequest(t *testing.T, permissions *boolset.BooleanSet) *gin.Context {
+ t.Helper()
+ w := httptest.NewRecorder()
+ c := gin.CreateTestContextOnly(w, testEngine)
+ c.Request = httptest.NewRequest("GET", "/api/v4/admin/summary", nil)
+ dep := dependency.NewDependency(
+ dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)),
+ )
+ c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), dependency.DepCtx{}, dep))
+ u := &ent.User{
+ ID: 2,
+ Edges: ent.UserEdges{
+ Group: &ent.Group{Permissions: permissions},
+ },
+ }
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+}
+
+func TestIsAdminOrDelegated(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler := IsAdminOrDelegated()
+
+ t.Run("full admin passes", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin))
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("full admin was rejected")
+ }
+ })
+
+ t.Run("delegated admin passes", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers))
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("delegated admin was rejected")
+ }
+ })
+
+ t.Run("regular user rejected", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionShare))
+ handler(c)
+ if !c.IsAborted() {
+ t.Fatal("regular user was not rejected")
+ }
+ })
+}
+
+func TestAdminSection(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler := AdminSection(types.GroupPermissionAdminUsers, types.GroupPermissionAdminGroups)
+
+ t.Run("full admin passes any section", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin))
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("full admin was rejected")
+ }
+ })
+
+ t.Run("matching delegated section passes", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers))
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("delegated admin with matching section was rejected")
+ }
+ })
+
+ t.Run("non-matching delegated section rejected", func(t *testing.T) {
+ c := newAdminRequest(t, perms(types.GroupPermissionAdminFiles))
+ handler(c)
+ if !c.IsAborted() {
+ t.Fatal("delegated admin without matching section was not rejected")
+ }
+ })
+
+ t.Run("regular user rejected", func(t *testing.T) {
+ c := newAdminRequest(t, perms())
+ handler(c)
+ if !c.IsAborted() {
+ t.Fatal("regular user was not rejected")
+ }
+ })
+}
diff --git a/middleware/auth.go b/middleware/auth.go
index 0653faeb..135f115d 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -3,6 +3,7 @@ package middleware
import (
"crypto/subtle"
"net/http"
+ "strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
@@ -104,44 +105,77 @@ func LoginRequired() gin.HandlerFunc {
// WebDAVAuth 验证WebDAV登录及权限
func WebDAVAuth() gin.HandlerFunc {
return func(c *gin.Context) {
+ dep := dependency.FromContext(c)
+ l := dep.Logger()
+
username, password, ok := c.Request.BasicAuth()
+ var expectedUser *ent.User
+ bearerAuth := false
if !ok {
- // OPTIONS 请求不需要鉴权
- if c.Request.Method == http.MethodOptions {
- c.Next()
+ // Bearer auth: accept Cloudreve API access tokens so OIDC-only
+ // users and API clients can mount without a DAV password (#3548).
+ if !strings.HasPrefix(c.GetHeader(auth.AuthorizationHeader), auth.TokenHeaderPrefix) {
+ // OPTIONS 请求不需要鉴权
+ if c.Request.Method == http.MethodOptions {
+ c.Next()
+ return
+ }
+ c.Writer.Header().Add("WWW-Authenticate", `Basic realm="cloudreve"`)
+ c.Writer.Header().Add("WWW-Authenticate", `Bearer realm="cloudreve"`)
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
return
}
- c.Writer.Header()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`}
- c.Status(http.StatusUnauthorized)
- c.Abort()
- return
- }
- dep := dependency.FromContext(c)
- l := dep.Logger()
- userClient := dep.UserClient()
- expectedUser, err := userClient.GetActiveByDavAccount(c, username, password)
- if err != nil {
- if username == "" {
- if u, err := userClient.GetByEmail(c, username); err == nil {
- // Try login with known user but incorrect password, record audit log
- SetUserCtxByUser(c, u)
- }
+ if _, err := dep.TokenAuth().VerifyAndRetrieveUser(c); err != nil {
+ l.Debug("WebDAVAuth: bearer token rejected: %s", err)
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
}
- l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err)
- c.Status(http.StatusUnauthorized)
- c.Abort()
- return
- }
+ uid := inventory.UserIDFromContext(c)
+ if uid == 0 {
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
- // Validate dav account
- accounts, err := expectedUser.Edges.DavAccountsOrErr()
- if err != nil || len(accounts) == 0 {
- l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err)
- c.Status(http.StatusUnauthorized)
- c.Abort()
- return
+ if err := SetUserCtx(c, uid); err != nil {
+ l.Debug("WebDAVAuth: failed to load bearer user %d: %s", uid, err)
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
+
+ expectedUser = inventory.UserFromContext(c)
+ bearerAuth = true
+ } else {
+ userClient := dep.UserClient()
+ var err error
+ expectedUser, err = userClient.GetActiveByDavAccount(c, username, password)
+ if err != nil {
+ if username == "" {
+ if u, err := userClient.GetByEmail(c, username); err == nil {
+ // Try login with known user but incorrect password, record audit log
+ SetUserCtxByUser(c, u)
+ }
+ }
+
+ l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err)
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
+
+ // Validate dav account
+ accounts, err := expectedUser.Edges.DavAccountsOrErr()
+ if err != nil || len(accounts) == 0 {
+ l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err)
+ c.Status(http.StatusUnauthorized)
+ c.Abort()
+ return
+ }
}
// 用户组已启用WebDAV?
@@ -160,17 +194,38 @@ func WebDAVAuth() gin.HandlerFunc {
return
}
+ // Scoped client tokens need at least Files.Read; tokens without
+ // Files.Write are confined to read-only access.
+ if bearerAuth {
+ if err := auth.CheckScope(c, types.ScopeFilesRead); err != nil {
+ c.Status(http.StatusForbidden)
+ c.Abort()
+ return
+ }
+ }
+
// 检查是否只读
- if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
+ readOnly := group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly))
+ if len(expectedUser.Edges.DavAccounts) > 0 &&
+ expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
+ readOnly = true
+ }
+ if bearerAuth && auth.CheckScope(c, types.ScopeFilesWrite) != nil {
+ readOnly = true
+ }
+
+ if readOnly {
switch c.Request.Method {
- case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK":
+ case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "PROPPATCH":
c.Status(http.StatusForbidden)
c.Abort()
return
}
}
- SetUserCtxByUser(c, expectedUser)
+ if !bearerAuth {
+ SetUserCtxByUser(c, expectedUser)
+ }
c.Next()
}
}
@@ -280,6 +335,64 @@ func IsAdmin() gin.HandlerFunc {
}
}
+// IsAdminOrDelegated allows full admins and delegated administrators (groups
+// carrying at least one per-section admin permission bit).
+func IsAdminOrDelegated() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ user := inventory.UserFromContext(c)
+ permissions := user.Edges.Group.Permissions
+ if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ delegated := false
+ for _, p := range types.DelegatedAdminPermissions() {
+ if permissions.Enabled(int(p)) {
+ delegated = true
+ break
+ }
+ }
+
+ if !delegated {
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil))
+ c.Abort()
+ return
+ }
+ }
+
+ c.Next()
+ }
+}
+
+// AdminSection requires the full admin permission or at least one of the
+// given delegated admin section permissions. Delegated-admin passes are
+// audit-logged since they exercise elevated permissions without full admin.
+func AdminSection(sections ...types.GroupPermission) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ user := inventory.UserFromContext(c)
+ permissions := user.Edges.Group.Permissions
+ if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ allowed := false
+ for _, p := range sections {
+ if permissions.Enabled(int(p)) {
+ allowed = true
+ break
+ }
+ }
+
+ if !allowed {
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil))
+ c.Abort()
+ return
+ }
+
+ dependency.FromContext(c).Logger().Info(
+ "Delegated admin %q (uid=%d) accessed %s %s",
+ user.Email, user.ID, c.Request.Method, c.FullPath(),
+ )
+ }
+
+ c.Next()
+ }
+}
+
// RequiredScopes checks if the JWT token has the required scopes.
// If the token has scopes (hasScopes is true), it verifies the token has all required scopes.
// Write scopes implicitly include Read scopes for the same resource (e.g., "File.Write" includes "File.Read").
diff --git a/middleware/davbearer_test.go b/middleware/davbearer_test.go
new file mode 100644
index 00000000..c3619193
--- /dev/null
+++ b/middleware/davbearer_test.go
@@ -0,0 +1,194 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/auth"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cache"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+)
+
+type stubTokenAuth struct {
+ uid int
+ scopes []string
+}
+
+func (s stubTokenAuth) Issue(ctx context.Context, args *auth.IssueTokenArgs) (*auth.Token, error) {
+ return nil, nil
+}
+
+func (s stubTokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) {
+ if c.GetHeader(auth.AuthorizationHeader) == "Bearer good" {
+ util.WithValue(c, inventory.UserIDCtx{}, s.uid)
+ if s.scopes != nil {
+ util.WithValue(c, auth.ScopeContextKey{}, s.scopes)
+ }
+ }
+ return false, nil
+}
+
+func (s stubTokenAuth) Refresh(ctx context.Context, refreshToken string) (*auth.Token, error) {
+ return nil, nil
+}
+
+func (s stubTokenAuth) Claims(ctx context.Context, tokenStr string) (*auth.Claims, error) {
+ return nil, nil
+}
+
+func newDavAuthDep(t *testing.T, groupPerms *boolset.BooleanSet, scopes []string) dependency.Dep {
+ t.Helper()
+ client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1")
+ t.Cleanup(func() { client.Close() })
+
+ group := client.Group.Create().
+ SetName("g").
+ SetPermissions(groupPerms).
+ SaveX(context.Background())
+
+ u := client.User.Create().
+ SetEmail("dav@test.com").
+ SetNick("dav").
+ SetStatus("active").
+ SetPassword("x").
+ SetGroup(group).
+ SaveX(context.Background())
+
+ return dependency.NewDependency(
+ dependency.WithKV(cache.NewMemoStore("", nil)),
+ dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)),
+ dependency.WithUserClient(inventory.NewUserClient(client)),
+ dependency.WithTokenAuth(stubTokenAuth{uid: u.ID, scopes: scopes}),
+ )
+}
+
+func newDavRequest(t *testing.T, dep dependency.Dep, method, authHeader string) (*gin.Context, *httptest.ResponseRecorder) {
+ t.Helper()
+ w := httptest.NewRecorder()
+ c := gin.CreateTestContextOnly(w, testEngine)
+ req := httptest.NewRequest(method, "/dav/file.txt", nil)
+ if authHeader != "" {
+ req.Header.Set("Authorization", authHeader)
+ }
+ c.Request = req.WithContext(context.WithValue(req.Context(), dependency.DepCtx{}, dep))
+ return c, w
+}
+
+func TestWebDAVBearerAuth(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ handler := WebDAVAuth()
+
+ davPerms := &boolset.BooleanSet{}
+ boolset.Set(int(types.GroupPermissionWebDAV), true, davPerms)
+ readOnlyPerms := &boolset.BooleanSet{}
+ boolset.Set(int(types.GroupPermissionWebDAV), true, readOnlyPerms)
+ boolset.Set(int(types.GroupPermissionWebDAVReadOnly), true, readOnlyPerms)
+
+ t.Run("valid bearer token authenticates", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, nil)
+ c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good")
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("valid bearer token was rejected")
+ }
+ u := inventory.UserFromContext(c)
+ if u == nil || u.Email != "dav@test.com" {
+ t.Fatalf("expected bearer user in ctx, got %+v", u)
+ }
+ })
+
+ t.Run("invalid bearer token rejected", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, nil)
+ c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer bad")
+ handler(c)
+ if !c.IsAborted() || c.Writer.Status() != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", c.Writer.Status())
+ }
+ })
+
+ t.Run("no credentials still issues basic+bearer challenge", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, nil)
+ c, w := newDavRequest(t, dep, http.MethodGet, "")
+ handler(c)
+ if c.Writer.Status() != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", c.Writer.Status())
+ }
+ if challenges := w.Header().Values("WWW-Authenticate"); len(challenges) != 2 {
+ t.Fatalf("expected Basic+Bearer challenges, got %v", challenges)
+ }
+ })
+
+ t.Run("options without credentials passes", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, nil)
+ c, _ := newDavRequest(t, dep, http.MethodOptions, "")
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("unauthenticated OPTIONS was rejected")
+ }
+ })
+
+ t.Run("bearer user without group dav permission forbidden", func(t *testing.T) {
+ dep := newDavAuthDep(t, &boolset.BooleanSet{}, nil)
+ c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good")
+ handler(c)
+ if c.Writer.Status() != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", c.Writer.Status())
+ }
+ })
+
+ t.Run("group read-only blocks bearer writes", func(t *testing.T) {
+ dep := newDavAuthDep(t, readOnlyPerms, nil)
+ c, _ := newDavRequest(t, dep, http.MethodPut, "Bearer good")
+ handler(c)
+ if c.Writer.Status() != http.StatusForbidden {
+ t.Fatalf("expected 403 for PUT, got %d", c.Writer.Status())
+ }
+ c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good")
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("read-only bearer GET was rejected")
+ }
+ })
+
+ t.Run("scoped token without Files.Write is read-only", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, []string{types.ScopeFilesRead})
+ c, _ := newDavRequest(t, dep, http.MethodDelete, "Bearer good")
+ handler(c)
+ if c.Writer.Status() != http.StatusForbidden {
+ t.Fatalf("expected 403 for DELETE, got %d", c.Writer.Status())
+ }
+ c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good")
+ handler(c)
+ if c.IsAborted() {
+ t.Fatal("scoped read token GET was rejected")
+ }
+ })
+
+ t.Run("scoped token without Files.Read forbidden", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, []string{"User.Read"})
+ c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good")
+ handler(c)
+ if c.Writer.Status() != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d", c.Writer.Status())
+ }
+ })
+
+ t.Run("basic auth path unchanged", func(t *testing.T) {
+ dep := newDavAuthDep(t, davPerms, nil)
+ c, _ := newDavRequest(t, dep, http.MethodGet, "Basic dGVzdDp0ZXN0")
+ handler(c)
+ // No dav account exists -> 401 via the basic credential path.
+ if c.Writer.Status() != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", c.Writer.Status())
+ }
+ })
+}
diff --git a/middleware/ratelimit_test.go b/middleware/ratelimit_test.go
index 1b9a72dc..d1dd576f 100644
--- a/middleware/ratelimit_test.go
+++ b/middleware/ratelimit_test.go
@@ -85,9 +85,9 @@ func TestRateLimitExpiry(t *testing.T) {
t.Fatal("second request not rejected")
}
- // Window expiry frees the bucket. MemoStore TTL is Unix-second granular
- // (item valid while Expires >= now), so a 1s window can live ~2s.
- time.Sleep(2100 * time.Millisecond)
+ // Window expiry frees the bucket; TTL semantics are covered in pkg/cache
+ // tests, so the bucket is cleared directly rather than sleeping ~2s.
+ dep.KV().Delete(rateLimitPrefix + "login:192.0.2.1")
c, _ = newRateLimitContext(t, dep, "192.0.2.1")
handler(c)
diff --git a/middleware/session.go b/middleware/session.go
index aa1fae7d..1d269f15 100644
--- a/middleware/session.go
+++ b/middleware/session.go
@@ -6,7 +6,6 @@ import (
"net/http"
"strings"
- "github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-contrib/sessions"
@@ -22,7 +21,7 @@ const SessionName = "cloudreve-session"
func Session(dep dependency.Dep) gin.HandlerFunc {
Store = sessionstore.NewStore(dep.KV(), []byte(dep.ConfigProvider().System().SessionSecret))
sameSiteMode := http.SameSiteDefaultMode
- switch strings.ToLower(conf.CORSConfig.SameSite) {
+ switch strings.ToLower(dep.ConfigProvider().Cors().SameSite) {
case "default":
sameSiteMode = http.SameSiteDefaultMode
case "none":
@@ -39,7 +38,7 @@ func Session(dep dependency.Dep) gin.HandlerFunc {
MaxAge: 60 * 86400,
Path: "/",
SameSite: sameSiteMode,
- Secure: conf.CORSConfig.Secure,
+ Secure: dep.ConfigProvider().Cors().Secure,
})
return sessions.Sessions(SessionName, Store)
@@ -61,7 +60,7 @@ func CSRFCheck() gin.HandlerFunc {
return
}
- c.JSON(200, serializer.ErrDeprecated(serializer.CodeNoPermissionErr, "Invalid origin", nil))
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "Invalid origin", nil))
c.Abort()
}
}
diff --git a/pkg/auth/hmac_test.go b/pkg/auth/hmac_test.go
new file mode 100644
index 00000000..e1039273
--- /dev/null
+++ b/pkg/auth/hmac_test.go
@@ -0,0 +1,53 @@
+package auth
+
+import (
+ "testing"
+ "time"
+)
+
+func TestHMACSignCheckRoundTrip(t *testing.T) {
+ a := HMACAuth{SecretKey: []byte("secret")}
+
+ sign := a.Sign("body-content", 0)
+ if err := a.Check("body-content", sign); err != nil {
+ t.Fatalf("expected valid sign, got %v", err)
+ }
+}
+
+func TestHMACCheckExpired(t *testing.T) {
+ a := HMACAuth{SecretKey: []byte("secret")}
+
+ sign := a.Sign("body", time.Now().Add(-time.Hour).Unix())
+ if err := a.Check("body", sign); err != ErrExpired {
+ t.Fatalf("expected ErrExpired, got %v", err)
+ }
+}
+
+func TestHMACCheckMissingExpires(t *testing.T) {
+ a := HMACAuth{SecretKey: []byte("secret")}
+
+ if err := a.Check("body", "abc:"); err != ErrExpiresMissing {
+ t.Fatalf("expected ErrExpiresMissing, got %v", err)
+ }
+}
+
+func TestHMACCheckTampered(t *testing.T) {
+ a := HMACAuth{SecretKey: []byte("secret")}
+
+ if err := a.Check("other-body", a.Sign("body", 0)); err == nil {
+ t.Fatal("expected invalid sign error for tampered body")
+ }
+
+ if err := a.Check("body", a.Sign("body", 0)+"tampered"); err == nil {
+ t.Fatal("expected invalid sign error for tampered signature")
+ }
+}
+
+func TestHMACZeroExpiresNeverExpires(t *testing.T) {
+ a := HMACAuth{SecretKey: []byte("secret")}
+
+ sign := a.Sign("body", 0)
+ if err := a.Check("body", sign); err != nil {
+ t.Fatalf("expires=0 should never expire, got %v", err)
+ }
+}
diff --git a/pkg/auth/jwt_test.go b/pkg/auth/jwt_test.go
new file mode 100644
index 00000000..42aa4abf
--- /dev/null
+++ b/pkg/auth/jwt_test.go
@@ -0,0 +1,205 @@
+package auth
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cache"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+)
+
+type stubSettingProvider struct {
+ setting.Provider
+ tokenAuth *setting.TokenAuth
+ siteBasic *setting.SiteBasic
+}
+
+func (s *stubSettingProvider) TokenAuth(ctx context.Context) *setting.TokenAuth {
+ return s.tokenAuth
+}
+
+func (s *stubSettingProvider) SiteBasic(ctx context.Context) *setting.SiteBasic {
+ return s.siteBasic
+}
+
+type stubUserClient struct {
+ inventory.UserClient
+ user *ent.User
+ err error
+}
+
+func (s *stubUserClient) GetActiveByID(ctx context.Context, id int) (*ent.User, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.user, nil
+}
+
+func newTestTokenAuth(t *testing.T, userClient inventory.UserClient, kv cache.Driver) *tokenAuth {
+ t.Helper()
+ encoder, err := hashid.New("test-salt")
+ if err != nil {
+ t.Fatalf("failed to create hashid encoder: %v", err)
+ }
+
+ return &tokenAuth{
+ idEncoder: encoder,
+ s: &stubSettingProvider{
+ tokenAuth: &setting.TokenAuth{
+ AccessTokenTTL: time.Hour,
+ RefreshTokenTTL: 24 * time.Hour,
+ },
+ siteBasic: &setting.SiteBasic{ID: "site-id"},
+ },
+ secret: []byte("jwt-secret"),
+ userClient: userClient,
+ l: logging.NewConsoleLogger(logging.LevelDebug),
+ kv: kv,
+ }
+}
+
+func TestIssueAndClaimsRoundTrip(t *testing.T) {
+ ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
+
+ user := &ent.User{ID: 42, Email: "u@example.com", Password: "pw"}
+ token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+
+ claims, err := ta.Claims(context.Background(), token.AccessToken)
+ if err != nil {
+ t.Fatalf("Claims failed: %v", err)
+ }
+ if claims.TokenType != TokenTypeAccess {
+ t.Fatalf("expected access token type, got %q", claims.TokenType)
+ }
+
+ uid, err := ta.idEncoder.Decode(claims.Subject, hashid.UserID)
+ if err != nil {
+ t.Fatalf("failed to decode subject: %v", err)
+ }
+ if uid != user.ID {
+ t.Fatalf("expected uid %d, got %d", user.ID, uid)
+ }
+
+ refreshClaims, err := ta.Claims(context.Background(), token.RefreshToken)
+ if err != nil {
+ t.Fatalf("Claims on refresh token failed: %v", err)
+ }
+ if refreshClaims.TokenType != TokenTypeRefresh {
+ t.Fatalf("expected refresh token type, got %q", refreshClaims.TokenType)
+ }
+ if refreshClaims.RootTokenID == nil {
+ t.Fatal("refresh token missing RootTokenID")
+ }
+}
+
+func TestClaimsRejectsTamperedToken(t *testing.T) {
+ ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
+
+ if _, err := ta.Claims(context.Background(), "not.a.jwt"); err == nil {
+ t.Fatal("expected error for malformed token")
+ }
+
+ // Sign with a different secret must fail verification.
+ other := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
+ other.secret = []byte("other-secret")
+ token, err := other.Issue(context.Background(), &IssueTokenArgs{User: &ent.User{ID: 1}})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+ if _, err := ta.Claims(context.Background(), token.AccessToken); err == nil {
+ t.Fatal("expected signature mismatch error")
+ }
+}
+
+func TestRefreshRotatesTokenPair(t *testing.T) {
+ user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
+ kv := cache.NewMemoStore("", nil)
+ ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
+
+ token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+
+ refreshed, err := ta.Refresh(context.Background(), token.RefreshToken)
+ if err != nil {
+ t.Fatalf("Refresh failed: %v", err)
+ }
+ if refreshed.AccessToken == "" || refreshed.RefreshToken == "" {
+ t.Fatal("refresh returned empty token pair")
+ }
+}
+
+func TestRefreshRejectsAccessToken(t *testing.T) {
+ user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
+ ta := newTestTokenAuth(t, &stubUserClient{user: user}, cache.NewMemoStore("", nil))
+
+ token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+
+ if _, err := ta.Refresh(context.Background(), token.AccessToken); err != ErrInvalidRefreshToken {
+ t.Fatalf("expected ErrInvalidRefreshToken, got %v", err)
+ }
+}
+
+func TestRefreshRejectsRevokedRootToken(t *testing.T) {
+ user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
+ kv := cache.NewMemoStore("", nil)
+ ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
+
+ token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+
+ claims, err := ta.Claims(context.Background(), token.RefreshToken)
+ if err != nil {
+ t.Fatalf("Claims failed: %v", err)
+ }
+ kv.Set(RevokeTokenPrefix+claims.RootTokenID.String(), true, 0)
+
+ if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken {
+ t.Fatalf("expected ErrInvalidRefreshToken for revoked root, got %v", err)
+ }
+}
+
+func TestRefreshRejectsChangedUserState(t *testing.T) {
+ user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
+ kv := cache.NewMemoStore("", nil)
+ ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
+
+ token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
+ if err != nil {
+ t.Fatalf("Issue failed: %v", err)
+ }
+
+ // Password change invalidates the state hash in the refresh token.
+ changed := &ent.User{ID: 7, Email: "r@example.com", Password: "new-pw"}
+ ta.userClient = &stubUserClient{user: changed}
+
+ if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken {
+ t.Fatalf("expected ErrInvalidRefreshToken after password change, got %v", err)
+ }
+}
+
+func TestValidateScopes(t *testing.T) {
+ if !ValidateScopes([]string{"a", "b"}, []string{"a", "b", "c"}) {
+ t.Fatal("subset should validate")
+ }
+ if ValidateScopes([]string{"a", "z"}, []string{"a", "b"}) {
+ t.Fatal("superset scope should be rejected")
+ }
+ if !ValidateScopes(nil, []string{"a"}) {
+ t.Fatal("empty request should validate")
+ }
+}
diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go
index 8b935253..b73b41c8 100644
--- a/pkg/auth/oidc.go
+++ b/pkg/auth/oidc.go
@@ -21,6 +21,12 @@ type OIDCIDTokenClaims struct {
UpdatedAt int64 `json:"updated_at,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
+ // AD FS and similar providers emit these instead of the standard
+ // profile claims; their userinfo endpoints often return only sub.
+ UPN string `json:"upn,omitempty"`
+ UniqueName string `json:"unique_name,omitempty"`
+ GivenName string `json:"given_name,omitempty"`
+ FamilyName string `json:"family_name,omitempty"`
}
type JWKSet struct {
diff --git a/pkg/auth/oidc_consumer.go b/pkg/auth/oidc_consumer.go
index fde768e9..145b0e05 100644
--- a/pkg/auth/oidc_consumer.go
+++ b/pkg/auth/oidc_consumer.go
@@ -43,6 +43,11 @@ type OIDCUserInfo struct {
Picture string `json:"picture,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
+ // AD FS-style alternates, mirrored from the ID-token claims.
+ UPN string `json:"upn,omitempty"`
+ UniqueName string `json:"unique_name,omitempty"`
+ GivenName string `json:"given_name,omitempty"`
+ FamilyName string `json:"family_name,omitempty"`
}
// Validate checks the discovery document has the endpoints the code flow requires.
diff --git a/pkg/auth/oidc_consumer_test.go b/pkg/auth/oidc_consumer_test.go
index 804e0f04..ab5c1499 100644
--- a/pkg/auth/oidc_consumer_test.go
+++ b/pkg/auth/oidc_consumer_test.go
@@ -89,6 +89,23 @@ func TestVerifyOIDCIDToken(t *testing.T) {
_, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks)
require.Error(t, err)
})
+
+ t.Run("adfs profile claims", func(t *testing.T) {
+ claims := validClaims()
+ claims.Email = ""
+ claims.UPN = "user@corp.example.com"
+ claims.UniqueName = `CORP\user`
+ claims.GivenName = "Jane"
+ claims.FamilyName = "Doe"
+ claims.Name = "Jane Doe"
+ token := signConsumerToken(t, pemKey, claims)
+ parsed, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks)
+ require.NoError(t, err)
+ require.Equal(t, "user@corp.example.com", parsed.UPN)
+ require.Equal(t, `CORP\user`, parsed.UniqueName)
+ require.Equal(t, "Jane", parsed.GivenName)
+ require.Equal(t, "Doe", parsed.FamilyName)
+ })
}
func TestOIDCDiscoveryValidate(t *testing.T) {
diff --git a/pkg/cache/memo_test.go b/pkg/cache/memo_test.go
index 1f812018..a530977a 100644
--- a/pkg/cache/memo_test.go
+++ b/pkg/cache/memo_test.go
@@ -63,7 +63,7 @@ func TestMemoStore_Get(t *testing.T) {
// 过期
{
_ = store.Set("string", "string_val", 1)
- time.Sleep(time.Duration(2) * time.Second)
+ store.Store.Store("string", itemWithTTL{Value: "string_val", Expires: time.Now().Unix() - 1})
val, ok := store.Get("string")
asserts.Nil(val)
asserts.False(ok)
@@ -141,7 +141,7 @@ func TestMemoStore_GarbageCollect(t *testing.T) {
asserts := assert.New(t)
store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
store.Set("test", 1, 1)
- time.Sleep(time.Duration(2000) * time.Millisecond)
+ store.Store.Store("test", itemWithTTL{Value: 1, Expires: time.Now().Unix() - 1})
store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug))
_, ok := store.Get("test")
asserts.False(ok)
diff --git a/pkg/cluster/pool_test.go b/pkg/cluster/pool_test.go
new file mode 100644
index 00000000..5fc2cc89
--- /dev/null
+++ b/pkg/cluster/pool_test.go
@@ -0,0 +1,88 @@
+package cluster
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/ent/node"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type stubSettings struct {
+ setting.Provider
+}
+
+func capsOf(bits ...types.NodeCapability) *boolset.BooleanSet {
+ b := &boolset.BooleanSet{}
+ for _, c := range bits {
+ boolset.Set(int(c), true, b)
+ }
+ return b
+}
+
+func newPoolFixture(t *testing.T) (*ent.Client, NodePool, context.Context) {
+ t.Helper()
+ ctx := context.Background()
+ client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1")
+ t.Cleanup(func() { client.Close() })
+
+ logger := logging.NewConsoleLogger(logging.LevelError)
+ cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger)
+ require.NoError(t, err)
+
+ pool, err := NewNodePool(ctx, logger, cfg, &stubSettings{}, inventory.NewNodeClient(client))
+ require.NoError(t, err)
+ return client, pool, ctx
+}
+
+func TestWeightedNodePoolGet(t *testing.T) {
+ a := assert.New(t)
+ client, pool, ctx := newPoolFixture(t)
+
+ n1 := client.Node.Create().
+ SetName("n1").SetType(node.TypeMaster).SetStatus(node.StatusActive).
+ SetServer("http://n1").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)).
+ SetWeight(10).SaveX(ctx)
+ client.Node.Create().
+ SetName("n2").SetType(node.TypeMaster).SetStatus(node.StatusActive).
+ SetServer("http://n2").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)).
+ SetWeight(1).SaveX(ctx)
+
+ pool.(*weightedNodePool).Upsert(ctx, n1)
+ // Upsert requires a full refresh — rebuild pool from the seeded DB instead.
+ pool, err := NewNodePool(ctx, logging.NewConsoleLogger(logging.LevelError), pool.(*weightedNodePool).conf, &stubSettings{}, inventory.NewNodeClient(client))
+ require.NoError(t, err)
+
+ // Capability with no matching nodes errors.
+ _, err = pool.Get(ctx, types.NodeCapabilityCreateArchive, 0)
+ a.True(errors.Is(err, ErrNoAvailableNode))
+
+ // Preferred node wins regardless of weight.
+ selected, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID)
+ require.NoError(t, err)
+ a.Equal(n1.ID, selected.ID())
+
+ // Without preference, the heavier node wins the first pick.
+ selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0)
+ require.NoError(t, err)
+ a.Equal(n1.ID, selected.ID())
+}
+
+func TestWeightedNodePoolGetUnknownCapability(t *testing.T) {
+ a := assert.New(t)
+ _, pool, ctx := newPoolFixture(t)
+
+ _, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0)
+ a.True(errors.Is(err, ErrNoAvailableNode))
+}
diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go
index 79b18f1b..11b2d6cc 100644
--- a/pkg/cluster/routes/routes.go
+++ b/pkg/cluster/routes/routes.go
@@ -18,15 +18,17 @@ const (
)
var (
- masterPing *url.URL
- masterUserActivate *url.URL
- masterUserReset *url.URL
- masterHome *url.URL
+ masterPing *url.URL
+ masterUserActivate *url.URL
+ masterUserActivateEmail *url.URL
+ masterUserReset *url.URL
+ masterHome *url.URL
)
func init() {
masterPing, _ = url.Parse(constants.APIPrefix + "/site/ping")
masterUserActivate, _ = url.Parse("/session/activate")
+ masterUserActivateEmail, _ = url.Parse("/session/activate_email")
masterUserReset, _ = url.Parse("/session/reset")
}
@@ -62,6 +64,15 @@ func MasterUserActivateUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterUserActivate)
}
+func MasterUserActivateEmailAPIUrl(base *url.URL, uid string) *url.URL {
+ route, _ := url.Parse(constants.APIPrefix + "/user/activate_email/" + uid)
+ return base.ResolveReference(route)
+}
+
+func MasterUserActivateEmailUrl(base *url.URL) *url.URL {
+ return base.ResolveReference(masterUserActivateEmail)
+}
+
func MasterUserResetUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterUserReset)
}
@@ -203,11 +214,16 @@ func SlaveFileListRoute(srcPath string, recursive bool) string {
return fmt.Sprintf("%s?%s", base, query.Encode())
}
-func SlaveThumbUrl(base *url.URL, srcPath, ext string) *url.URL {
+func SlaveThumbUrl(base *url.URL, srcPath, ext string, entityID int) *url.URL {
srcPath = url.PathEscape(base64.URLEncoding.EncodeToString([]byte(srcPath)))
ext = url.PathEscape(ext)
route, _ := url.Parse(constants.APIPrefixSlave + fmt.Sprintf("/file/thumb/%s/%s", srcPath, ext))
base = base.ResolveReference(route)
+ if entityID > 0 {
+ query := base.Query()
+ query.Set("eid", strconv.Itoa(entityID))
+ base.RawQuery = query.Encode()
+ }
return base
}
diff --git a/pkg/conf/conf_test.go b/pkg/conf/conf_test.go
new file mode 100644
index 00000000..7786e682
--- /dev/null
+++ b/pkg/conf/conf_test.go
@@ -0,0 +1,27 @@
+package conf
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnvOverrideDatabaseName(t *testing.T) {
+ dir := t.TempDir()
+ confPath := filepath.Join(dir, "conf.ini")
+ require.NoError(t, os.WriteFile(confPath, []byte("[System]\nDebug = false\nMode = master\nListen = :5212\nSessionSecret = s\nHashIDSalt = s\n"), 0644))
+
+ t.Setenv("CR_CONF_Database.Type", "postgres")
+ t.Setenv("CR_CONF_Database.Host", "postgresql")
+ t.Setenv("CR_CONF_Database.User", "user")
+ t.Setenv("CR_CONF_Database.Name", "database")
+ t.Setenv("CR_CONF_Database.Port", "5432")
+
+ p, err := NewIniConfigProvider(confPath, logging.NewConsoleLogger(logging.LevelDebug))
+ require.NoError(t, err)
+ require.Equal(t, "user", p.Database().User)
+ require.Equal(t, "database", p.Database().Name)
+}
diff --git a/pkg/downloader/aria2/aria2_test.go b/pkg/downloader/aria2/aria2_test.go
new file mode 100644
index 00000000..3c1611e1
--- /dev/null
+++ b/pkg/downloader/aria2/aria2_test.go
@@ -0,0 +1,121 @@
+package aria2
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/downloader"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/stretchr/testify/require"
+)
+
+type stubSettingProvider struct {
+ setting.Provider
+}
+
+// rpcStub answers aria2 JSON-RPC over HTTP for the methods under test.
+func rpcStub(t *testing.T, responses map[string]any) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Method string `json:"method"`
+ Id any `json:"id"`
+ }
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
+
+ result, ok := responses[req.Method]
+ require.True(t, ok, "unexpected rpc method: %s", req.Method)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "jsonrpc": "2.0",
+ "id": req.Id,
+ "result": result,
+ })
+ }))
+}
+
+func newTestClient(server string) downloader.Downloader {
+ return New(
+ logging.NewConsoleLogger(logging.LevelError),
+ &stubSettingProvider{},
+ &types.Aria2Setting{Server: server, TempPath: "/tmp"},
+ )
+}
+
+func TestCreateTaskReturnsHandle(t *testing.T) {
+ srv := rpcStub(t, map[string]any{"aria2.addUri": "gid-abc"})
+ defer srv.Close()
+
+ c := newTestClient(srv.URL)
+ handle, err := c.CreateTask(context.Background(), "https://example.com/file", nil)
+ require.NoError(t, err)
+ require.Equal(t, "gid-abc", handle.ID)
+}
+
+func TestInfoMapsStatuses(t *testing.T) {
+ cases := []struct {
+ aria2Status string
+ want downloader.Status
+ }{
+ {"complete", downloader.StatusCompleted},
+ {"error", downloader.StatusError},
+ {"waiting", downloader.StatusDownloading},
+ {"paused", downloader.StatusDownloading},
+ }
+ for _, tc := range cases {
+ srv := rpcStub(t, map[string]any{
+ "aria2.tellStatus": map[string]any{
+ "gid": "g1",
+ "status": tc.aria2Status,
+ "totalLength": "100",
+ "completedLength": "50",
+ "bittorrent": map[string]any{},
+ },
+ })
+ c := newTestClient(srv.URL)
+ status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"})
+ require.NoError(t, err)
+ require.Equal(t, tc.want, status.State, "aria2 status %q", tc.aria2Status)
+ srv.Close()
+ }
+}
+
+func TestInfoSeedingWhenTorrentComplete(t *testing.T) {
+ srv := rpcStub(t, map[string]any{
+ "aria2.tellStatus": map[string]any{
+ "gid": "g1",
+ "status": "active",
+ "totalLength": "100",
+ "completedLength": "100",
+ "bittorrent": map[string]any{"mode": "single"},
+ },
+ })
+ defer srv.Close()
+
+ c := newTestClient(srv.URL)
+ status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"})
+ require.NoError(t, err)
+ require.Equal(t, downloader.StatusSeeding, status.State)
+}
+
+func TestCancelRemovesTask(t *testing.T) {
+ srv := rpcStub(t, map[string]any{
+ "aria2.tellStatus": map[string]any{
+ "gid": "g1",
+ "status": "active",
+ "totalLength": "100",
+ "completedLength": "10",
+ "bittorrent": map[string]any{},
+ "dir": "/tmp",
+ },
+ "aria2.remove": "g1",
+ })
+ defer srv.Close()
+
+ c := newTestClient(srv.URL)
+ require.NoError(t, c.Cancel(context.Background(), &downloader.TaskHandle{ID: "g1"}))
+}
diff --git a/pkg/downloader/qbittorrent/qbittorrent.go b/pkg/downloader/qbittorrent/qbittorrent.go
index 110cf2cd..c82c17f3 100644
--- a/pkg/downloader/qbittorrent/qbittorrent.go
+++ b/pkg/downloader/qbittorrent/qbittorrent.go
@@ -189,7 +189,7 @@ func (c *qbittorrentClient) Info(ctx context.Context, handle *downloader.TaskHan
// Combining and converting all info
state := downloader.StatusDownloading
switch torrents[0].State {
- case "downloading", "pausedDL", "allocating", "metaDL", "queuedDL", "stalledDL", "checkingDL", "forcedDL", "checkingResumeData", "moving", "forcedMetaDL":
+ case "downloading", "pausedDL", "stoppedDL", "allocating", "metaDL", "queuedDL", "stalledDL", "checkingDL", "forcedDL", "checkingResumeData", "moving", "forcedMetaDL":
state = downloader.StatusDownloading
case "uploading", "queuedUP", "stalledUP", "checkingUP", "forcedUP":
state = downloader.StatusSeeding
diff --git a/pkg/downloader/slave/slave.go b/pkg/downloader/slave/slave.go
index 2ab27102..fd05942b 100644
--- a/pkg/downloader/slave/slave.go
+++ b/pkg/downloader/slave/slave.go
@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
- "github.com/cloudflare/cfssl/scan/crypto/sha1"
+ "crypto/sha1"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
diff --git a/pkg/filemanager/driver/cos/cos.go b/pkg/filemanager/driver/cos/cos.go
index 1f66e7cf..0a3cbe26 100644
--- a/pkg/filemanager/driver/cos/cos.go
+++ b/pkg/filemanager/driver/cos/cos.go
@@ -234,7 +234,7 @@ func (handler Driver) CORS() error {
return err
}
-// Get 获取文件
+// Open opens a file for reading
func (handler *Driver) Open(ctx context.Context, path string) (*os.File, error) {
return nil, errors.New("not implemented")
}
diff --git a/pkg/filemanager/driver/cos/media.go b/pkg/filemanager/driver/cos/media.go
index 0a894a92..71750034 100644
--- a/pkg/filemanager/driver/cos/media.go
+++ b/pkg/filemanager/driver/cos/media.go
@@ -9,9 +9,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/mediameta"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/samber/lo"
- "math"
"net/http"
- "strconv"
"strings"
"time"
)
@@ -206,78 +204,25 @@ func (handler *Driver) extractMediaInfo(ctx context.Context, path string, opt *u
}
func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta {
- latitude := imageInfo["GPSLatitude"] // 31deg 16.26808'
- longitude := imageInfo["GPSLongitude"] // 120deg 42.91039'
- latRef := imageInfo["GPSLatitudeRef"] // North
- lonRef := imageInfo["GPSLongitudeRef"] // East
-
- // Make sure all value exist in map
- if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" {
- return nil
- }
-
- lat := parseRawGPS(latitude.Value, latRef.Value)
- lon := parseRawGPS(longitude.Value, lonRef.Value)
- if !math.IsNaN(lat) && !math.IsNaN(lon) {
- lat, lng := mediameta.NormalizeGPS(lat, lon)
- return []driver.MediaMeta{{
- Key: mediameta.GpsLat,
- Value: fmt.Sprintf("%f", lat),
- }, {
- Key: mediameta.GpsLng,
- Value: fmt.Sprintf("%f", lng),
- }}
- }
-
- return nil
+ return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value,
+ imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS)
}
func parseRawGPS(gpsStr string, ref string) float64 {
elem := strings.Split(gpsStr, " ")
- if len(elem) < 1 {
- return 0
- }
- var (
- deg float64
- minutes float64
- seconds float64
- )
-
- deg = getGpsElemValue(elem[0])
+ var deg, minutes, seconds float64
+ if len(elem) >= 1 {
+ deg = mediameta.GpsRationalElem(elem[0])
+ }
if len(elem) >= 2 {
- minutes = getGpsElemValue(elem[1])
+ minutes = mediameta.GpsRationalElem(elem[1])
}
if len(elem) >= 3 {
- seconds = getGpsElemValue(elem[2])
- }
-
- decimal := deg + minutes/60.0 + seconds/3600.0
-
- if ref == "S" || ref == "W" {
- return -decimal
- }
-
- return decimal
-}
-
-func getGpsElemValue(elm string) float64 {
- elements := strings.Split(elm, "/")
- if len(elements) != 2 {
- return 0
- }
-
- numerator, err := strconv.ParseFloat(elements[0], 64)
- if err != nil {
- return 0
- }
-
- denominator, err := strconv.ParseFloat(elements[1], 64)
- if err != nil || denominator == 0 {
- return 0
+ seconds = mediameta.GpsRationalElem(elem[2])
}
- return numerator / denominator
+ return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W")
}
func handleCosError(resp string, originErr error) error {
diff --git a/pkg/filemanager/driver/local/local.go b/pkg/filemanager/driver/local/local.go
index 699c5d91..757f1926 100644
--- a/pkg/filemanager/driver/local/local.go
+++ b/pkg/filemanager/driver/local/local.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
@@ -151,16 +152,10 @@ func (handler *Driver) Put(ctx context.Context, file *fs.UploadRequest) error {
}
defer out.Close()
- stat, err := out.Stat()
- if err != nil {
- handler.l.Warning("Failed to read file info: %s", err)
- return err
- }
-
- if stat.Size() < file.Offset {
- return errors.New("size of unfinished uploaded chunks is not as expected")
- }
-
+ // Chunks may arrive out of order under concurrent uploads, so the current
+ // file size is not a valid precondition for the chunk offset. Positioned
+ // writes are safe here; a missing range is caught by the assembled-size
+ // check in CompleteUpload.
if _, err := out.Seek(file.Offset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek to desired offset %d: %s", file.Offset, err)
}
@@ -184,6 +179,8 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e
handler.l.Warning("Failed to delete file: %s", err)
retErr = err
deleteFailed = append(deleteFailed, value)
+ } else {
+ handler.pruneEmptyAncestors(filePath)
}
}
@@ -194,6 +191,23 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e
return deleteFailed, retErr
}
+// pruneEmptyAncestors removes empty parent directories left behind by a
+// deleted blob (#3290). os.Remove fails on non-empty directories, so the
+// walk self-terminates; the climb is also bounded to the application
+// root so it can never escape the storage tree.
+func (handler *Driver) pruneEmptyAncestors(filePath string) {
+ root := filepath.Dir(util.RelativePath("x"))
+ for dir := filepath.Dir(filePath); ; dir = filepath.Dir(dir) {
+ rel, err := filepath.Rel(root, dir)
+ if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
+ return
+ }
+ if err := os.Remove(dir); err != nil {
+ return
+ }
+ }
+}
+
// Thumb 获取文件缩略图
func (handler *Driver) Thumb(ctx context.Context, expire *time.Time, ext string, e fs.Entity) (string, error) {
return "", errors.New("not implemented")
@@ -259,6 +273,24 @@ func (handler *Driver) CancelToken(ctx context.Context, uploadSession *fs.Upload
}
func (handler *Driver) CompleteUpload(ctx context.Context, session *fs.UploadSession) error {
+ // Concurrent chunks are written at their own offsets and may land out of
+ // order; verify the assembled file matches the declared size before
+ // reporting success so a missing or lost range fails loudly instead of
+ // leaving a corrupted blob.
+ if session.Props != nil && session.Props.SavePath != "" {
+ stat, err := os.Stat(handler.LocalPath(ctx, session.Props.SavePath))
+ if err != nil {
+ return fmt.Errorf("failed to stat uploaded file: %w", err)
+ }
+ if stat.Size() != session.Props.Size {
+ return serializer.NewError(
+ serializer.CodeUploadFailed,
+ fmt.Sprintf("uploaded file size mismatch: expected %d, got %d", session.Props.Size, stat.Size()),
+ nil,
+ )
+ }
+ }
+
if session.Callback == "" {
return nil
}
diff --git a/pkg/filemanager/driver/local/local_test.go b/pkg/filemanager/driver/local/local_test.go
new file mode 100644
index 00000000..999d78a6
--- /dev/null
+++ b/pkg/filemanager/driver/local/local_test.go
@@ -0,0 +1,80 @@
+package local
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeletePrunesEmptyAncestors(t *testing.T) {
+ old := util.UseWorkingDir
+ util.UseWorkingDir = true
+ t.Cleanup(func() { util.UseWorkingDir = old })
+ t.Chdir(t.TempDir())
+
+ nested := filepath.Join("uploads", "3", "deep", "nested")
+ require.NoError(t, os.MkdirAll(nested, 0755))
+ require.NoError(t, os.WriteFile(filepath.Join(nested, "a.txt"), []byte("a"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join(nested, "b.txt"), []byte("b"), 0644))
+ require.NoError(t, os.WriteFile(filepath.Join("uploads", "keep.txt"), []byte("k"), 0644))
+
+ d := New(nil, logging.NewConsoleLogger(logging.LevelDebug), nil)
+
+ // Deleting both files should remove deep/nested/3 but stop at uploads
+ // (still holds keep.txt).
+ failed, err := d.Delete(context.Background(),
+ filepath.Join("uploads", "3", "deep", "nested", "a.txt"),
+ filepath.Join("uploads", "3", "deep", "nested", "b.txt"),
+ )
+ require.NoError(t, err)
+ require.Empty(t, failed)
+ require.NoDirExists(t, filepath.Join("uploads", "3"))
+ require.DirExists(t, "uploads")
+ require.FileExists(t, filepath.Join("uploads", "keep.txt"))
+}
+
+func TestPutOutOfOrderChunks(t *testing.T) {
+ old := util.UseWorkingDir
+ util.UseWorkingDir = true
+ t.Cleanup(func() { util.UseWorkingDir = old })
+ t.Chdir(t.TempDir())
+
+ d := New(nil, logging.NewConsoleLogger(logging.LevelDebug), nil)
+ ctx := context.Background()
+ savePath := filepath.Join("uploads", "blob.bin")
+
+ props := &fs.UploadProps{SavePath: savePath, Size: 12}
+ put := func(offset int64, data string) {
+ require.NoError(t, d.Put(ctx, &fs.UploadRequest{
+ Props: props,
+ Mode: fs.ModeOverwrite,
+ Offset: offset,
+ File: io.NopCloser(strings.NewReader(data)),
+ }))
+ }
+
+ // Later chunk lands first — must not be rejected by the offset check.
+ put(8, "IJXL")
+ put(4, "EFGH")
+ put(0, "ABCD")
+
+ content, err := os.ReadFile(savePath)
+ require.NoError(t, err)
+ require.Equal(t, "ABCDEFGHIJXL", string(content))
+
+ // Completion verifies the assembled size.
+ require.NoError(t, d.CompleteUpload(ctx, &fs.UploadSession{Props: props}))
+
+ // A lost chunk fails loudly at completion instead of silently corrupting.
+ require.NoError(t, os.WriteFile(savePath, []byte("short"), 0644))
+ err = d.CompleteUpload(ctx, &fs.UploadSession{Props: props})
+ require.Error(t, err)
+}
diff --git a/pkg/filemanager/driver/obs/media.go b/pkg/filemanager/driver/obs/media.go
index fec894fd..596e27c9 100644
--- a/pkg/filemanager/driver/obs/media.go
+++ b/pkg/filemanager/driver/obs/media.go
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
- "math"
"net/http"
- "strconv"
"strings"
"time"
@@ -62,76 +60,23 @@ func (d *Driver) MediaMeta(ctx context.Context, path, ext, language string) ([]d
}
func parseGpsInfo(imageInfo map[string]string) []driver.MediaMeta {
- latitude := imageInfo["GPSLatitude"] // 31/1, 162680820/10000000, 0/1
- longitude := imageInfo["GPSLongitude"] // 120/1, 429103939/10000000, 0/1
- latRef := imageInfo["GPSLatitudeRef"] // N
- lonRef := imageInfo["GPSLongitudeRef"] // E
-
- // Make sure all value exist in map
- if latitude == "" || longitude == "" || latRef == "" || lonRef == "" {
- return nil
- }
-
- lat := parseRawGPS(latitude, latRef)
- lon := parseRawGPS(longitude, lonRef)
- if !math.IsNaN(lat) && !math.IsNaN(lon) {
- lat, lng := mediameta.NormalizeGPS(lat, lon)
- return []driver.MediaMeta{{
- Key: mediameta.GpsLat,
- Value: fmt.Sprintf("%f", lat),
- }, {
- Key: mediameta.GpsLng,
- Value: fmt.Sprintf("%f", lng),
- }}
- }
-
- return nil
+ return mediameta.GpsMeta(imageInfo["GPSLatitude"], imageInfo["GPSLongitude"],
+ imageInfo["GPSLatitudeRef"], imageInfo["GPSLongitudeRef"], parseRawGPS)
}
func parseRawGPS(gpsStr string, ref string) float64 {
elem := strings.Split(gpsStr, ", ")
- if len(elem) < 1 {
- return 0
- }
-
- var (
- deg float64
- minutes float64
- seconds float64
- )
- deg = getGpsElemValue(elem[0])
+ var deg, minutes, seconds float64
+ if len(elem) >= 1 {
+ deg = mediameta.GpsRationalElem(elem[0])
+ }
if len(elem) >= 2 {
- minutes = getGpsElemValue(elem[1])
+ minutes = mediameta.GpsRationalElem(elem[1])
}
if len(elem) >= 3 {
- seconds = getGpsElemValue(elem[2])
- }
-
- decimal := deg + minutes/60.0 + seconds/3600.0
-
- if ref == "S" || ref == "W" {
- return -decimal
- }
-
- return decimal
-}
-
-func getGpsElemValue(elm string) float64 {
- elements := strings.Split(elm, "/")
- if len(elements) != 2 {
- return 0
- }
-
- numerator, err := strconv.ParseFloat(elements[0], 64)
- if err != nil {
- return 0
- }
-
- denominator, err := strconv.ParseFloat(elements[1], 64)
- if err != nil || denominator == 0 {
- return 0
+ seconds = mediameta.GpsRationalElem(elem[2])
}
- return numerator / denominator
+ return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W")
}
diff --git a/pkg/filemanager/driver/oss/media.go b/pkg/filemanager/driver/oss/media.go
index 11db9483..808ae12d 100644
--- a/pkg/filemanager/driver/oss/media.go
+++ b/pkg/filemanager/driver/oss/media.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
- "math"
"net/http"
"strconv"
"strings"
@@ -292,45 +291,17 @@ func (handler *Driver) extractMediaInfo(ctx context.Context, path string, catego
}
func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta {
- latitude := imageInfo["GPSLatitude"] // 31deg 16.26808'
- longitude := imageInfo["GPSLongitude"] // 120deg 42.91039'
- latRef := imageInfo["GPSLatitudeRef"] // North
- lonRef := imageInfo["GPSLongitudeRef"] // East
-
- // Make sure all value exist in map
- if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" {
- return nil
- }
-
- lat := parseRawGPS(latitude.Value, latRef.Value)
- lon := parseRawGPS(longitude.Value, lonRef.Value)
- if !math.IsNaN(lat) && !math.IsNaN(lon) {
- lat, lng := mediameta.NormalizeGPS(lat, lon)
- return []driver.MediaMeta{{
- Key: mediameta.GpsLat,
- Value: fmt.Sprintf("%f", lat),
- }, {
- Key: mediameta.GpsLng,
- Value: fmt.Sprintf("%f", lng),
- }}
- }
-
- return nil
+ return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value,
+ imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS)
}
func parseRawGPS(gpsStr string, ref string) float64 {
elem := strings.Split(gpsStr, " ")
- if len(elem) < 1 {
- return 0
- }
- var (
- deg float64
- minutes float64
- seconds float64
- )
-
- deg, _ = strconv.ParseFloat(strings.TrimSuffix(elem[0], "deg"), 64)
+ var deg, minutes, seconds float64
+ if len(elem) >= 1 {
+ deg, _ = strconv.ParseFloat(strings.TrimSuffix(elem[0], "deg"), 64)
+ }
if len(elem) >= 2 {
minutes, _ = strconv.ParseFloat(strings.TrimSuffix(elem[1], "'"), 64)
}
@@ -338,13 +309,7 @@ func parseRawGPS(gpsStr string, ref string) float64 {
seconds, _ = strconv.ParseFloat(strings.TrimSuffix(elem[2], "\""), 64)
}
- decimal := deg + minutes/60.0 + seconds/3600.0
-
- if ref == "South" || ref == "West" {
- return -decimal
- }
-
- return decimal
+ return mediameta.DMSDecimal(deg, minutes, seconds, ref == "South" || ref == "West")
}
func handleOssError(resp string, originErr error) error {
diff --git a/pkg/filemanager/driver/oss/oss.go b/pkg/filemanager/driver/oss/oss.go
index 1b382533..c0191e37 100644
--- a/pkg/filemanager/driver/oss/oss.go
+++ b/pkg/filemanager/driver/oss/oss.go
@@ -351,7 +351,7 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e
// 统计未删除的文件
failed = append(
failed,
- util.SliceDifference(files,
+ util.SliceDifference(group,
lo.Map(delRes.DeletedObjects, func(v oss.DeletedInfo, i int) string {
return *v.Key
}),
diff --git a/pkg/filemanager/driver/qiniu/media.go b/pkg/filemanager/driver/qiniu/media.go
index 81b106cc..5875a5be 100644
--- a/pkg/filemanager/driver/qiniu/media.go
+++ b/pkg/filemanager/driver/qiniu/media.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
- "math"
"net/http"
"net/url"
"strconv"
@@ -131,45 +130,17 @@ func unmarshalError(resp string, originErr error) error {
}
func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta {
- latitude := imageInfo["GPSLatitude"] // 31, 16.2680820, 0
- longitude := imageInfo["GPSLongitude"] // 120, 42.9103939, 0
- latRef := imageInfo["GPSLatitudeRef"] // N
- lonRef := imageInfo["GPSLongitudeRef"] // E
-
- // Make sure all value exist in map
- if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" {
- return nil
- }
-
- lat := parseRawGPS(latitude.Value, latRef.Value)
- lon := parseRawGPS(longitude.Value, lonRef.Value)
- if !math.IsNaN(lat) && !math.IsNaN(lon) {
- lat, lng := mediameta.NormalizeGPS(lat, lon)
- return []driver.MediaMeta{{
- Key: mediameta.GpsLat,
- Value: fmt.Sprintf("%f", lat),
- }, {
- Key: mediameta.GpsLng,
- Value: fmt.Sprintf("%f", lng),
- }}
- }
-
- return nil
+ return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value,
+ imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS)
}
func parseRawGPS(gpsStr string, ref string) float64 {
elem := strings.Split(gpsStr, ", ")
- if len(elem) < 1 {
- return 0
- }
-
- var (
- deg float64
- minutes float64
- seconds float64
- )
- deg, _ = strconv.ParseFloat(elem[0], 64)
+ var deg, minutes, seconds float64
+ if len(elem) >= 1 {
+ deg, _ = strconv.ParseFloat(elem[0], 64)
+ }
if len(elem) >= 2 {
minutes, _ = strconv.ParseFloat(elem[1], 64)
}
@@ -177,11 +148,5 @@ func parseRawGPS(gpsStr string, ref string) float64 {
seconds, _ = strconv.ParseFloat(elem[2], 64)
}
- decimal := deg + minutes/60.0 + seconds/3600.0
-
- if ref == "S" || ref == "W" {
- return -decimal
- }
-
- return decimal
+ return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W")
}
diff --git a/pkg/filemanager/driver/remote/client.go b/pkg/filemanager/driver/remote/client.go
index 859abafa..6918c9f5 100644
--- a/pkg/filemanager/driver/remote/client.go
+++ b/pkg/filemanager/driver/remote/client.go
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
+ "sync/atomic"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
@@ -91,6 +92,37 @@ type remoteClient struct {
httpClient request.Client
settings setting.Provider
l logging.Logger
+ // clockOffset compensates master/slave clock skew, learned from a
+ // signature-expired response's Date header (upstream #3242).
+ clockOffset atomic.Int64
+}
+
+// request sends a signed slave API request, applying the learned clock offset
+// to the signature base time so skewed master/slave clocks do not produce
+// expired signatures.
+func (c *remoteClient) request(method, target string, body io.Reader, opts ...request.Option) *request.Response {
+ if off := c.clockOffset.Load(); off != 0 {
+ opts = append(opts, request.WithSignBaseTime(time.Now().Add(time.Duration(off)*time.Second)))
+ }
+ return c.httpClient.Request(method, target, body, opts...)
+}
+
+// learnClockOffset stores the master-minus-local clock offset computed from
+// the response Date header.
+func (c *remoteClient) learnClockOffset(resp *http.Response) {
+ if resp == nil {
+ return
+ }
+ masterTime, err := http.ParseTime(resp.Header.Get("Date"))
+ if err != nil {
+ return
+ }
+ // Date has second precision; round up so truncation never under-compensates.
+ offset := int64(masterTime.Sub(time.Now()).Seconds()) + 1
+ if offset != 0 {
+ c.l.Info("Compensating master clock offset of %ds after signature expiry", offset)
+ c.clockOffset.Store(offset)
+ }
}
func (c *remoteClient) Upload(ctx context.Context, file *fs.UploadRequest) error {
@@ -133,7 +165,7 @@ func (c *remoteClient) Upload(ctx context.Context, file *fs.UploadRequest) error
}
func (c *remoteClient) DeleteUploadSession(ctx context.Context, sessionID string) error {
- resp, err := c.httpClient.Request(
+ resp, err := c.request(
"DELETE",
"upload/"+sessionID,
nil,
@@ -161,7 +193,7 @@ func (c *remoteClient) DeleteFiles(ctx context.Context, files ...string) ([]stri
return files, fmt.Errorf("failed to marshal delete request: %w", err)
}
- resp, err := c.httpClient.Request(
+ resp, err := c.request(
"DELETE",
"file",
bytes.NewReader(reqStr),
@@ -185,7 +217,7 @@ func (c *remoteClient) DeleteFiles(ctx context.Context, files ...string) ([]stri
}
func (c *remoteClient) MediaMeta(ctx context.Context, src, ext, language string) ([]driver.MediaMeta, error) {
- resp, err := c.httpClient.Request(
+ resp, err := c.request(
http.MethodGet,
routes.SlaveMediaMetaRoute(src, ext, language),
nil,
@@ -214,14 +246,33 @@ func (c *remoteClient) CreateUploadSession(ctx context.Context, session *fs.Uplo
return err
}
- bodyReader := strings.NewReader(string(reqBodyEncoded))
- resp, err := c.httpClient.Request(
- "PUT",
- "upload",
- bodyReader,
- request.WithContext(ctx),
- request.WithLogger(c.l),
- ).CheckHTTPResponse(200).DecodeResponse()
+ send := func() (*serializer.Response, error) {
+ raw := c.request(
+ "PUT",
+ "upload",
+ strings.NewReader(string(reqBodyEncoded)),
+ request.WithContext(ctx),
+ request.WithLogger(c.l),
+ )
+ httpResp := raw.Response
+ resp, err := raw.CheckHTTPResponse(200).DecodeResponse()
+ if err == nil && resp.Code == serializer.CodeSignExpired {
+ // Master clock is ahead of ours beyond the sign TTL; learn the
+ // offset from the response Date header and retry once (#3242).
+ c.learnClockOffset(httpResp)
+ raw = c.request(
+ "PUT",
+ "upload",
+ strings.NewReader(string(reqBodyEncoded)),
+ request.WithContext(ctx),
+ request.WithLogger(c.l),
+ )
+ resp, err = raw.CheckHTTPResponse(200).DecodeResponse()
+ }
+ return resp, err
+ }
+
+ resp, err := send()
if err != nil {
return err
}
@@ -234,7 +285,7 @@ func (c *remoteClient) CreateUploadSession(ctx context.Context, session *fs.Uplo
}
func (c *remoteClient) List(ctx context.Context, path string, recursive bool) ([]fs.PhysicalObject, error) {
- resp, err := c.httpClient.Request(
+ resp, err := c.request(
http.MethodGet,
routes.SlaveFileListRoute(path, recursive),
nil,
@@ -271,7 +322,7 @@ func (c *remoteClient) GetUploadURL(ctx context.Context, expires time.Time, sess
}
func (c *remoteClient) uploadChunk(ctx context.Context, sessionID string, index int, chunk io.Reader, overwrite bool, size int64) error {
- resp, err := c.httpClient.Request(
+ resp, err := c.request(
"POST",
fmt.Sprintf("upload/%s?chunk=%d", sessionID, index),
chunk,
diff --git a/pkg/filemanager/driver/remote/client_test.go b/pkg/filemanager/driver/remote/client_test.go
new file mode 100644
index 00000000..6688c5f7
--- /dev/null
+++ b/pkg/filemanager/driver/remote/client_test.go
@@ -0,0 +1,76 @@
+package remote
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/auth"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/request"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCreateUploadSessionClockSkewRetry reproduces upstream #3242: when the
+// slave's clock is behind the master's, the upload-session signature arrives
+// already expired. The client must learn the offset from the response Date
+// header and retry with a compensated base time.
+func TestCreateUploadSessionClockSkewRetry(t *testing.T) {
+ const skewSeconds = 3600 // master is 1h ahead of slave
+ masterNow := func() time.Time { return time.Now().Add(skewSeconds * time.Second) }
+
+ var calls atomic.Int32
+ var lastExpires int64
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ authHeader := r.Header.Get("Authorization")
+ parts := strings.Split(authHeader, ":")
+ expires, _ := strconv.ParseInt(parts[len(parts)-1], 10, 64)
+ lastExpires = expires
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Date", masterNow().UTC().Format(http.TimeFormat))
+ if expires < masterNow().Unix() {
+ w.Write([]byte(`{"code":40005,"msg":"signature expired"}`))
+ return
+ }
+ w.Write([]byte(`{"code":0}`))
+ }))
+ defer srv.Close()
+
+ c := &remoteClient{
+ l: logging.NewConsoleLogger(logging.LevelError),
+ httpClient: request.NewClient(
+ nil,
+ request.WithEndpoint(srv.URL),
+ request.WithCredential(auth.HMACAuth{SecretKey: []byte("test")}, 60),
+ ),
+ }
+
+ err := c.CreateUploadSession(context.Background(), &fs.UploadSession{}, false)
+ require.NoError(t, err)
+ require.Equal(t, int32(2), calls.Load(), "expected one expired attempt plus one compensated retry")
+ require.InDelta(t, skewSeconds, c.clockOffset.Load(), 1)
+ require.GreaterOrEqual(t, lastExpires, masterNow().Unix())
+}
+
+// TestLearnClockOffsetIgnoresBadHeaders ensures malformed or missing Date
+// headers leave the offset untouched.
+func TestLearnClockOffsetIgnoresBadHeaders(t *testing.T) {
+ c := &remoteClient{l: logging.NewConsoleLogger(logging.LevelError)}
+
+ c.learnClockOffset(nil)
+ require.Zero(t, c.clockOffset.Load())
+
+ c.learnClockOffset(&http.Response{Header: http.Header{}})
+ require.Zero(t, c.clockOffset.Load())
+
+ c.learnClockOffset(&http.Response{Header: http.Header{"Date": {"garbage"}}})
+ require.Zero(t, c.clockOffset.Load())
+}
diff --git a/pkg/filemanager/driver/remote/remote.go b/pkg/filemanager/driver/remote/remote.go
index b7a762df..878d4352 100644
--- a/pkg/filemanager/driver/remote/remote.go
+++ b/pkg/filemanager/driver/remote/remote.go
@@ -98,7 +98,7 @@ func (handler *Driver) Thumb(ctx context.Context, expire *time.Time, ext string,
return "", fmt.Errorf("parse server url failed: %w", err)
}
- thumbURL := routes.SlaveThumbUrl(serverURL, e.Source(), ext)
+ thumbURL := routes.SlaveThumbUrl(serverURL, e.Source(), ext, e.ID())
signedThumbURL, err := auth.SignURI(ctx, handler.AuthInstance, thumbURL.String(), expire)
if err != nil {
return "", err
diff --git a/pkg/filemanager/driver/upyun/media.go b/pkg/filemanager/driver/upyun/media.go
index bf36acb1..7ec0971d 100644
--- a/pkg/filemanager/driver/upyun/media.go
+++ b/pkg/filemanager/driver/upyun/media.go
@@ -8,9 +8,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/mediameta"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/samber/lo"
- "math"
"net/http"
- "strconv"
"strings"
"time"
)
@@ -31,8 +29,6 @@ func (handler *Driver) extractImageMeta(ctx context.Context, path string) ([]dri
return nil, err
}
- fmt.Println(resp)
-
var imageInfo ImageInfo
if err := json.Unmarshal([]byte(resp), &imageInfo); err != nil {
return nil, fmt.Errorf("failed to unmarshal image info: %w", err)
@@ -79,76 +75,23 @@ func unmarshalError(resp string, err error) error {
}
func parseGpsInfo(imageInfo map[string]string) []driver.MediaMeta {
- latitude := imageInfo["GPSLatitude"] // 31/1, 162680820/10000000, 0/1
- longitude := imageInfo["GPSLongitude"] // 120/1, 429103939/10000000, 0/1
- latRef := imageInfo["GPSLatitudeRef"] // N
- lonRef := imageInfo["GPSLongitudeRef"] // E
-
- // Make sure all value exist in map
- if latitude == "" || longitude == "" || latRef == "" || lonRef == "" {
- return nil
- }
-
- lat := parseRawGPS(latitude, latRef)
- lon := parseRawGPS(longitude, lonRef)
- if !math.IsNaN(lat) && !math.IsNaN(lon) {
- lat, lng := mediameta.NormalizeGPS(lat, lon)
- return []driver.MediaMeta{{
- Key: mediameta.GpsLat,
- Value: fmt.Sprintf("%f", lat),
- }, {
- Key: mediameta.GpsLng,
- Value: fmt.Sprintf("%f", lng),
- }}
- }
-
- return nil
+ return mediameta.GpsMeta(imageInfo["GPSLatitude"], imageInfo["GPSLongitude"],
+ imageInfo["GPSLatitudeRef"], imageInfo["GPSLongitudeRef"], parseRawGPS)
}
func parseRawGPS(gpsStr string, ref string) float64 {
elem := strings.Split(gpsStr, ",")
- if len(elem) < 1 {
- return 0
- }
-
- var (
- deg float64
- minutes float64
- seconds float64
- )
- deg = getGpsElemValue(elem[0])
+ var deg, minutes, seconds float64
+ if len(elem) >= 1 {
+ deg = mediameta.GpsRationalElem(elem[0])
+ }
if len(elem) >= 2 {
- minutes = getGpsElemValue(elem[1])
+ minutes = mediameta.GpsRationalElem(elem[1])
}
if len(elem) >= 3 {
- seconds = getGpsElemValue(elem[2])
- }
-
- decimal := deg + minutes/60.0 + seconds/3600.0
-
- if ref == "S" || ref == "W" {
- return -decimal
- }
-
- return decimal
-}
-
-func getGpsElemValue(elm string) float64 {
- elements := strings.Split(elm, "/")
- if len(elements) != 2 {
- return 0
- }
-
- numerator, err := strconv.ParseFloat(elements[0], 64)
- if err != nil {
- return 0
- }
-
- denominator, err := strconv.ParseFloat(elements[1], 64)
- if err != nil || denominator == 0 {
- return 0
+ seconds = mediameta.GpsRationalElem(elem[2])
}
- return numerator / denominator
+ return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W")
}
diff --git a/pkg/filemanager/fs/dbfs/dbfs.go b/pkg/filemanager/fs/dbfs/dbfs.go
index ef2d4581..81e9d81e 100644
--- a/pkg/filemanager/fs/dbfs/dbfs.go
+++ b/pkg/filemanager/fs/dbfs/dbfs.go
@@ -22,10 +22,9 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
- "github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gofrs/uuid"
"github.com/samber/lo"
- "golang.org/x/tools/container/intsets"
+ "math"
)
const (
@@ -43,8 +42,44 @@ type (
// IsDownloadCtxKey marks the request as an explicit file download (as
// opposed to an inline preview fetch). Navigator hooks consult it.
IsDownloadCtxKey struct{}
+ // ExpectedSourceIDsCtxKey carries source-file identity preconditions
+ // for move/copy/rename: a []int positionally aligned with the source
+ // URI list. An entry of 0 disables the check for that position (#3565).
+ ExpectedSourceIDsCtxKey struct{}
+ // MoveConflictCtxKey carries the on-conflict policy for move/copy:
+ // "skip" drops colliding entries, "overwrite" deletes the colliding
+ // destination first. Empty keeps the fail-fast default (#3159).
+ MoveConflictCtxKey struct{}
)
+const (
+ MoveConflictSkip = "skip"
+ MoveConflictOverwrite = "overwrite"
+)
+
+// moveConflictMode returns the configured on-conflict policy.
+func moveConflictMode(ctx context.Context) string {
+ mode, _ := ctx.Value(MoveConflictCtxKey{}).(string)
+ return mode
+}
+
+// WithExpectedSourceIDs records the expected database IDs of the source
+// files so a delayed/retried request cannot silently operate on a new
+// file that reused the same path.
+func WithExpectedSourceIDs(ctx context.Context, ids []int) context.Context {
+ return context.WithValue(ctx, ExpectedSourceIDsCtxKey{}, ids)
+}
+
+// sourceIDMismatch reports whether the resolved file violates the
+// expected-source precondition at the given position.
+func sourceIDMismatch(ctx context.Context, pos int, actual int) bool {
+ expected, ok := ctx.Value(ExpectedSourceIDsCtxKey{}).([]int)
+ if !ok || pos >= len(expected) || expected[pos] == 0 {
+ return false
+ }
+ return expected[pos] != actual
+}
+
// writePermitted reports whether the user may mutate file under the given
// capability. File owners are always permitted; non-owners (e.g. share
// visitors) require the capability in the file's resolved capability set,
@@ -57,26 +92,41 @@ func (f *DBFS) writePermitted(file *File, capability NavigatorCapability) bool {
return caps != nil && caps.Enabled(int(capability))
}
-func NewDatabaseFS(u *ent.User, fileClient inventory.FileClient, shareClient inventory.ShareClient,
- l logging.Logger, ls lock.LockSystem, settingClient setting.Provider,
- storagePolicyClient inventory.StoragePolicyClient, hasher hashid.Encoder, userClient inventory.UserClient,
- cache, stateKv cache.Driver, directLinkClient inventory.DirectLinkClient, encryptorFactory encrypt.CryptorFactory, eventHub eventhub.EventHub) fs.FileSystem {
+// DBFSDependencies groups the collaborators wired into a DBFS instance so the
+// constructor call site names each dependency.
+type DBFSDependencies struct {
+ FileClient inventory.FileClient
+ ShareClient inventory.ShareClient
+ UserClient inventory.UserClient
+ StoragePolicyClient inventory.StoragePolicyClient
+ DirectLinkClient inventory.DirectLinkClient
+ Logger logging.Logger
+ LockSystem lock.LockSystem
+ SettingProvider setting.Provider
+ Hasher hashid.Encoder
+ Cache cache.Driver
+ StateKV cache.Driver
+ EncryptorFactory encrypt.CryptorFactory
+ EventHub eventhub.EventHub
+}
+
+func NewDatabaseFS(u *ent.User, deps DBFSDependencies) fs.FileSystem {
return &DBFS{
user: u,
navigators: make(map[string]Navigator),
- fileClient: fileClient,
- shareClient: shareClient,
- l: l,
- ls: ls,
- settingClient: settingClient,
- storagePolicyClient: storagePolicyClient,
- hasher: hasher,
- userClient: userClient,
- cache: cache,
- stateKv: stateKv,
- directLinkClient: directLinkClient,
- encryptorFactory: encryptorFactory,
- eventHub: eventHub,
+ fileClient: deps.FileClient,
+ shareClient: deps.ShareClient,
+ l: deps.Logger,
+ ls: deps.LockSystem,
+ settingClient: deps.SettingProvider,
+ storagePolicyClient: deps.StoragePolicyClient,
+ hasher: deps.Hasher,
+ userClient: deps.UserClient,
+ cache: deps.Cache,
+ stateKv: deps.StateKV,
+ directLinkClient: deps.DirectLinkClient,
+ encryptorFactory: deps.EncryptorFactory,
+ eventHub: deps.EventHub,
}
}
@@ -485,7 +535,7 @@ func (f *DBFS) Get(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fil
// disable load metadata to speed up
ctxWalk := context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, false)
- if err := navigator.Walk(ctxWalk, []*File{target}, limit, intsets.MaxInt, func(files []*File, l int) error {
+ if err := navigator.Walk(ctxWalk, []*File{target}, limit, math.MaxInt, func(files []*File, l int) error {
for _, file := range files {
if file.ID() == target.ID() {
continue
@@ -804,7 +854,14 @@ func (f *DBFS) navigatorId(path *fs.URI) string {
func generateSavePath(policy *ent.StoragePolicy, req *fs.UploadRequest, user *ent.User) string {
currentTime := time.Now()
dynamicReplace := func(rule string, pathAvailable bool) string {
- return util.ReplaceMagicVar(rule, fs.Separator, pathAvailable, false, currentTime, user.ID, req.Props.Uri.Name(), req.Props.Uri.Dir(), "")
+ return fs.ReplaceMagicVar(rule, fs.MagicVarProps{
+ FsSeparator: fs.Separator,
+ PathAvailable: pathAvailable,
+ Time: currentTime,
+ UserID: user.ID,
+ OriginName: req.Props.Uri.Name(),
+ OriginPath: req.Props.Uri.Dir(),
+ })
}
dirRule := policy.DirNameRule
diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go
index 43940787..80871529 100644
--- a/pkg/filemanager/fs/dbfs/manage.go
+++ b/pkg/filemanager/fs/dbfs/manage.go
@@ -16,7 +16,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/samber/lo"
- "golang.org/x/tools/container/intsets"
+ "math"
)
func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType, opts ...fs.Option) (fs.File, error) {
@@ -83,8 +83,9 @@ func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType
return nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("parent must be a valid folder"))
}
- // Validate object name
- if err := validateFileName(desired[i]); err != nil {
+ // Validate object name — folder segments keep the strict rule set
+ // since their governing policy is ambiguous.
+ if err := validateFileName(desired[i], nil); err != nil {
return nil, fs.ErrIllegalObjectName.WithError(err)
}
@@ -160,6 +161,9 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil
if err != nil {
return nil, nil, fmt.Errorf("failed to get target file: %w", err)
}
+ if sourceIDMismatch(ctx, 0, target.Model.ID) {
+ return nil, nil, fs.ErrModified.WithError(fmt.Errorf("source file no longer matches the expected file"))
+ }
oldName := target.Name()
if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilityRenameFile) {
@@ -171,16 +175,17 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil
return nil, nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot modify root folder"))
}
+ policy, err := f.getPreferredPolicy(ctx, target)
+ if err != nil {
+ return nil, nil, err
+ }
+
// Validate new name
- if err := validateFileName(newName); err != nil {
+ if err := validateFileName(newName, policy); err != nil {
return nil, nil, fs.ErrIllegalObjectName.WithError(err)
}
// If target is a file, validate file extension
- policy, err := f.getPreferredPolicy(ctx, target)
- if err != nil {
- return nil, nil, err
- }
if target.Type() == types.FileTypeFile {
if err := validateExtension(newName, policy); err != nil {
@@ -305,11 +310,16 @@ func (f *DBFS) SoftDelete(ctx context.Context, path ...*fs.URI) error {
return serializer.NewError(serializer.CodeDBError, "failed to soft-delete file", err)
}
- // Save restore uri into metadata
+ // Save restore uri into metadata. A user-level retention overrides
+ // the group default when set.
+ retention := target.Owner().Edges.Group.Settings.TrashRetention
+ if us := target.Owner().Settings; us != nil && us.TrashRetention > 0 {
+ retention = us.TrashRetention
+ }
if err := fc.UpsertMetadata(ctx, target.Model, map[string]string{
MetadataRestoreUri: target.Uri(true).String(),
MetadataExpectedCollectTime: strconv.FormatInt(
- time.Now().Add(time.Duration(target.Owner().Edges.Group.Settings.TrashRetention)*time.Second).Unix(),
+ time.Now().Add(time.Duration(retention)*time.Second).Unix(),
10),
}, nil); err != nil {
_ = inventory.Rollback(tx)
@@ -557,11 +567,12 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo
ae := serializer.NewAggregateError()
fileNavGroup := make(map[Navigator][]*File)
+ navOf := make(map[*File]Navigator)
dstRootPath := destination.Uri(true)
ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
- for _, p := range path {
+ for i, p := range path {
// Get navigator
navigator, err := f.getNavigator(ctx, p, NavigatorCapabilityLockFile)
if err != nil {
@@ -582,6 +593,11 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo
continue
}
+ if sourceIDMismatch(ctx, i, target.Model.ID) {
+ ae.Add(p.String(), fs.ErrModified.WithError(fmt.Errorf("source file no longer matches the expected file")))
+ continue
+ }
+
// Copy reads the source, move deletes it.
requiredSrcCap := NavigatorCapabilityDownloadFile
if !isCopy {
@@ -607,6 +623,7 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo
}
targets = append(targets, target)
+ navOf[target] = navigator
if isCopy {
if _, ok := fileNavGroup[navigator]; !ok {
fileNavGroup[navigator] = make([]*File, 0)
@@ -615,6 +632,13 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo
}
}
+ // Resolve name conflicts against existing destination children before
+ // locking: skip drops the colliding target, overwrite deletes the
+ // colliding destination object first (#3159).
+ if mode := moveConflictMode(ctx); mode != "" && len(targets) > 0 {
+ targets, fileNavGroup = f.resolveMoveConflicts(ctx, targets, navOf, destination, isCopy, mode, ae)
+ }
+
indexDiff := &fs.IndexDiff{}
if len(targets) > 0 {
// Lock all targets
@@ -855,7 +879,7 @@ func (f *DBFS) deleteFiles(ctx context.Context, targets map[Navigator][]*File, f
// listing resolves via HasParentWith, so parent rows must stay
// alive until their level has been fetched.
folderModels := make([]*ent.File, 0, 64)
- if err := n.Walk(ctx, files, intsets.MaxInt, intsets.MaxInt, func(targets []*File, level int) error {
+ if err := n.Walk(ctx, files, math.MaxInt, math.MaxInt, func(targets []*File, level int) error {
indexToDelete = append(indexToDelete, lo.Map(targets, func(item *File, index int) int {
return item.ID()
})...)
@@ -924,7 +948,7 @@ func (f *DBFS) copyFiles(ctx context.Context, targets map[Navigator][]*File, des
initialDstMap[file.Model.FileChildren] = dstAncestors
}
- if err := n.Walk(ctx, files, limit, intsets.MaxInt, func(targets []*File, level int) error {
+ if err := n.Walk(ctx, files, limit, math.MaxInt, func(targets []*File, level int) error {
// check capacity for each file
sizeTotal := int64(0)
for _, file := range targets {
@@ -1043,3 +1067,48 @@ func (f *DBFS) moveFiles(ctx context.Context, targets []*File, destination *File
return storageDiff, nil, nil
}
+
+// resolveMoveConflicts filters or replaces targets whose names collide
+// with existing destination children (#3159). "skip" drops the colliding
+// target with a per-file ErrFileExisted; "overwrite" deletes the
+// colliding destination object through the regular Delete path first.
+func (f *DBFS) resolveMoveConflicts(ctx context.Context, targets []*File, navOf map[*File]Navigator, destination *File, isCopy bool, mode string, ae *serializer.AggregateError) ([]*File, map[Navigator][]*File) {
+ surviving := make([]*File, 0, len(targets))
+ group := make(map[Navigator][]*File)
+ dstBase := destination.Uri(true)
+
+ for _, target := range targets {
+ dstName := target.Name()
+ if !isCopy {
+ if _, ok := target.Metadata()[MetadataRestoreUri]; ok {
+ dstName = target.DisplayName()
+ }
+ }
+
+ _, err := f.fileClient.GetChildFile(ctx, destination.Model, destination.OwnerID(), dstName, false)
+ if err != nil && !ent.IsNotFound(err) {
+ ae.Add(target.Uri(true).String(), fmt.Errorf("failed to check destination conflict: %w", err))
+ continue
+ }
+
+ if err == nil {
+ // Destination already holds an object under the same name.
+ if mode == MoveConflictSkip {
+ ae.Add(target.Uri(true).String(), fs.ErrFileExisted)
+ continue
+ }
+ if _, _, err := f.Delete(ctx, []*fs.URI{dstBase.Join(dstName)}); err != nil {
+ ae.Add(target.Uri(true).String(), err)
+ continue
+ }
+ }
+
+ surviving = append(surviving, target)
+ if isCopy {
+ nav := navOf[target]
+ group[nav] = append(group[nav], target)
+ }
+ }
+
+ return surviving, group
+}
diff --git a/pkg/filemanager/fs/dbfs/manage_conflict_test.go b/pkg/filemanager/fs/dbfs/manage_conflict_test.go
new file mode 100644
index 00000000..bed145a1
--- /dev/null
+++ b/pkg/filemanager/fs/dbfs/manage_conflict_test.go
@@ -0,0 +1,119 @@
+package dbfs
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
+ "github.com/stretchr/testify/require"
+)
+
+// conflictTestFixture creates user -> root -> dst folder, optionally with
+// dstChildren file rows inside dst. Returns the user, the dst folder
+// wrapped as *File and a real FileClient backed by the enttest client.
+func conflictTestFixture(t *testing.T, client *ent.Client, dstChildren ...string) (*ent.User, *ent.File, *File, inventory.FileClient) {
+ t.Helper()
+ ctx := context.Background()
+ group := client.Group.Create().SetName("conflict").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ user := client.User.Create().SetEmail("c@example.com").SetNick("c").SetGroup(group).SaveX(ctx)
+ root := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(user).SaveX(ctx)
+ dst := client.File.Create().SetName("dst").SetType(int(types.FileTypeFolder)).SetOwner(user).SetParent(root).SaveX(ctx)
+ for _, name := range dstChildren {
+ client.File.Create().SetName(name).SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(dst).SaveX(ctx)
+ }
+
+ dstUri, err := fs.NewUriFromString(fmt.Sprintf("%s/dst", fs.NewMyUri("")))
+ require.NoError(t, err)
+ dstFile := newFile(nil, dst)
+ dstFile.OwnerModel = user
+ dstFile.Path[0] = dstUri
+
+ return user, root, dstFile, inventory.NewFileClient(client, conf.SQLiteDB, nil)
+}
+
+func conflictTestTarget(t *testing.T, model *ent.File, user *ent.User) *File {
+ t.Helper()
+ u, err := fs.NewUriFromString(fmt.Sprintf("%s/%s", fs.NewMyUri(""), model.Name))
+ require.NoError(t, err)
+ f := newFile(nil, model)
+ f.OwnerModel = user
+ f.Path[0] = u
+ return f
+}
+
+func TestResolveMoveConflictsSkipsColliding(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ user, root, dstFile, fc := conflictTestFixture(t, client, "a.txt")
+
+ srcA := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx)
+ srcB := client.File.Create().SetName("b.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx)
+ targetA := conflictTestTarget(t, srcA, user)
+ targetB := conflictTestTarget(t, srcB, user)
+ targets := []*File{targetA, targetB}
+
+ f := &DBFS{fileClient: fc}
+ nav := Navigator(&myNavigator{})
+ navOf := map[*File]Navigator{targetA: nav, targetB: nav}
+ ae := serializer.NewAggregateError()
+
+ surviving, group := f.resolveMoveConflicts(ctx, targets, navOf, dstFile, true, MoveConflictSkip, ae)
+
+ require.Equal(t, []*File{targetB}, surviving)
+ require.Equal(t, []*File{targetB}, group[nav])
+ require.NotNil(t, ae.Aggregate())
+}
+
+func TestResolveMoveConflictsKeepsNonColliding(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ user, root, dstFile, fc := conflictTestFixture(t, client, "other.txt")
+
+ srcA := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx)
+ targetA := conflictTestTarget(t, srcA, user)
+
+ f := &DBFS{fileClient: fc}
+ ae := serializer.NewAggregateError()
+
+ surviving, group := f.resolveMoveConflicts(ctx, []*File{targetA}, map[*File]Navigator{targetA: &myNavigator{}}, dstFile, true, MoveConflictSkip, ae)
+
+ require.Equal(t, []*File{targetA}, surviving)
+ require.Len(t, group, 1)
+ require.Nil(t, ae.Aggregate())
+}
+
+func TestResolveMoveConflictsUsesRestoreDisplayName(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ // Destination holds a file under the restored display name, not the
+ // trash-internal UUID name.
+ user, root, dstFile, fc := conflictTestFixture(t, client, "original.txt")
+
+ trashed := client.File.Create().SetName("uuid-name").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx)
+ trashed.Edges.Metadata = []*ent.Metadata{
+ {Name: MetadataRestoreUri, Value: fmt.Sprintf("%s/original.txt", fs.NewMyUri(""))},
+ }
+ target := conflictTestTarget(t, trashed, user)
+
+ f := &DBFS{fileClient: fc}
+ ae := serializer.NewAggregateError()
+
+ surviving, _ := f.resolveMoveConflicts(ctx, []*File{target}, map[*File]Navigator{}, dstFile, false, MoveConflictSkip, ae)
+
+ require.Empty(t, surviving)
+ require.NotNil(t, ae.Aggregate())
+}
diff --git a/pkg/filemanager/fs/dbfs/my_navigator.go b/pkg/filemanager/fs/dbfs/my_navigator.go
index b607ba21..db271a9f 100644
--- a/pkg/filemanager/fs/dbfs/my_navigator.go
+++ b/pkg/filemanager/fs/dbfs/my_navigator.go
@@ -134,12 +134,7 @@ func (n *myNavigator) walkNext(ctx context.Context, root *File, next string, isL
}
func (n *myNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
- res := &fs.NavigatorProps{
- Capability: myNavigatorCapability,
- OrderDirectionOptions: fullOrderDirectionOption,
- OrderByOptions: fullOrderByOption,
- MaxPageSize: n.config.MaxPageSize,
- }
+ res := baseNavigatorProps(myNavigatorCapability, n.config.MaxPageSize)
if isSearching {
res.OrderByOptions = nil
res.OrderDirectionOptions = nil
@@ -153,27 +148,24 @@ func (n *myNavigator) Walk(ctx context.Context, levelFiles []*File, limit, depth
}
func (n *myNavigator) FollowTx(ctx context.Context) (func(), error) {
- if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
- return nil, fmt.Errorf("navigator: no inherited transaction found in context")
- }
- newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient)
+ oldBase := n.baseNavigator.fileClient
+ revertFile, err := followTxClients(ctx, &n.fileClient)
if err != nil {
return nil, err
}
- newUserClient, _, _, err := inventory.WithTx(ctx, n.userClient)
-
- oldFileClient, oldUserClient := n.fileClient, n.userClient
- revert := func() {
- n.fileClient = oldFileClient
- n.userClient = oldUserClient
- n.baseNavigator.fileClient = oldFileClient
+ revertUser, err := followTxClients(ctx, &n.userClient)
+ if err != nil {
+ revertFile()
+ return nil, err
}
- n.fileClient = newFileClient
- n.userClient = newUserClient
- n.baseNavigator.fileClient = newFileClient
- return revert, nil
+ n.baseNavigator.fileClient = n.fileClient
+ return func() {
+ revertUser()
+ revertFile()
+ n.baseNavigator.fileClient = oldBase
+ }, nil
}
func (n *myNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
diff --git a/pkg/filemanager/fs/dbfs/navigator.go b/pkg/filemanager/fs/dbfs/navigator.go
index c2ac0be1..9e1ebc49 100644
--- a/pkg/filemanager/fs/dbfs/navigator.go
+++ b/pkg/filemanager/fs/dbfs/navigator.go
@@ -19,6 +19,31 @@ import (
"github.com/samber/lo"
)
+// followTxClients swaps each client field to its tx-bound instance from the
+// transaction in ctx, returning a revert closure that restores the originals.
+func followTxClients[T inventory.TxOperator](ctx context.Context, fields ...*T) (func(), error) {
+ if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
+ return nil, fmt.Errorf("navigator: no inherited transaction found in context")
+ }
+
+ olds := make([]T, len(fields))
+ for i, f := range fields {
+ newClient, _, _, err := inventory.WithTx(ctx, *f)
+ if err != nil {
+ return nil, err
+ }
+
+ olds[i] = *f
+ *f = newClient
+ }
+
+ return func() {
+ for i, f := range fields {
+ *f = olds[i]
+ }
+ }, nil
+}
+
var (
ErrFsNotInitialized = fmt.Errorf("fs not initialized")
ErrPermissionDenied = serializer.NewError(serializer.CodeNoPermissionErr, "Permission denied", nil)
@@ -33,6 +58,17 @@ var (
fullOrderDirectionOption = []string{"asc", "desc"}
)
+// baseNavigatorProps builds the shared NavigatorProps skeleton; navigators
+// adjust search options on top.
+func baseNavigatorProps(capability *boolset.BooleanSet, maxPageSize int) *fs.NavigatorProps {
+ return &fs.NavigatorProps{
+ Capability: capability,
+ OrderDirectionOptions: fullOrderDirectionOption,
+ OrderByOptions: fullOrderByOption,
+ MaxPageSize: maxPageSize,
+ }
+}
+
type (
// Navigator is a navigator for database file system.
Navigator interface {
diff --git a/pkg/filemanager/fs/dbfs/precondition_test.go b/pkg/filemanager/fs/dbfs/precondition_test.go
new file mode 100644
index 00000000..115c97b4
--- /dev/null
+++ b/pkg/filemanager/fs/dbfs/precondition_test.go
@@ -0,0 +1,28 @@
+package dbfs
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSourceIDMismatch(t *testing.T) {
+ ctx := context.Background()
+
+ // No expectations -> never mismatches.
+ require.False(t, sourceIDMismatch(ctx, 0, 42))
+ require.False(t, sourceIDMismatch(ctx, 5, 42))
+
+ ctx = WithExpectedSourceIDs(ctx, []int{11, 0, 33})
+
+ // Matching positions pass.
+ require.False(t, sourceIDMismatch(ctx, 0, 11))
+ // Zero entries disable the check for that position.
+ require.False(t, sourceIDMismatch(ctx, 1, 999))
+ // Positions beyond the expectation list pass.
+ require.False(t, sourceIDMismatch(ctx, 3, 999))
+ // Mismatches are caught per position.
+ require.True(t, sourceIDMismatch(ctx, 0, 12))
+ require.True(t, sourceIDMismatch(ctx, 2, 34))
+}
diff --git a/pkg/filemanager/fs/dbfs/share_navigator.go b/pkg/filemanager/fs/dbfs/share_navigator.go
index 0eb36de6..38c0fb27 100644
--- a/pkg/filemanager/fs/dbfs/share_navigator.go
+++ b/pkg/filemanager/fs/dbfs/share_navigator.go
@@ -278,12 +278,7 @@ func (n *shareNavigator) latestSharedSingleFile(ctx context.Context) (*File, err
}
func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
- res := &fs.NavigatorProps{
- Capability: shareNavigatorCapability,
- OrderDirectionOptions: fullOrderDirectionOption,
- OrderByOptions: fullOrderByOption,
- MaxPageSize: n.config.MaxPageSize,
- }
+ res := baseNavigatorProps(shareNavigatorCapability, n.config.MaxPageSize)
// Once the share is resolved, narrow capabilities to what its props grant.
// This set is stamped onto resolved files and consulted by writePermitted.
@@ -351,27 +346,24 @@ func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet {
}
func (n *shareNavigator) FollowTx(ctx context.Context) (func(), error) {
- if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
- return nil, fmt.Errorf("navigator: no inherited transaction found in context")
- }
- newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient)
+ oldBase := n.baseNavigator.fileClient
+ revertFile, err := followTxClients(ctx, &n.fileClient)
if err != nil {
return nil, err
}
- newSharClient, _, _, err := inventory.WithTx(ctx, n.shareClient)
-
- oldFileClient, oldShareClient := n.fileClient, n.shareClient
- revert := func() {
- n.fileClient = oldFileClient
- n.shareClient = oldShareClient
- n.baseNavigator.fileClient = oldFileClient
+ revertShare, err := followTxClients(ctx, &n.shareClient)
+ if err != nil {
+ revertFile()
+ return nil, err
}
- n.fileClient = newFileClient
- n.shareClient = newSharClient
- n.baseNavigator.fileClient = newFileClient
- return revert, nil
+ n.baseNavigator.fileClient = n.fileClient
+ return func() {
+ revertShare()
+ revertFile()
+ n.baseNavigator.fileClient = oldBase
+ }, nil
}
func (n *shareNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
diff --git a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go
index 6c42a619..2e2bd64e 100644
--- a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go
+++ b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go
@@ -101,13 +101,7 @@ func (t *sharedWithMeNavigator) Children(ctx context.Context, parent *File, args
}
func (t *sharedWithMeNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
- res := &fs.NavigatorProps{
- Capability: sharedWithMeNavigatorCapability,
- OrderDirectionOptions: fullOrderDirectionOption,
- OrderByOptions: fullOrderByOption,
- MaxPageSize: t.config.MaxPageSize,
- }
-
+ res := baseNavigatorProps(sharedWithMeNavigatorCapability, t.config.MaxPageSize)
if isSearching {
res.OrderByOptions = searchLimitedOrderByOption
}
@@ -120,23 +114,17 @@ func (t *sharedWithMeNavigator) Walk(ctx context.Context, levelFiles []*File, li
}
func (n *sharedWithMeNavigator) FollowTx(ctx context.Context) (func(), error) {
- if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
- return nil, fmt.Errorf("navigator: no inherited transaction found in context")
- }
- newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient)
+ oldBase := n.baseNavigator.fileClient
+ revert, err := followTxClients(ctx, &n.fileClient)
if err != nil {
return nil, err
}
- oldFileClient := n.fileClient
- revert := func() {
- n.fileClient = oldFileClient
- n.baseNavigator.fileClient = oldFileClient
- }
-
- n.fileClient = newFileClient
- n.baseNavigator.fileClient = newFileClient
- return revert, nil
+ n.baseNavigator.fileClient = n.fileClient
+ return func() {
+ revert()
+ n.baseNavigator.fileClient = oldBase
+ }, nil
}
func (n *sharedWithMeNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
diff --git a/pkg/filemanager/fs/dbfs/trash_navigator.go b/pkg/filemanager/fs/dbfs/trash_navigator.go
index ede265ed..acfbcd48 100644
--- a/pkg/filemanager/fs/dbfs/trash_navigator.go
+++ b/pkg/filemanager/fs/dbfs/trash_navigator.go
@@ -116,13 +116,7 @@ func (t *trashNavigator) Children(ctx context.Context, parent *File, args *ListA
}
func (t *trashNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
- res := &fs.NavigatorProps{
- Capability: trashNavigatorCapability,
- OrderDirectionOptions: fullOrderDirectionOption,
- OrderByOptions: fullOrderByOption,
- MaxPageSize: t.config.MaxPageSize,
- }
-
+ res := baseNavigatorProps(trashNavigatorCapability, t.config.MaxPageSize)
if isSearching {
res.OrderByOptions = searchLimitedOrderByOption
}
@@ -135,23 +129,17 @@ func (t *trashNavigator) Walk(ctx context.Context, levelFiles []*File, limit, de
}
func (n *trashNavigator) FollowTx(ctx context.Context) (func(), error) {
- if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
- return nil, fmt.Errorf("navigator: no inherited transaction found in context")
- }
- newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient)
+ oldBase := n.baseNavigator.fileClient
+ revert, err := followTxClients(ctx, &n.fileClient)
if err != nil {
return nil, err
}
- oldFileClient := n.fileClient
- revert := func() {
- n.fileClient = oldFileClient
- n.baseNavigator.fileClient = oldFileClient
- }
-
- n.fileClient = newFileClient
- n.baseNavigator.fileClient = newFileClient
- return revert, nil
+ n.baseNavigator.fileClient = n.fileClient
+ return func() {
+ revert()
+ n.baseNavigator.fileClient = oldBase
+ }, nil
}
func (n *trashNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
diff --git a/pkg/filemanager/fs/dbfs/upload.go b/pkg/filemanager/fs/dbfs/upload.go
index b871ddd3..f6d1839b 100644
--- a/pkg/filemanager/fs/dbfs/upload.go
+++ b/pkg/filemanager/fs/dbfs/upload.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
@@ -138,6 +139,10 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
return nil, err
}
+ if policy.Status == storagepolicy.StatusSuspended {
+ return nil, serializer.NewError(serializer.CodePolicyNotAllowed, "Storage policy is suspended", nil)
+ }
+
// Encryption setting
var (
encryptMetadata *types.EncryptMetadata
@@ -173,7 +178,16 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
if req.Props.SavePath == "" || isThumbnailAndPolicyNotAvailable {
req.Props.SavePath = generateSavePath(policy, req, f.user)
if isThumbnailAndPolicyNotAvailable {
- req.Props.SavePath = path.Clean(util.ReplaceMagicVar(f.settingClient.ThumbEntitySuffix(ctx), fs.Separator, true, true, time.Now(), f.user.ID, req.Props.Uri.Name(), req.Props.Uri.Path(), req.Props.SavePath))
+ req.Props.SavePath = path.Clean(fs.ReplaceMagicVar(f.settingClient.ThumbEntitySuffix(ctx), fs.MagicVarProps{
+ FsSeparator: fs.Separator,
+ PathAvailable: true,
+ BlobAvailable: true,
+ Time: time.Now(),
+ UserID: f.user.ID,
+ OriginName: req.Props.Uri.Name(),
+ OriginPath: req.Props.Uri.Path(),
+ CompleteBlobPath: req.Props.SavePath,
+ }))
}
}
diff --git a/pkg/filemanager/fs/dbfs/validator.go b/pkg/filemanager/fs/dbfs/validator.go
index 34fde54d..f38b57bf 100644
--- a/pkg/filemanager/fs/dbfs/validator.go
+++ b/pkg/filemanager/fs/dbfs/validator.go
@@ -13,13 +13,20 @@ import (
const MaxFileNameLength = 256
-// validateFileName validates the file name.
-func validateFileName(name string) error {
+// validateFileName validates the file name. When the policy allows native
+// names, characters only forbidden on Windows filesystems are accepted;
+// path separators and dot-names stay illegal regardless (#3065).
+func validateFileName(name string, policy *ent.StoragePolicy) error {
if len(name) >= MaxFileNameLength || len(name) == 0 {
return fmt.Errorf("length of name must be between 1 and 255")
}
- if strings.ContainsAny(name, "\\/:*?\"<>|") {
+ if strings.ContainsAny(name, "\\/") {
+ return fmt.Errorf("name contains path separators")
+ }
+
+ nativeAllowed := policy != nil && policy.Settings.AllowNativeName
+ if !nativeAllowed && strings.ContainsAny(name, ":*?\"<>|") {
return fmt.Errorf("name contains illegal characters")
}
@@ -74,7 +81,7 @@ func validateFileSize(size int64, policy *ent.StoragePolicy) error {
// validateNewFile validates the upload request.
func validateNewFile(fileName string, size int64, policy *ent.StoragePolicy) error {
- if err := validateFileName(fileName); err != nil {
+ if err := validateFileName(fileName, policy); err != nil {
return fs.ErrIllegalObjectName.WithError(err)
}
diff --git a/pkg/filemanager/fs/dbfs/validator_test.go b/pkg/filemanager/fs/dbfs/validator_test.go
new file mode 100644
index 00000000..3b381335
--- /dev/null
+++ b/pkg/filemanager/fs/dbfs/validator_test.go
@@ -0,0 +1,27 @@
+package dbfs
+
+import (
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateFileNameNativeChars(t *testing.T) {
+ native := &ent.StoragePolicy{Settings: &types.PolicySetting{AllowNativeName: true}}
+
+ // Windows-illegal characters accepted only under allow_native_name.
+ for _, name := range []string{"a:b.txt", "a.txt", "a|b?.txt", `say "hi".txt`} {
+ require.Error(t, validateFileName(name, nil))
+ require.Error(t, validateFileName(name, &ent.StoragePolicy{Settings: &types.PolicySetting{}}))
+ require.NoError(t, validateFileName(name, native))
+ }
+
+ // Separators and dot-names stay illegal regardless.
+ for _, name := range []string{"a/b", `a\b`, ".", "..", "", string(make([]byte, 300))} {
+ require.Error(t, validateFileName(name, native))
+ }
+
+ require.NoError(t, validateFileName("normal file.txt", nil))
+}
diff --git a/pkg/filemanager/fs/fs.go b/pkg/filemanager/fs/fs.go
index c27a340e..0753ec72 100644
--- a/pkg/filemanager/fs/fs.go
+++ b/pkg/filemanager/fs/fs.go
@@ -327,6 +327,7 @@ type (
SysSkipSoftDelete bool
Metadata map[string]string
ArchiveCompression bool
+ ArchiveWorkers int
ProgressFunc
MaxArchiveSize int64
DryRun CreateArchiveDryRunFunc
@@ -586,6 +587,15 @@ func WithArchiveCompression(b bool) Option {
})
}
+// WithArchiveWorkers sets the number of parallel compression workers for
+// archive creation. <=0 selects an adaptive default, 1 keeps the
+// sequential single-writer path.
+func WithArchiveWorkers(n int) Option {
+ return OptionFunc(func(o *FsOption) {
+ o.ArchiveWorkers = n
+ })
+}
+
// WithMaxArchiveSize sets maximum size of to be archived file or to-be decompressed
// size, 0 for unlimited.
func WithMaxArchiveSize(s int64) Option {
diff --git a/pkg/filemanager/fs/magicvar.go b/pkg/filemanager/fs/magicvar.go
new file mode 100644
index 00000000..d3d1126a
--- /dev/null
+++ b/pkg/filemanager/fs/magicvar.go
@@ -0,0 +1,101 @@
+package fs
+
+import (
+ "math/rand"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gofrs/uuid"
+)
+
+// MagicVarProps carries the context available to magic-variable expansion in
+// path templates.
+type MagicVarProps struct {
+ FsSeparator string
+ PathAvailable bool
+ BlobAvailable bool
+ Time time.Time
+ UserID int
+ OriginName string
+ OriginPath string
+ CompleteBlobPath string
+}
+
+var magicVarRe = regexp.MustCompile(`\{[^{}]+\}`)
+
+// ReplaceMagicVar expands {var} placeholders in rawString using p.
+func ReplaceMagicVar(rawString string, p MagicVarProps) string {
+ return magicVarRe.ReplaceAllStringFunc(rawString, func(match string) string {
+ switch match {
+ case "{randomkey16}":
+ return util.RandStringRunes(16)
+ case "{randomkey8}":
+ return util.RandStringRunes(8)
+ case "{timestamp}":
+ return strconv.FormatInt(p.Time.Unix(), 10)
+ case "{timestamp_nano}":
+ return strconv.FormatInt(p.Time.UnixNano(), 10)
+ case "{randomnum2}":
+ return strconv.Itoa(rand.Intn(2))
+ case "{randomnum3}":
+ return strconv.Itoa(rand.Intn(3))
+ case "{randomnum4}":
+ return strconv.Itoa(rand.Intn(4))
+ case "{randomnum8}":
+ return strconv.Itoa(rand.Intn(8))
+ case "{uid}":
+ return strconv.Itoa(p.UserID)
+ case "{datetime}":
+ return p.Time.Format("20060102150405")
+ case "{date}":
+ return p.Time.Format("20060102")
+ case "{year}":
+ return p.Time.Format("2006")
+ case "{month}":
+ return p.Time.Format("01")
+ case "{day}":
+ return p.Time.Format("02")
+ case "{hour}":
+ return p.Time.Format("15")
+ case "{minute}":
+ return p.Time.Format("04")
+ case "{second}":
+ return p.Time.Format("05")
+ case "{uuid}":
+ return uuid.Must(uuid.NewV4()).String()
+ case "{ext}":
+ return filepath.Ext(p.OriginName)
+ case "{originname}":
+ return p.OriginName
+ case "{originname_without_ext}":
+ return strings.TrimSuffix(p.OriginName, filepath.Ext(p.OriginName))
+ case "{path}":
+ if p.PathAvailable {
+ return p.OriginPath + p.FsSeparator
+ }
+ return match
+ case "{blob_name}":
+ if p.BlobAvailable {
+ return filepath.Base(p.CompleteBlobPath)
+ }
+ return match
+ case "{blob_name_without_ext}":
+ if p.BlobAvailable {
+ return strings.TrimSuffix(filepath.Base(p.CompleteBlobPath), filepath.Ext(p.CompleteBlobPath))
+ }
+ return match
+ case "{blob_path}":
+ if p.BlobAvailable {
+ return path.Dir(p.CompleteBlobPath) + p.FsSeparator
+ }
+ return match
+ default:
+ return match
+ }
+ })
+}
diff --git a/pkg/filemanager/fs/magicvar_test.go b/pkg/filemanager/fs/magicvar_test.go
new file mode 100644
index 00000000..06d681c8
--- /dev/null
+++ b/pkg/filemanager/fs/magicvar_test.go
@@ -0,0 +1,48 @@
+package fs
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestReplaceMagicVar(t *testing.T) {
+ a := assert.New(t)
+ now := time.Date(2024, 3, 5, 6, 7, 8, 0, time.UTC)
+ props := MagicVarProps{
+ FsSeparator: "/",
+ PathAvailable: true,
+ BlobAvailable: true,
+ Time: now,
+ UserID: 42,
+ OriginName: "photo.jpg",
+ OriginPath: "album/2024",
+ CompleteBlobPath: "blobs/abc123.bin",
+ }
+
+ a.Equal("u42", ReplaceMagicVar("u{uid}", props))
+ a.Equal("20240305", ReplaceMagicVar("{date}", props))
+ a.Equal("20240305060708", ReplaceMagicVar("{datetime}", props))
+ a.Equal("2024/03/05", ReplaceMagicVar("{year}/{month}/{day}", props))
+ a.Equal("photo.jpg", ReplaceMagicVar("{originname}", props))
+ a.Equal("photo", ReplaceMagicVar("{originname_without_ext}", props))
+ a.Equal(".jpg", ReplaceMagicVar("{ext}", props))
+ a.Equal("album/2024/", ReplaceMagicVar("{path}", props))
+ a.Equal("abc123.bin", ReplaceMagicVar("{blob_name}", props))
+ a.Equal("abc123", ReplaceMagicVar("{blob_name_without_ext}", props))
+ a.Equal("blobs/", ReplaceMagicVar("{blob_path}", props))
+ a.Equal("{unknown}", ReplaceMagicVar("{unknown}", props))
+
+ // Unavailable context leaves the placeholder untouched.
+ noPath := props
+ noPath.PathAvailable = false
+ a.Equal("{path}", ReplaceMagicVar("{path}", noPath))
+ noBlob := props
+ noBlob.BlobAvailable = false
+ a.Equal("{blob_name}", ReplaceMagicVar("{blob_name}", noBlob))
+
+ // Random vars produce non-empty output.
+ a.Len(ReplaceMagicVar("{randomkey8}", props), 8)
+ a.Len(ReplaceMagicVar("{uuid}", props), 36)
+}
diff --git a/pkg/filemanager/fs/uri.go b/pkg/filemanager/fs/uri.go
index 3f5bb147..84868a23 100644
--- a/pkg/filemanager/fs/uri.go
+++ b/pkg/filemanager/fs/uri.go
@@ -356,9 +356,9 @@ func SearchCategoryFromString(s string) setting.SearchCategory {
func NewShareUri(id, password string) string {
if password != "" {
- return fmt.Sprintf("%s://%s:%s@%s", constants.CloudreveScheme, id, password, constants.FileSystemShare)
+ return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, url.UserPassword(id, password), constants.FileSystemShare)
}
- return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, id, constants.FileSystemShare)
+ return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, url.User(id), constants.FileSystemShare)
}
func NewMyUri(id string) string {
diff --git a/pkg/filemanager/fs/uri_test.go b/pkg/filemanager/fs/uri_test.go
new file mode 100644
index 00000000..fc95af86
--- /dev/null
+++ b/pkg/filemanager/fs/uri_test.go
@@ -0,0 +1,21 @@
+package fs
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestShareUriPasswordRoundTrip(t *testing.T) {
+ for _, password := range []string{"plain", "with space", "a:b@c/d?e", "😀😴😭", "$%^&*"} {
+ u, err := NewUriFromString(NewShareUri("abc123", password))
+ require.NoError(t, err, "password %q", password)
+ require.Equal(t, "abc123", u.ID(""))
+ require.Equal(t, password, u.Password(), "password %q", password)
+ }
+
+ u, err := NewUriFromString(NewShareUri("abc123", ""))
+ require.NoError(t, err)
+ require.Equal(t, "abc123", u.ID(""))
+ require.Equal(t, "", u.Password())
+}
diff --git a/pkg/filemanager/manager/archive.go b/pkg/filemanager/manager/archive.go
index daded9cf..6e1473a1 100644
--- a/pkg/filemanager/manager/archive.go
+++ b/pkg/filemanager/manager/archive.go
@@ -2,13 +2,18 @@ package manager
import (
"archive/zip"
+ "compress/flate"
"context"
"encoding/gob"
"fmt"
+ "hash/crc32"
"io"
+ "os"
"path"
"path/filepath"
+ "runtime"
"strings"
+ "sync"
"time"
"github.com/bodgit/sevenzip"
@@ -24,7 +29,7 @@ import (
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
- "golang.org/x/tools/container/intsets"
+ "math"
)
type (
@@ -172,48 +177,48 @@ func (m *manager) CreateArchive(ctx context.Context, uris []*fs.URI, writer io.W
zipWriter := zip.NewWriter(writer)
defer zipWriter.Close()
- var compressed int64
+ // Enumerate all archive entries up front; compression itself then runs
+ // either sequentially or fanned out to workers (#3316).
+ jobs := make([]createArchiveJob, 0, len(files))
for _, file := range files {
if file.Type() == types.FileTypeFile {
- if err := m.compressFileToArchive(ctx, "/", file, zipWriter, o.ArchiveCompression, o.DryRun); err != nil {
- failed++
- m.l.Warning("Failed to compress file %s: %s, skipping it...", file.Uri(false), err)
- }
-
- compressed += file.Size()
- if o.ProgressFunc != nil {
- o.ProgressFunc(compressed, file.Size(), 0)
- }
+ jobs = append(jobs, createArchiveJob{parent: "/", file: file})
+ continue
+ }
- if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize {
- return 0, fs.ErrArchiveSrcSizeTooBig
+ if err := m.Walk(ctx, file.Uri(false), math.MaxInt, func(f fs.File, level int) error {
+ if f.Type() == types.FileTypeFolder || f.IsSymbolic() {
+ return nil
}
+ jobs = append(jobs, createArchiveJob{
+ parent: strings.TrimPrefix(f.Uri(false).Dir(), file.Uri(false).Dir()),
+ file: f,
+ })
+ return nil
+ }); err != nil {
+ m.l.Warning("Failed to walk folder %s: %s, skipping it...", file.Uri(false), err)
+ failed++
+ }
+ }
- } else {
- if err := m.Walk(ctx, file.Uri(false), intsets.MaxInt, func(f fs.File, level int) error {
- if f.Type() == types.FileTypeFolder || f.IsSymbolic() {
- return nil
- }
- if err := m.compressFileToArchive(ctx, strings.TrimPrefix(f.Uri(false).Dir(),
- file.Uri(false).Dir()), f, zipWriter, o.ArchiveCompression, o.DryRun); err != nil {
- failed++
- m.l.Warning("Failed to compress file %s: %s, skipping it...", f.Uri(false), err)
- }
+ if o.DryRun == nil && o.ArchiveCompression && o.ArchiveWorkers != 1 && len(jobs) > 1 {
+ return m.createArchiveParallel(ctx, jobs, zipWriter, o)
+ }
- compressed += f.Size()
- if o.ProgressFunc != nil {
- o.ProgressFunc(compressed, f.Size(), 0)
- }
+ var compressed int64
+ for _, job := range jobs {
+ if err := m.compressFileToArchive(ctx, job.parent, job.file, zipWriter, o.ArchiveCompression, o.DryRun); err != nil {
+ failed++
+ m.l.Warning("Failed to compress file %s: %s, skipping it...", job.file.Uri(false), err)
+ }
- if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize {
- return fs.ErrArchiveSrcSizeTooBig
- }
+ compressed += job.file.Size()
+ if o.ProgressFunc != nil {
+ o.ProgressFunc(compressed, job.file.Size(), 0)
+ }
- return nil
- }); err != nil {
- m.l.Warning("Failed to walk folder %s: %s, skipping it...", file.Uri(false), err)
- failed++
- }
+ if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize {
+ return 0, fs.ErrArchiveSrcSizeTooBig
}
}
@@ -257,6 +262,163 @@ func (m *manager) compressFileToArchive(ctx context.Context, parent string, file
}
+// maxArchiveWorkers caps the adaptive default so disk spooling stays sane.
+const maxArchiveWorkers = 8
+
+type (
+ // createArchiveJob is a single file scheduled into an archive.
+ createArchiveJob struct {
+ parent string
+ file fs.File
+ }
+ // archiveEntryResult carries a pre-compressed entry ready for a raw
+ // zip write, along with the temp file holding its deflate stream.
+ archiveEntryResult struct {
+ header *zip.FileHeader
+ tmpPath string
+ file fs.File
+ err error
+ }
+)
+
+// countingWriter tracks bytes written for zip header sizes.
+type countingWriter struct {
+ w io.Writer
+ n int64
+}
+
+func (c *countingWriter) Write(p []byte) (int, error) {
+ n, err := c.w.Write(p)
+ c.n += int64(n)
+ return n, err
+}
+
+// createArchiveParallel deflates archive entries on a worker pool while a
+// single writer drains results in completion order via CreateRaw (#3316).
+// Compressed data is spooled to temp files so zip's sequential layout is
+// preserved without holding entries in memory.
+func (m *manager) createArchiveParallel(ctx context.Context, jobs []createArchiveJob, zipWriter *zip.Writer, o *fs.FsOption) (int, error) {
+ workers := o.ArchiveWorkers
+ if workers <= 0 {
+ workers = min(runtime.GOMAXPROCS(0), maxArchiveWorkers)
+ }
+
+ jobCh := make(chan createArchiveJob)
+ resCh := make(chan archiveEntryResult, workers)
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for job := range jobCh {
+ resCh <- m.spoolEntry(ctx, job)
+ }
+ }()
+ }
+ go func() {
+ for _, job := range jobs {
+ jobCh <- job
+ }
+ close(jobCh)
+ wg.Wait()
+ close(resCh)
+ }()
+
+ failed := 0
+ var compressed int64
+ for res := range resCh {
+ if res.err != nil {
+ failed++
+ m.l.Warning("Failed to compress file %s: %s, skipping it...", res.file.Uri(false), res.err)
+ } else {
+ if err := writeRawEntry(zipWriter, res); err != nil {
+ failed++
+ m.l.Warning("Failed to write archive entry %s: %s, skipping it...", res.file.Uri(false), err)
+ }
+ }
+
+ compressed += res.file.Size()
+ if o.ProgressFunc != nil {
+ o.ProgressFunc(compressed, res.file.Size(), 0)
+ }
+ if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize {
+ return 0, fs.ErrArchiveSrcSizeTooBig
+ }
+ }
+
+ return failed, nil
+}
+
+// spoolEntry deflates one file into a temp file and returns a header with
+// precomputed CRC32 and sizes for zip.Writer.CreateRaw.
+func (m *manager) spoolEntry(ctx context.Context, job createArchiveJob) archiveEntryResult {
+ res := archiveEntryResult{file: job.file}
+ es, err := m.GetEntitySource(ctx, job.file.PrimaryEntityID())
+ if err != nil {
+ res.err = fmt.Errorf("failed to get entity source: %w", err)
+ return res
+ }
+ defer es.Close()
+
+ tmp, err := os.CreateTemp("", "cloudreve-zip-*")
+ if err != nil {
+ res.err = err
+ return res
+ }
+
+ cw := &countingWriter{w: tmp}
+ crc := crc32.NewIEEE()
+ fw, err := flate.NewWriter(cw, flate.DefaultCompression)
+ if err != nil {
+ tmp.Close()
+ os.Remove(tmp.Name())
+ res.err = err
+ return res
+ }
+ es.Apply(entitysource.WithContext(ctx))
+ _, copyErr := io.Copy(fw, io.TeeReader(es, crc))
+ closeErr := fw.Close()
+ tmp.Close()
+ if copyErr != nil {
+ os.Remove(tmp.Name())
+ res.err = copyErr
+ return res
+ }
+ if closeErr != nil {
+ os.Remove(tmp.Name())
+ res.err = closeErr
+ return res
+ }
+
+ res.header = &zip.FileHeader{
+ Name: filepath.FromSlash(path.Join(job.parent, job.file.DisplayName())),
+ Method: zip.Deflate,
+ Modified: job.file.UpdatedAt(),
+ CRC32: crc.Sum32(),
+ CompressedSize64: uint64(cw.n),
+ UncompressedSize64: uint64(job.file.Size()),
+ }
+ res.tmpPath = tmp.Name()
+ return res
+}
+
+// writeRawEntry writes a pre-compressed entry's local header and streams
+// its spooled deflate data, then removes the temp file.
+func writeRawEntry(zipWriter *zip.Writer, res archiveEntryResult) error {
+ defer os.Remove(res.tmpPath)
+ raw, err := zipWriter.CreateRaw(res.header)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.Open(res.tmpPath)
+ if err != nil {
+ return err
+ }
+ defer tmp.Close()
+ _, err = io.Copy(raw, tmp)
+ return err
+}
+
func getZipFileList(ctx context.Context, file io.ReaderAt, size int64, textEncoding encoding.Encoding) ([]ArchivedFile, error) {
zr, err := zip.NewReader(file, size)
if err != nil {
@@ -283,7 +445,7 @@ func getZipFileList(ctx context.Context, file io.ReaderAt, size int64, textEncod
info := f.FileInfo()
modTime := info.ModTime()
fileList = append(fileList, ArchivedFile{
- Name: util.FormSlash(hdr.Name),
+ Name: util.FormSlash(strings.ToValidUTF8(hdr.Name, "�")),
Size: info.Size(),
UpdatedAt: &modTime,
IsDirectory: info.IsDir(),
@@ -303,7 +465,7 @@ func get7zFileList(ctx context.Context, file io.ReaderAt, size int64, extEncodin
info := f.FileInfo()
modTime := info.ModTime()
fileList = append(fileList, ArchivedFile{
- Name: util.FormSlash(f.Name),
+ Name: util.FormSlash(strings.ToValidUTF8(f.Name, "�")),
Size: info.Size(),
UpdatedAt: &modTime,
IsDirectory: info.IsDir(),
diff --git a/pkg/filemanager/manager/archive_parallel_test.go b/pkg/filemanager/manager/archive_parallel_test.go
new file mode 100644
index 00000000..e0d6bfbc
--- /dev/null
+++ b/pkg/filemanager/manager/archive_parallel_test.go
@@ -0,0 +1,65 @@
+package manager
+
+import (
+ "archive/zip"
+ "bytes"
+ "compress/flate"
+ "hash/crc32"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestWriteRawEntryRoundTrip verifies that a deflate stream spooled to a
+// temp file with precomputed CRC32/sizes produces a valid zip entry via
+// CreateRaw — the invariant the parallel path relies on (#3316).
+func TestWriteRawEntryRoundTrip(t *testing.T) {
+ payload := []byte("hello cloudreve parallel zip payload, repeat repeat repeat")
+
+ var compBuf bytes.Buffer
+ cw := &countingWriter{w: &compBuf}
+ crc := crc32.NewIEEE()
+ fw, err := flate.NewWriter(cw, flate.DefaultCompression)
+ require.NoError(t, err)
+ _, err = io.Copy(fw, io.TeeReader(bytes.NewReader(payload), crc))
+ require.NoError(t, err)
+ require.NoError(t, fw.Close())
+
+ tmp := filepath.Join(t.TempDir(), "entry.deflate")
+ require.NoError(t, os.WriteFile(tmp, compBuf.Bytes(), 0o600))
+
+ var zipBuf bytes.Buffer
+ zw := zip.NewWriter(&zipBuf)
+ err = writeRawEntry(zw, archiveEntryResult{
+ header: &zip.FileHeader{
+ Name: "dir/a.txt",
+ Method: zip.Deflate,
+ CRC32: crc.Sum32(),
+ CompressedSize64: uint64(cw.n),
+ UncompressedSize64: uint64(len(payload)),
+ },
+ tmpPath: tmp,
+ })
+ require.NoError(t, err)
+ require.NoError(t, zw.Close())
+
+ // Temp file is consumed and removed.
+ _, statErr := os.Stat(tmp)
+ require.True(t, os.IsNotExist(statErr))
+
+ zr, err := zip.NewReader(bytes.NewReader(zipBuf.Bytes()), int64(zipBuf.Len()))
+ require.NoError(t, err)
+ require.Len(t, zr.File, 1)
+ require.Equal(t, "dir/a.txt", zr.File[0].Name)
+ require.Equal(t, uint64(len(payload)), zr.File[0].UncompressedSize64)
+
+ rc, err := zr.File[0].Open()
+ require.NoError(t, err)
+ got, err := io.ReadAll(rc)
+ require.NoError(t, err)
+ require.NoError(t, rc.Close())
+ require.Equal(t, payload, got)
+}
diff --git a/pkg/filemanager/manager/manager.go b/pkg/filemanager/manager/manager.go
index 2f022f7a..39a39085 100644
--- a/pkg/filemanager/manager/manager.go
+++ b/pkg/filemanager/manager/manager.go
@@ -127,6 +127,7 @@ type (
AllowEdit bool
PreviewOnly bool
UploadOnly bool
+ Note string
}
FullTextSearchResults struct {
@@ -164,9 +165,21 @@ func NewFileManager(dep dependency.Dep, u *ent.User) FileManager {
l: dep.Logger(),
user: u,
settings: dep.SettingProvider(),
- fs: dbfs.NewDatabaseFS(u, dep.FileClient(), dep.ShareClient(), dep.Logger(), dep.LockSystem(),
- dep.SettingProvider(), dep.StoragePolicyClient(), dep.HashIDEncoder(), dep.UserClient(), dep.KV(), dep.NavigatorStateKV(),
- dep.DirectLinkClient(), dep.EncryptorFactory(context.TODO()), dep.EventHub()),
+ fs: dbfs.NewDatabaseFS(u, dbfs.DBFSDependencies{
+ FileClient: dep.FileClient(),
+ ShareClient: dep.ShareClient(),
+ UserClient: dep.UserClient(),
+ StoragePolicyClient: dep.StoragePolicyClient(),
+ DirectLinkClient: dep.DirectLinkClient(),
+ Logger: dep.Logger(),
+ LockSystem: dep.LockSystem(),
+ SettingProvider: dep.SettingProvider(),
+ Hasher: dep.HashIDEncoder(),
+ Cache: dep.KV(),
+ StateKV: dep.NavigatorStateKV(),
+ EncryptorFactory: dep.EncryptorFactory(context.TODO()),
+ EventHub: dep.EventHub(),
+ }),
kv: dep.KV(),
config: config,
auth: dep.GeneralAuth(),
diff --git a/pkg/filemanager/manager/operation.go b/pkg/filemanager/manager/operation.go
index ca87f280..7e2ce99f 100644
--- a/pkg/filemanager/manager/operation.go
+++ b/pkg/filemanager/manager/operation.go
@@ -140,7 +140,15 @@ func (m *manager) Create(ctx context.Context, path *fs.URI, fileType types.FileT
func (m *manager) Rename(ctx context.Context, path *fs.URI, newName string) (fs.File, error) {
file, indexDiff, err := m.fs.Rename(ctx, path, newName)
m.processIndexDiff(ctx, indexDiff)
- return file, err
+ if err != nil {
+ return file, err
+ }
+
+ // Keep WebDAV mounts bound under the renamed path pointing at its new URI.
+ // Scope to the file's owner — the URI authority carries the owner's hash,
+ // which may differ from the requester for admin-on-behalf operations.
+ m.rewriteDavMounts(ctx, file.OwnerID(), path, file.Uri(false))
+ return file, nil
}
func (m *manager) MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, isCopy bool) error {
@@ -150,9 +158,28 @@ func (m *manager) MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, is
}
m.processIndexDiff(ctx, indexDiff)
+
+ // Keep WebDAV mounts bound under moved paths pointing at their new URIs.
+ if !isCopy && m.user != nil {
+ for _, srcURI := range src {
+ m.rewriteDavMounts(ctx, m.user.ID, srcURI, dst.JoinRaw(srcURI.Name()))
+ }
+ }
return nil
}
+// rewriteDavMounts repoints the given user's dav account mount URIs that
+// reference oldURI (or any path under it) to newURI. Failures are logged but
+// never fail the file operation itself.
+func (m *manager) rewriteDavMounts(ctx context.Context, ownerID int, oldURI, newURI *fs.URI) {
+ if ownerID == 0 || oldURI == nil || newURI == nil {
+ return
+ }
+ if err := m.dep.DavAccountClient().UpdateURIPrefix(ctx, ownerID, oldURI.String(), newURI.String()); err != nil {
+ m.l.Warning("failed to update dav account URIs after file operation: %s", err)
+ }
+}
+
func (m *manager) SoftDelete(ctx context.Context, path ...*fs.URI) error {
return m.fs.SoftDelete(ctx, path...)
}
@@ -328,6 +355,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
AllowEdit: args.AllowEdit,
PreviewOnly: args.PreviewOnly,
UploadOnly: args.UploadOnly,
+ Note: args.Note,
}
share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{
diff --git a/pkg/filemanager/manager/recycle.go b/pkg/filemanager/manager/recycle.go
index e01abfbd..7929c05f 100644
--- a/pkg/filemanager/manager/recycle.go
+++ b/pkg/filemanager/manager/recycle.go
@@ -40,6 +40,10 @@ type (
}
)
+// RecycleFailThreshold is the number of consecutive sweeps an entity may fail
+// driver deletion before its DB row is force-removed (blob orphaned).
+const RecycleFailThreshold = 5
+
func init() {
queue.RegisterResumableTaskFactory(queue.ExplicitEntityRecycleTaskType, NewExplicitEntityRecycleTaskFromModel)
queue.RegisterResumableTaskFactory(queue.EntityRecycleRoutineTaskType, NewEntityRecycleRoutineTaskFromModel)
@@ -209,12 +213,19 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ...
m.l.Info("Start to recycle batch #%d, %d entities", batch, len(chunk))
mapSrcToId := make(map[string]int, len(chunk))
_, d, err := m.getEntityPolicyDriver(ctx, chunk[0], nil)
- if err != nil {
+ if err != nil && !force {
for _, entity := range chunk {
ae.Add(strconv.Itoa(entity.ID()), err)
}
continue
}
+ if err != nil {
+ // Force delete: the policy/driver is broken (e.g. deleted or
+ // misconfigured), so physical blobs are unreachable anyway —
+ // drop the DB rows instead of deadlocking the policy.
+ m.l.Warning("Skipping driver init failure under force recycle: %s", err)
+ d = nil
+ }
for _, entity := range chunk {
mapSrcToId[entity.Source()] = entity.ID()
@@ -226,14 +237,19 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ...
}), func(entity fs.Entity, index int) string {
return entity.Source()
})
- if len(toBeDeletedSrc) > 0 {
- res, err := d.Delete(ctx, toBeDeletedSrc...)
- if err != nil {
- for _, src := range res {
- ae.Add(strconv.Itoa(mapSrcToId[src]), err)
+ var failedSrcs []string
+ if len(toBeDeletedSrc) > 0 && d != nil {
+ var deleteErr error
+ failedSrcs, deleteErr = d.Delete(ctx, toBeDeletedSrc...)
+ if deleteErr != nil {
+ for _, src := range failedSrcs {
+ ae.Add(strconv.Itoa(mapSrcToId[src]), deleteErr)
}
}
}
+ // A failure covering every source signals an infrastructure outage
+ // (driver/bucket down) — per-entity badness only counts on partial failure.
+ partialFailure := len(failedSrcs) < len(toBeDeletedSrc)
// Delete upload session if it's still valid
for _, entity := range chunk {
@@ -244,8 +260,10 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ...
if session, ok := m.kv.Get(UploadSessionCachePrefix + sid.String()); ok {
session := session.(fs.UploadSession)
- if err := d.CancelToken(ctx, &session); err != nil {
- m.l.Warning("Failed to cancel upload session for %q: %s, this is expected if it's remote policy.", session.Props.Uri.String(), err)
+ if d != nil {
+ if err := d.CancelToken(ctx, &session); err != nil {
+ m.l.Warning("Failed to cancel upload session for %q: %s, this is expected if it's remote policy.", session.Props.Uri.String(), err)
+ }
}
_ = m.kv.Delete(UploadSessionCachePrefix, sid.String())
}
@@ -253,25 +271,53 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ...
// Filtering out entities that are successfully deleted
rawAe := ae.Raw()
- successEntities := lo.FilterMap(chunk, func(entity fs.Entity, index int) (int, bool) {
+ successEntities := make([]int, 0, len(chunk))
+ var dirtyProps []*ent.Entity
+ for _, entity := range chunk {
entityIdStr := fmt.Sprintf("%d", entity.ID())
- _, ok := rawAe[entityIdStr]
- if !ok {
- // No error, deleted
- return entity.ID(), true
+ if _, ok := rawAe[entityIdStr]; !ok {
+ successEntities = append(successEntities, entity.ID())
+ continue
}
if force {
ae.Remove(entityIdStr)
+ successEntities = append(successEntities, entity.ID())
+ continue
}
- return entity.ID(), force
- })
+
+ // Count consecutive per-entity delete failures; past the
+ // threshold the row is force-removed so one un-deletable blob
+ // (e.g. an illegal over-long path) cannot stall every sweep.
+ if partialFailure {
+ model := entity.Model()
+ if model.Props == nil {
+ model.Props = &types.EntityProps{}
+ }
+ model.Props.RecycleFailCount++
+ if model.Props.RecycleFailCount >= RecycleFailThreshold {
+ m.l.Warning(
+ "Entity %d failed blob deletion %d times; removing DB row and orphaning blob %q",
+ entity.ID(), model.Props.RecycleFailCount, entity.Source())
+ ae.Remove(entityIdStr)
+ successEntities = append(successEntities, entity.ID())
+ continue
+ }
+ dirtyProps = append(dirtyProps, model)
+ }
+ }
// Remove entities from DB
fc, tx, ctx, err := inventory.WithTx(ctx, m.dep.FileClient())
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
+ if len(dirtyProps) > 0 {
+ if err := fc.UpdateEntityProps(ctx, dirtyProps...); err != nil {
+ _ = inventory.Rollback(tx)
+ return fmt.Errorf("failed to persist recycle failure counts: %w", err)
+ }
+ }
storageReduced, err := fc.RemoveEntitiesByID(ctx, successEntities...)
if err != nil {
_ = inventory.Rollback(tx)
diff --git a/pkg/filemanager/manager/recycle_force_test.go b/pkg/filemanager/manager/recycle_force_test.go
new file mode 100644
index 00000000..bf8f44db
--- /dev/null
+++ b/pkg/filemanager/manager/recycle_force_test.go
@@ -0,0 +1,83 @@
+package manager
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cache"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRecycleEntitiesForceBypassesBrokenPolicy covers upstream #2909: when a
+// storage policy is deleted or too broken to initialize a driver, its blob
+// rows used to be undeletable even with force=true, which also deadlocked
+// removal of the policy itself.
+func TestRecycleEntitiesForceBypassesBrokenPolicy(t *testing.T) {
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+
+ logger := logging.NewConsoleLogger(logging.LevelError)
+ cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger)
+ require.NoError(t, err)
+ hasher, err := hashid.New("test-salt")
+ require.NoError(t, err)
+
+ dep := dependency.NewDependency(
+ dependency.WithDbClient(client),
+ dependency.WithConfigProvider(cfg),
+ dependency.WithKV(cache.NewMemoStore("", logger)),
+ dependency.WithLogger(logger),
+ dependency.WithHashIDEncoder(hasher),
+ )
+ ctx := context.WithValue(context.Background(), dependency.DepCtx{}, dep)
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ user := client.User.Create().SetEmail("u@example.com").SetNick("u").SetGroup(group).SaveX(ctx)
+
+ // Unknown policy type - GetStorageDriver rejects it with
+ // ErrUnknownPolicyType, simulating a misconfigured policy.
+ brokenPolicy := client.StoragePolicy.Create().
+ SetName("broken").
+ SetType("bogus").
+ SetAccessKey("x").
+ SetSecretKey("x").
+ SetMaxSize(1).
+ SetDirNameRule("x").
+ SetFileNameRule("x").
+ SaveX(ctx)
+
+ newStaleEntity := func() int {
+ return client.Entity.Create().
+ SetType(0).
+ SetSource("uploads/blob.bin").
+ SetSize(123).
+ SetStoragePolicyEntities(brokenPolicy.ID).
+ SetReferenceCount(0).
+ SetCreatedBy(user.ID).
+ SaveX(ctx).ID
+ }
+
+ fm := NewFileManager(dep, user)
+
+ t.Run("without force the row is retained and error returned", func(t *testing.T) {
+ id := newStaleEntity()
+ err := fm.RecycleEntities(ctx, false, id)
+ require.Error(t, err)
+ require.NotNil(t, client.Entity.GetX(ctx, id))
+ })
+
+ t.Run("with force the row is dropped despite driver failure", func(t *testing.T) {
+ id := newStaleEntity()
+ err := fm.RecycleEntities(ctx, true, id)
+ require.NoError(t, err)
+ _, getErr := client.Entity.Get(ctx, id)
+ require.Error(t, getErr)
+ })
+}
diff --git a/pkg/filemanager/manager/thumbnail.go b/pkg/filemanager/manager/thumbnail.go
index 1b979382..bb30d0bb 100644
--- a/pkg/filemanager/manager/thumbnail.go
+++ b/pkg/filemanager/manager/thumbnail.go
@@ -184,9 +184,18 @@ func (m *manager) generateThumb(ctx context.Context, uri *fs.URI, ext string, es
entityType := types.EntityTypeThumbnail
req := &fs.UploadRequest{
Props: &fs.UploadProps{
- Uri: uri,
- Size: fileInfo.Size(),
- SavePath: path.Clean(util.ReplaceMagicVar(m.settings.ThumbEntitySuffix(ctx), fs.Separator, true, true, time.Now(), m.user.ID, uri.Name(), uri.Path(), es.Entity().Source())),
+ Uri: uri,
+ Size: fileInfo.Size(),
+ SavePath: path.Clean(fs.ReplaceMagicVar(m.settings.ThumbEntitySuffix(ctx), fs.MagicVarProps{
+ FsSeparator: fs.Separator,
+ PathAvailable: true,
+ BlobAvailable: true,
+ Time: time.Now(),
+ UserID: m.user.ID,
+ OriginName: uri.Name(),
+ OriginPath: uri.Path(),
+ CompleteBlobPath: es.Entity().Source(),
+ })),
MimeType: m.dep.MimeDetector(ctx).TypeByName("thumb.jpg"),
EntityType: &entityType,
},
diff --git a/pkg/filemanager/workflows/archive.go b/pkg/filemanager/workflows/archive.go
index 53fe21b1..2bef70b5 100644
--- a/pkg/filemanager/workflows/archive.go
+++ b/pkg/filemanager/workflows/archive.go
@@ -125,6 +125,11 @@ func (m *CreateArchiveTask) Do(ctx context.Context) (task.Status, error) {
}
m.state = state
+ // Prefer the node hosting the first source file's storage policy.
+ if len(m.state.Uris) > 0 {
+ preferPolicyNode(ctx, dep, &m.state.NodeState, m.state.Uris[0])
+ }
+
// select node
node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityCreateArchive)
if err != nil {
diff --git a/pkg/filemanager/workflows/blob_audit.go b/pkg/filemanager/workflows/blob_audit.go
new file mode 100644
index 00000000..e1a54541
--- /dev/null
+++ b/pkg/filemanager/workflows/blob_audit.go
@@ -0,0 +1,301 @@
+package workflows
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path"
+ "strings"
+ "sync/atomic"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
+ "github.com/cloudreve/Cloudreve/v4/ent/task"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/queue"
+)
+
+type (
+ BlobAuditTask struct {
+ *queue.DBTask
+ state *BlobAuditTaskState
+ l logging.Logger
+ progress queue.Progresses
+ }
+ BlobAuditTaskPhase string
+ BlobAuditTaskState struct {
+ Phase BlobAuditTaskPhase `json:"phase"`
+ PolicyID int `json:"policy_id"`
+ Delete bool `json:"delete"`
+ Scanned int `json:"scanned"`
+ Orphans []string `json:"orphans"`
+ OrphanCount int `json:"orphan_count"`
+ Deleted int `json:"deleted"`
+ Failed int `json:"failed"`
+ Truncated bool `json:"truncated"`
+ }
+)
+
+const (
+ BlobAuditPhaseScan BlobAuditTaskPhase = "scan"
+ BlobAuditPhaseDelete BlobAuditTaskPhase = "delete"
+
+ BlobAuditDeleteBatch = 200
+ BlobAuditMaxOrphanList = 5000
+
+ ProgressTypeBlobAudit = "blob_audit"
+ SummaryKeyOrphanCount = "orphan_count"
+ SummaryKeyDeleted = "deleted"
+ SummaryKeyScannedObject = "scanned"
+)
+
+func init() {
+ queue.RegisterResumableTaskFactory(queue.BlobAuditTaskType, NewBlobAuditTaskFromModel)
+}
+
+// NewBlobAuditTask creates a task that lists all physical objects under a
+// storage policy and diffs them against the entities table, reporting (and
+// optionally deleting) blobs no entity references (upstream #3395).
+func NewBlobAuditTask(ctx context.Context, u *ent.User, policyID int, delete bool) (queue.Task, error) {
+ state := &BlobAuditTaskState{
+ Phase: BlobAuditPhaseScan,
+ PolicyID: policyID,
+ Delete: delete,
+ }
+ stateBytes, err := json.Marshal(state)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal state: %w", err)
+ }
+
+ return &BlobAuditTask{
+ DBTask: &queue.DBTask{
+ Task: &ent.Task{
+ Type: queue.BlobAuditTaskType,
+ CorrelationID: logging.CorrelationID(ctx),
+ PrivateState: string(stateBytes),
+ PublicState: &types.TaskPublicState{},
+ },
+ DirectOwner: u,
+ },
+ }, nil
+}
+
+func NewBlobAuditTaskFromModel(t *ent.Task) queue.Task {
+ return &BlobAuditTask{
+ DBTask: &queue.DBTask{
+ Task: t,
+ },
+ }
+}
+
+func (m *BlobAuditTask) Do(ctx context.Context) (task.Status, error) {
+ dep := dependency.FromContext(ctx)
+ m.l = dep.Logger()
+
+ m.Lock()
+ if m.progress == nil {
+ m.progress = make(queue.Progresses)
+ }
+ m.progress[ProgressTypeBlobAudit] = &queue.Progress{}
+ m.Unlock()
+
+ state := &BlobAuditTaskState{}
+ if err := json.Unmarshal([]byte(m.State()), state); err != nil {
+ return task.StatusError, fmt.Errorf("failed to unmarshal state: %s (%w)", err, queue.CriticalErr)
+ }
+ m.state = state
+
+ var (
+ next = task.StatusCompleted
+ err error
+ )
+ switch m.state.Phase {
+ case BlobAuditPhaseScan, "":
+ next, err = m.scan(ctx, dep)
+ case BlobAuditPhaseDelete:
+ next, err = m.deleteOrphans(ctx, dep)
+ default:
+ next, err = task.StatusError, fmt.Errorf("unknown phase %q: %w", m.state.Phase, queue.CriticalErr)
+ }
+
+ newStateStr, marshalErr := json.Marshal(m.state)
+ if marshalErr != nil {
+ return task.StatusError, fmt.Errorf("failed to marshal state: %w", marshalErr)
+ }
+
+ m.Lock()
+ m.Task.PrivateState = string(newStateStr)
+ m.Unlock()
+ return next, err
+}
+
+// scan lists all physical objects under the policy's blob prefix and diffs
+// them against the entity sources recorded for that policy.
+func (m *BlobAuditTask) scan(ctx context.Context, dep dependency.Dep) (task.Status, error) {
+ policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, m.state.PolicyID)
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to get storage policy %d: %w", m.state.PolicyID, err)
+ }
+ if policy.Status == storagepolicy.StatusSuspended {
+ return task.StatusError, fmt.Errorf("storage policy %d is suspended: %w", m.state.PolicyID, queue.CriticalErr)
+ }
+
+ fm := manager.NewFileManager(dep, nil)
+ defer fm.Recycle()
+ d, err := fm.GetStorageDriver(ctx, policy)
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to get storage driver: %w", err)
+ }
+
+ // Literal prefix of the dir name rule (e.g. "uploads/{uid}/{path}" ->
+ // "uploads") scopes the listing so non-blob areas (temp, avatars) are
+ // never touched.
+ base := literalDirPrefix(policy.DirNameRule)
+ m.l.Info("Blob audit on policy %d: listing objects under %q", policy.ID, base)
+
+ var objects []fs.PhysicalObject
+ objects, err = d.List(ctx, base, func(i int) {
+ atomic.AddInt64(&m.progress[ProgressTypeBlobAudit].Current, int64(i))
+ }, true)
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to list objects on policy %d: %w", m.state.PolicyID, err)
+ }
+ m.state.Scanned = len(objects)
+
+ // Full entity source set for this policy, paginated.
+ known := make(map[string]struct{})
+ page := 0
+ for {
+ res, err := dep.FileClient().ListEntities(ctx, &inventory.ListEntityParameters{
+ PaginationArgs: &inventory.PaginationArgs{Page: page, PageSize: 1000},
+ StoragePolicyID: policy.ID,
+ })
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to list entities: %w", err)
+ }
+ for _, e := range res.Entities {
+ known[e.Source] = struct{}{}
+ }
+ if len(res.Entities) < 1000 {
+ break
+ }
+ page++
+ }
+
+ orphanSet := make(map[string]struct{})
+ for _, obj := range objects {
+ if obj.IsDir {
+ continue
+ }
+ key := obj.RelativePath
+ if base != "" {
+ key = path.Join(base, obj.RelativePath)
+ }
+ if _, ok := known[key]; ok {
+ continue
+ }
+ if _, dup := orphanSet[key]; dup {
+ continue
+ }
+ orphanSet[key] = struct{}{}
+ if len(m.state.Orphans) < BlobAuditMaxOrphanList {
+ m.state.Orphans = append(m.state.Orphans, key)
+ } else {
+ m.state.Truncated = true
+ }
+ }
+
+ m.l.Info("Blob audit on policy %d: %d objects scanned, %d orphans found.", policy.ID, m.state.Scanned, len(orphanSet))
+ m.state.OrphanCount = len(orphanSet)
+
+ if m.state.Delete && len(m.state.Orphans) > 0 {
+ m.state.Phase = BlobAuditPhaseDelete
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
+ return task.StatusCompleted, nil
+}
+
+// deleteOrphans removes the recorded orphan blobs in batches.
+func (m *BlobAuditTask) deleteOrphans(ctx context.Context, dep dependency.Dep) (task.Status, error) {
+ if len(m.state.Orphans) == 0 {
+ return task.StatusCompleted, nil
+ }
+
+ policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, m.state.PolicyID)
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to get storage policy %d: %w", m.state.PolicyID, err)
+ }
+
+ fm := manager.NewFileManager(dep, nil)
+ defer fm.Recycle()
+ d, err := fm.GetStorageDriver(ctx, policy)
+ if err != nil {
+ return task.StatusError, fmt.Errorf("failed to get storage driver: %w", err)
+ }
+
+ batch := m.state.Orphans
+ if len(batch) > BlobAuditDeleteBatch {
+ batch = batch[:BlobAuditDeleteBatch]
+ }
+
+ failed, err := d.Delete(ctx, batch...)
+ if err != nil {
+ m.l.Warning("Blob audit delete partially failed on policy %d: %s", policy.ID, err)
+ }
+ m.state.Deleted += len(batch) - len(failed)
+ m.state.Failed += len(failed)
+ // Failed paths are dropped from the list — a re-run of the audit flags
+ // them again if they are still orphaned.
+ m.state.Orphans = m.state.Orphans[len(batch):]
+
+ atomic.StoreInt64(&m.progress[ProgressTypeBlobAudit].Current, int64(m.state.Deleted))
+
+ if len(m.state.Orphans) > 0 {
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
+ return task.StatusCompleted, nil
+}
+
+// literalDirPrefix returns the static path prefix before the first magic
+// variable, e.g. "uploads/{uid}/{path}" -> "uploads". An empty result means
+// the rule starts with a variable and the policy root cannot be narrowed.
+func literalDirPrefix(rule string) string {
+ if i := strings.Index(rule, "{"); i >= 0 {
+ rule = rule[:i]
+ }
+ return strings.Trim(rule, "/")
+}
+
+func (m *BlobAuditTask) Progress(ctx context.Context) queue.Progresses {
+ m.Lock()
+ defer m.Unlock()
+ return m.progress
+}
+
+func (m *BlobAuditTask) Summarize(hasher hashid.Encoder) *queue.Summary {
+ if m.state == nil {
+ if err := json.Unmarshal([]byte(m.State()), &m.state); err != nil {
+ return nil
+ }
+ }
+
+ return &queue.Summary{
+ Phase: string(m.state.Phase),
+ Props: map[string]any{
+ SummaryKeyOrphanCount: m.state.OrphanCount,
+ SummaryKeyScannedObject: m.state.Scanned,
+ SummaryKeyDeleted: m.state.Deleted,
+ SummaryKeyFailed: m.state.Failed,
+ "truncated": m.state.Truncated,
+ "policy_id": m.state.PolicyID,
+ },
+ }
+}
diff --git a/pkg/filemanager/workflows/blob_audit_test.go b/pkg/filemanager/workflows/blob_audit_test.go
new file mode 100644
index 00000000..7e29164c
--- /dev/null
+++ b/pkg/filemanager/workflows/blob_audit_test.go
@@ -0,0 +1,23 @@
+package workflows
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLiteralDirPrefix(t *testing.T) {
+ cases := []struct {
+ rule string
+ want string
+ }{
+ {"uploads/{uid}/{path}", "uploads"},
+ {"uploads/{uid}", "uploads"},
+ {"{uid}/{path}", ""},
+ {"blob/store/{uid}", "blob/store"},
+ {"", ""},
+ }
+ for _, c := range cases {
+ require.Equal(t, c.want, literalDirPrefix(c.rule), "rule %q", c.rule)
+ }
+}
diff --git a/pkg/filemanager/workflows/extract.go b/pkg/filemanager/workflows/extract.go
index 9fd2dce4..b145a0aa 100644
--- a/pkg/filemanager/workflows/extract.go
+++ b/pkg/filemanager/workflows/extract.go
@@ -126,6 +126,9 @@ func (m *ExtractArchiveTask) Do(ctx context.Context) (task.Status, error) {
}
m.state = state
+ // Prefer the node hosting the source file's storage policy.
+ preferPolicyNode(ctx, dep, &m.state.NodeState, m.state.Uri)
+
// select node
node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityExtractArchive)
if err != nil {
@@ -375,7 +378,7 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen
return nil
}
- rawPath := util.FormSlash(f.NameInArchive)
+ rawPath := util.FormSlash(strings.ToValidUTF8(f.NameInArchive, "�"))
savePath := dst.JoinRaw(rawPath)
// If file mask is not empty, check if the path is in the mask
@@ -751,7 +754,7 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) {
return nil
}
- rawPath := util.FormSlash(f.NameInArchive)
+ rawPath := util.FormSlash(strings.ToValidUTF8(f.NameInArchive, "�"))
savePath := dst.JoinRaw(rawPath)
// If file mask is not empty, check if the path is in the mask
diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go
index 4b78afc8..c02ae566 100644
--- a/pkg/filemanager/workflows/remote_download.go
+++ b/pkg/filemanager/workflows/remote_download.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
+ "maps"
+ "net/url"
"os"
"path"
"path/filepath"
@@ -42,9 +44,16 @@ type (
}
RemoteDownloadTaskPhase string
RemoteDownloadTaskState struct {
- SrcFileUri string `json:"src_file_uri,omitempty"`
- SrcUri string `json:"src_uri,omitempty"`
- Dst string `json:"dst,omitempty"`
+ SrcFileUri string `json:"src_file_uri,omitempty"`
+ SrcUri string `json:"src_uri,omitempty"`
+ Dst string `json:"dst,omitempty"`
+ FileName string `json:"file_name,omitempty"`
+ // Provider pins the task to a node offering this downloader provider
+ // (e.g. "aria2"/"qbittorrent"). Empty = pool picks any capable node.
+ Provider string `json:"provider,omitempty"`
+ HTTPUsername string `json:"http_username,omitempty"`
+ HTTPPassword string `json:"http_password,omitempty"`
+ HTTPHeaders []string `json:"http_headers,omitempty"`
Handle *downloader.TaskHandle `json:"handle,omitempty"`
Status *downloader.TaskStatus `json:"status,omitempty"`
NodeState `json:",inline"`
@@ -54,6 +63,10 @@ type (
GetTaskStatusTried int `json:"get_task_status_tried,omitempty"`
Transferred map[int]interface{} `json:"transferred,omitempty"`
Failed int `json:"failed,omitempty"`
+ // ResumeMonitorAfterTransfer marks an early transfer batch taken while
+ // the download is still running: after it completes, return to Monitor
+ // instead of awaiting seeding.
+ ResumeMonitorAfterTransfer bool `json:"resume_monitor_after_transfer,omitempty"`
}
)
@@ -81,14 +94,32 @@ func init() {
queue.RegisterResumableTaskFactory(queue.RemoteDownloadTaskType, NewRemoteDownloadTaskFromModel)
}
+// RemoteDownloadTaskOption carries optional per-task parameters supplied by
+// the user: a custom output file name and HTTP basic-auth credentials for
+// plain HTTP(S) sources.
+type RemoteDownloadTaskOption struct {
+ FileName string
+ Provider string
+ HTTPUsername string
+ HTTPPassword string
+ HTTPHeaders []string
+}
+
// NewRemoteDownloadTask creates a new RemoteDownloadTask
-func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string) (queue.Task, error) {
+func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string, opts *RemoteDownloadTaskOption) (queue.Task, error) {
state := &RemoteDownloadTaskState{
SrcUri: src,
SrcFileUri: srcFile,
Dst: dst,
NodeState: NodeState{},
}
+ if opts != nil {
+ state.FileName = sanitizeFileName(opts.FileName)
+ state.Provider = opts.Provider
+ state.HTTPUsername = opts.HTTPUsername
+ state.HTTPPassword = opts.HTTPPassword
+ state.HTTPHeaders = opts.HTTPHeaders
+ }
stateBytes, err := json.Marshal(state)
if err != nil {
return nil, fmt.Errorf("failed to marshal state: %w", err)
@@ -127,6 +158,12 @@ func (m *RemoteDownloadTask) Do(ctx context.Context) (task.Status, error) {
}
m.state = state
+ // Resolve a user-picked downloader provider to a preferred node. Runs only
+ // until a node is locked in; falls back to any capable node if no match.
+ if m.state.NodeID == 0 && m.state.Provider != "" {
+ m.state.NodeID = providerNodeID(ctx, dep, m.state.Provider)
+ }
+
// select node
node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityRemoteDownload)
if err != nil {
@@ -214,8 +251,10 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende
torrentUrl = torrentUrls[0].Url
}
+ options, taskUrl := m.buildDownloadOptions(ctx, user.Edges.Group.Settings.RemoteDownloadOptions, torrentUrl)
+
// Create download task
- handle, err := m.d.CreateTask(ctx, torrentUrl, user.Edges.Group.Settings.RemoteDownloadOptions)
+ handle, err := m.d.CreateTask(ctx, taskUrl, options)
if err != nil {
return task.StatusError, fmt.Errorf("failed to create download task: %w", err)
}
@@ -225,6 +264,58 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende
return task.StatusSuspending, nil
}
+// buildDownloadOptions overlays per-task options (custom file name, HTTP
+// credentials) onto the group's remote-download options. Custom name and
+// credentials only apply to plain HTTP(S) source URLs on aria2; qBittorrent
+// accepts a torrent rename and carries HTTP auth in the URL userinfo.
+func (m *RemoteDownloadTask) buildDownloadOptions(ctx context.Context, base map[string]interface{}, srcUrl string) (map[string]interface{}, string) {
+ if m.state.FileName == "" && m.state.HTTPUsername == "" && len(m.state.HTTPHeaders) == 0 {
+ return base, srcUrl
+ }
+
+ options := maps.Clone(base)
+ if options == nil {
+ options = map[string]interface{}{}
+ }
+
+ isHttpSrc := m.state.SrcFileUri == "" && (strings.HasPrefix(m.state.SrcUri, "http://") || strings.HasPrefix(m.state.SrcUri, "https://"))
+ switch m.node.Settings(ctx).Provider {
+ case types.DownloaderProviderQBittorrent:
+ if m.state.FileName != "" {
+ options["rename"] = m.state.FileName
+ }
+ if isHttpSrc {
+ if m.state.HTTPUsername != "" {
+ if u, err := url.Parse(srcUrl); err == nil {
+ u.User = url.UserPassword(m.state.HTTPUsername, m.state.HTTPPassword)
+ srcUrl = u.String()
+ }
+ }
+ // qBittorrent's add API only accepts a cookie field, not arbitrary
+ // headers — pass through any Cookie: line the user supplied.
+ for _, h := range m.state.HTTPHeaders {
+ if k, v, ok := strings.Cut(h, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "cookie") {
+ options["cookie"] = strings.TrimSpace(v)
+ }
+ }
+ }
+ default:
+ if isHttpSrc {
+ if m.state.FileName != "" {
+ options["out"] = m.state.FileName
+ }
+ if m.state.HTTPUsername != "" {
+ options["http-user"] = m.state.HTTPUsername
+ options["http-passwd"] = m.state.HTTPPassword
+ }
+ if len(m.state.HTTPHeaders) > 0 {
+ options["header"] = m.state.HTTPHeaders
+ }
+ }
+ }
+ return options, srcUrl
+}
+
// buildSSRFOptions composes the SSRF policy for a download: the assigned
// node's URLValidation settings, plus the operator-configured site URL hosts
// (always allowlisted so users can fetch files served by Cloudreve itself).
@@ -313,6 +404,13 @@ func (m *RemoteDownloadTask) monitor(ctx context.Context, dep dependency.Dep) (t
m.l.Info("Download task seeding completed")
return task.StatusCompleted, nil
case downloader.StatusDownloading:
+ if m.hasEarlyTransferCandidates(status) {
+ m.l.Info("Some files already completed, starting early transfer.")
+ m.state.Phase = RemoteDownloadTaskPhaseTransfer
+ m.state.ResumeMonitorAfterTransfer = true
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
m.ResumeAfter(resumeAfter)
return task.StatusSuspending, nil
case downloader.StatusUnknown, downloader.StatusError:
@@ -323,6 +421,19 @@ func (m *RemoteDownloadTask) monitor(ctx context.Context, dep dependency.Dep) (t
return task.StatusSuspending, nil
}
+// hasEarlyTransferCandidates reports whether any selected file finished
+// downloading but has not been transferred to the user's storage yet.
+func (m *RemoteDownloadTask) hasEarlyTransferCandidates(status *downloader.TaskStatus) bool {
+ for _, f := range status.Files {
+ if f.Selected && f.Progress >= 1 {
+ if _, ok := m.state.Transferred[f.Index]; !ok {
+ return true
+ }
+ }
+ }
+ return false
+}
+
func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.Dep) (task.Status, error) {
u := inventory.UserFromContext(ctx)
if m.state.Transferred == nil {
@@ -353,6 +464,11 @@ func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.D
continue
}
+ // During early transfer batches only completed files are picked.
+ if m.state.ResumeMonitorAfterTransfer && f.Progress < 1 {
+ continue
+ }
+
dst := dstUri.JoinRaw(sanitizeFileName(f.Name))
src := path.Join(m.state.Status.SavePath, f.Name)
payload.Files = append(payload.Files, SlaveUploadEntity{
@@ -404,12 +520,30 @@ func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.D
}
m.l.Warning("Slave task %d failed to transfer %d files, retrying...", slaveTaskId, len(m.state.SlaveUploadState.Files)-len(m.state.SlaveUploadState.Transferred))
+ if m.state.ResumeMonitorAfterTransfer {
+ // Early transfer batch failed while the download continues -
+ // return to monitoring; the final transfer pass retries them.
+ m.state.ResumeMonitorAfterTransfer = false
+ m.state.Phase = RemoteDownloadTaskPhaseMonitor
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
return task.StatusError, fmt.Errorf(
"slave task failed to transfer %d files, first 5 errors: %s",
len(m.state.SlaveUploadState.Files)-len(m.state.SlaveUploadState.Transferred),
m.state.SlaveUploadState.First5TransferErrors,
)
} else {
+ if m.state.ResumeMonitorAfterTransfer {
+ for i := range m.state.SlaveUploadState.Transferred {
+ m.state.Transferred[m.state.SlaveUploadState.Files[i].Index] = struct{}{}
+ }
+ m.state.SlaveUploadTaskID = 0
+ m.state.ResumeMonitorAfterTransfer = false
+ m.state.Phase = RemoteDownloadTaskPhaseMonitor
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
m.state.Phase = RemoteDownloadTaskPhaseAwaitSeeding
m.ResumeAfter(0)
return task.StatusSuspending, nil
@@ -443,6 +577,11 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
allFiles := make([]downloader.TaskFile, 0, len(m.state.Status.Files))
for _, f := range m.state.Status.Files {
if f.Selected {
+ // Early transfer batches only pick completed files; files still
+ // downloading are left for the final transfer pass.
+ if m.state.ResumeMonitorAfterTransfer && f.Progress < 1 {
+ continue
+ }
allFiles = append(allFiles, f)
totalSize += f.Size
totalCount++
@@ -546,12 +685,27 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
wg.Wait()
if failed > 0 {
+ if m.state.ResumeMonitorAfterTransfer {
+ // Early batch failed while the download continues - return to
+ // monitoring; the final transfer pass retries the failed files.
+ m.l.Warning("Early transfer batch failed for %d file(s), will retry after download completes.", failed)
+ m.state.ResumeMonitorAfterTransfer = false
+ m.state.Phase = RemoteDownloadTaskPhaseMonitor
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
m.state.Failed = int(failed)
m.l.Error("Failed to transfer %d file(s).", failed)
return task.StatusError, fmt.Errorf("failed to transfer %d file(s), first 5 errors: %s", failed, ae.FormatFirstN(5))
}
m.l.Info("All files transferred.")
+ if m.state.ResumeMonitorAfterTransfer {
+ m.state.ResumeMonitorAfterTransfer = false
+ m.state.Phase = RemoteDownloadTaskPhaseMonitor
+ m.ResumeAfter(0)
+ return task.StatusSuspending, nil
+ }
m.state.Phase = RemoteDownloadTaskPhaseAwaitSeeding
return task.StatusSuspending, nil
}
@@ -679,6 +833,31 @@ func (m *RemoteDownloadTask) Progress(ctx context.Context) queue.Progresses {
}
func sanitizeFileName(name string) string {
- r := strings.NewReplacer("\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
+ r := strings.NewReplacer("\\", "_", "/", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
return r.Replace(name)
}
+
+// providerNodeID resolves a downloader provider name to the lowest-ID active
+// node offering it for remote download. Returns 0 when no node matches, which
+// makes the pool fall back to any capable node.
+func providerNodeID(ctx context.Context, dep dependency.Dep, provider string) int {
+ nodes, err := dep.NodeClient().ListActiveNodes(ctx, nil)
+ if err != nil {
+ return 0
+ }
+ return PickProviderNode(nodes, provider)
+}
+
+func PickProviderNode(nodes []*ent.Node, provider string) int {
+ best := 0
+ for _, n := range nodes {
+ if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) ||
+ n.Settings == nil || string(n.Settings.Provider) != provider {
+ continue
+ }
+ if best == 0 || n.ID < best {
+ best = n.ID
+ }
+ }
+ return best
+}
diff --git a/pkg/filemanager/workflows/remote_download_test.go b/pkg/filemanager/workflows/remote_download_test.go
new file mode 100644
index 00000000..d867ae46
--- /dev/null
+++ b/pkg/filemanager/workflows/remote_download_test.go
@@ -0,0 +1,181 @@
+package workflows
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cluster"
+ "github.com/cloudreve/Cloudreve/v4/pkg/downloader"
+ "github.com/stretchr/testify/assert"
+)
+
+type stubNode struct {
+ cluster.Node
+ settings *types.NodeSetting
+}
+
+func (s *stubNode) Settings(ctx context.Context) *types.NodeSetting {
+ return s.settings
+}
+
+func newRemoteDownloadTaskForOptions(state *RemoteDownloadTaskState, provider types.DownloaderProvider) *RemoteDownloadTask {
+ return &RemoteDownloadTask{
+ state: state,
+ node: &stubNode{settings: &types.NodeSetting{Provider: provider}},
+ }
+}
+
+func TestBuildDownloadOptionsAria2(t *testing.T) {
+ a := assert.New(t)
+ m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "https://example.com/file.zip",
+ FileName: "renamed.zip",
+ HTTPUsername: "user",
+ HTTPPassword: "pass",
+ }, types.DownloaderProviderAria2)
+
+ base := map[string]interface{}{"max-connection-per-server": "4"}
+ opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip")
+
+ a.Equal("renamed.zip", opts["out"])
+ a.Equal("user", opts["http-user"])
+ a.Equal("pass", opts["http-passwd"])
+ a.Equal("4", opts["max-connection-per-server"])
+ a.Equal("https://example.com/file.zip", taskUrl)
+ _, exists := base["out"]
+ a.False(exists, "base options must not be mutated")
+}
+
+func TestBuildDownloadOptionsQBittorrent(t *testing.T) {
+ a := assert.New(t)
+ m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "https://example.com/file.torrent",
+ FileName: "renamed",
+ HTTPUsername: "user",
+ HTTPPassword: "p@ss:word",
+ }, types.DownloaderProviderQBittorrent)
+
+ opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://example.com/file.torrent")
+
+ a.Equal("renamed", opts["rename"])
+ _, exists := opts["out"]
+ a.False(exists)
+ a.Equal("https://user:p%40ss%3Aword@example.com/file.torrent", taskUrl)
+}
+
+func TestBuildDownloadOptionsNonHttpSrc(t *testing.T) {
+ a := assert.New(t)
+
+ // Torrent source file: aria2 "out" and HTTP credentials must not apply.
+ m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcFileUri: "cloudreve://my/file.torrent",
+ FileName: "renamed",
+ HTTPUsername: "user",
+ HTTPPassword: "pass",
+ }, types.DownloaderProviderAria2)
+
+ opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://slave.example.com/entity/1")
+ _, hasOut := opts["out"]
+ a.False(hasOut)
+ _, hasUser := opts["http-user"]
+ a.False(hasUser)
+ a.Equal("https://slave.example.com/entity/1", taskUrl)
+
+ // Magnet link: same for aria2.
+ m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "magnet:?xt=urn:btih:abc",
+ FileName: "renamed",
+ HTTPUsername: "user",
+ }, types.DownloaderProviderAria2)
+ opts, _ = m.buildDownloadOptions(context.Background(), nil, "magnet:?xt=urn:btih:abc")
+ _, hasOut = opts["out"]
+ a.False(hasOut)
+ _, hasUser = opts["http-user"]
+ a.False(hasUser)
+}
+
+func TestBuildDownloadOptionsNoExtras(t *testing.T) {
+ a := assert.New(t)
+ m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "https://example.com/file.zip",
+ }, types.DownloaderProviderAria2)
+
+ base := map[string]interface{}{"k": "v"}
+ opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip")
+ a.Equal("v", opts["k"])
+ a.Equal("https://example.com/file.zip", taskUrl)
+}
+
+func TestBuildDownloadOptionsHeaders(t *testing.T) {
+ a := assert.New(t)
+
+ // aria2: arbitrary headers pass through as a list.
+ m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "https://example.com/f.zip",
+ HTTPHeaders: []string{"Cookie: sid=abc", "Referer: https://example.com/"},
+ }, types.DownloaderProviderAria2)
+ opts, _ := m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.zip")
+ a.Equal([]string{"Cookie: sid=abc", "Referer: https://example.com/"}, opts["header"])
+
+ // qBittorrent: only the Cookie line maps onto the cookie field.
+ m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{
+ SrcUri: "https://example.com/f.torrent",
+ HTTPHeaders: []string{"Referer: https://x/", "Cookie: sid=abc"},
+ }, types.DownloaderProviderQBittorrent)
+ opts, _ = m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.torrent")
+ a.Equal("sid=abc", opts["cookie"])
+ _, hasHeader := opts["header"]
+ a.False(hasHeader)
+}
+
+func TestHasEarlyTransferCandidates(t *testing.T) {
+ a := assert.New(t)
+ m := &RemoteDownloadTask{state: &RemoteDownloadTaskState{}}
+
+ status := &downloader.TaskStatus{Files: []downloader.TaskFile{
+ {Index: 0, Selected: true, Progress: 1},
+ {Index: 1, Selected: true, Progress: 0.5},
+ {Index: 2, Selected: false, Progress: 1},
+ }}
+ a.True(m.hasEarlyTransferCandidates(status))
+
+ m.state.Transferred = map[int]interface{}{0: struct{}{}}
+ a.False(m.hasEarlyTransferCandidates(status))
+
+ status.Files[1].Progress = 1
+ a.True(m.hasEarlyTransferCandidates(status))
+}
+
+func TestNewRemoteDownloadTaskSanitizesFileName(t *testing.T) {
+ a := assert.New(t)
+ tsk, err := NewRemoteDownloadTask(context.Background(), "https://example.com/f", "", "cloudreve://my/dst", &RemoteDownloadTaskOption{
+ FileName: "../../etc/passwd",
+ })
+ a.NoError(err)
+
+ state := &RemoteDownloadTaskState{}
+ a.NoError(json.Unmarshal([]byte(tsk.(*RemoteDownloadTask).Task.PrivateState), state))
+ a.Equal(".._.._etc_passwd", state.FileName)
+}
+
+func TestPickProviderNode(t *testing.T) {
+ caps := &boolset.BooleanSet{}
+ boolset.Set(types.NodeCapabilityRemoteDownload, true, caps)
+
+ nodes := []*ent.Node{
+ {ID: 3, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderQBittorrent}},
+ {ID: 1, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}},
+ {ID: 2, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}},
+ {ID: 4, Capabilities: nil, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}},
+ {ID: 5, Capabilities: caps, Settings: nil},
+ }
+
+ assert.Equal(t, 1, PickProviderNode(nodes, string(types.DownloaderProviderAria2)))
+ assert.Equal(t, 3, PickProviderNode(nodes, string(types.DownloaderProviderQBittorrent)))
+ assert.Equal(t, 0, PickProviderNode(nodes, "deluge"))
+ assert.Equal(t, 0, PickProviderNode(nil, string(types.DownloaderProviderAria2)))
+}
diff --git a/pkg/filemanager/workflows/worfklows.go b/pkg/filemanager/workflows/workflows.go
similarity index 62%
rename from pkg/filemanager/workflows/worfklows.go
rename to pkg/filemanager/workflows/workflows.go
index 475dd9be..ddd35fbc 100644
--- a/pkg/filemanager/workflows/worfklows.go
+++ b/pkg/filemanager/workflows/workflows.go
@@ -8,8 +8,12 @@ import (
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
)
@@ -60,3 +64,38 @@ func prepareTempFolder(ctx context.Context, dep dependency.Dep, t queue.Task) (s
dep.Logger().Info("Temp folder created: %s", tempPath)
return tempPath, nil
}
+
+// preferPolicyNode seeds the preferred node with the node hosting the given
+// file's storage policy, so data-heavy tasks run where the blob already
+// lives. No-op when the URI is invalid, has no entity, or the policy is not
+// bound to a node — the pool then falls back to weighted selection.
+func preferPolicyNode(ctx context.Context, dep dependency.Dep, state *NodeState, uriStr string) {
+ if state.NodeID > 0 {
+ return
+ }
+
+ uri, err := fs.NewUriFromString(uriStr)
+ if err != nil {
+ return
+ }
+
+ fm := manager.NewFileManager(dep, inventory.UserFromContext(ctx))
+ defer fm.Recycle()
+
+ file, err := fm.Get(ctx, uri, dbfs.WithFileEntities())
+ if err != nil || file == nil {
+ return
+ }
+
+ entity := file.PrimaryEntity()
+ if entity == nil {
+ return
+ }
+
+ policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, entity.PolicyID())
+ if err != nil || policy == nil {
+ return
+ }
+
+ state.NodeID = policy.NodeID
+}
diff --git a/pkg/hashid/hash.go b/pkg/hashid/hash.go
index 9f93c6e6..d3017b61 100644
--- a/pkg/hashid/hash.go
+++ b/pkg/hashid/hash.go
@@ -75,76 +75,69 @@ func (e *hashEncoder) Decode(raw string, t int) (int, error) {
return res[0], nil
}
+func encodeID(encoder Encoder, id, t int) string {
+ res, _ := encoder.Encode([]int{id, t})
+ return res
+}
+
// EncodeUserID encode user id to hash id
func EncodeUserID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, UserID})
- return res
+ return encodeID(encoder, uid, UserID)
}
// EncodeGroupID encode group id to hash id
func EncodeGroupID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, GroupID})
- return res
+ return encodeID(encoder, uid, GroupID)
}
// EncodePaymentID encode payment id to hash id
func EncodePaymentID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, PaymentID})
- return res
+ return encodeID(encoder, uid, PaymentID)
}
// EncodeFileID encode file id to hash id
func EncodeFileID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, FileID})
- return res
+ return encodeID(encoder, uid, FileID)
}
// EncodeAuditLogID encode audit log id to hash id
func EncodeAuditLogID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, AuditLogID})
- return res
+ return encodeID(encoder, uid, AuditLogID)
}
// EncodeTaskID encode task id to hash id
func EncodeTaskID(encoder Encoder, uid int) string {
- res, _ := encoder.Encode([]int{uid, TaskID})
- return res
+ return encodeID(encoder, uid, TaskID)
}
// EncodeEntityID encode entity id to hash id
func EncodeEntityID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, EntityID})
- return res
+ return encodeID(encoder, id, EntityID)
}
// EncodeNodeID encode node id to hash id
func EncodeNodeID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, NodeID})
- return res
+ return encodeID(encoder, id, NodeID)
}
-// EncodeEntityID encode policy id to hash id
+// EncodePolicyID encode policy id to hash id
func EncodePolicyID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, PolicyID})
- return res
+ return encodeID(encoder, id, PolicyID)
}
-// EncodeEntityID encode share id to hash id
+// EncodeShareID encode share id to hash id
func EncodeShareID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, ShareID})
- return res
+ return encodeID(encoder, id, ShareID)
}
// EncodeDavAccountID encode dav account id to hash id
func EncodeDavAccountID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, DavAccountID})
- return res
+ return encodeID(encoder, id, DavAccountID)
}
// EncodeSourceLinkID encode source link id to hash id
func EncodeSourceLinkID(encoder Encoder, id int) string {
- res, _ := encoder.Encode([]int{id, SourceLinkID})
- return res
+ return encodeID(encoder, id, SourceLinkID)
}
func FromContext(c context.Context) int {
diff --git a/pkg/mediameta/exif.go b/pkg/mediameta/exif.go
index df55c885..b682197e 100644
--- a/pkg/mediameta/exif.go
+++ b/pkg/mediameta/exif.go
@@ -263,90 +263,51 @@ func (e *exifExtractor) Extract(ctx context.Context, ext string, source entityso
return metas, nil
}
-func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.MediaMeta {
- metas := make([]driver.MediaMeta, 0)
- if value, ok := exifMap["Artist"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: Artist,
- Value: SanitizeMeta(value),
- })
- }
-
- if value, ok := exifMap["Copyright"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: Copyright,
- Value: SanitizeString(value),
- })
- }
-
- cameraMode := ""
- if value, ok := exifMap["CameraModel"]; ok && !IsUInt(value) {
- cameraMode = SanitizeString(value)
- } else if value, ok = exifMap["Model"]; ok && !IsUInt(value) {
- cameraMode = SanitizeString(value)
- } else if value, ok = exifMap["UniqueCameraModel"]; ok && !IsUInt(value) {
- cameraMode = SanitizeString(value)
- }
- if cameraMode != "" {
- metas = append(metas, driver.MediaMeta{
- Key: CameraModel,
- Value: cameraMode,
- })
- }
-
- cameraMake := ""
- if value, ok := exifMap["CameraMake"]; ok && !IsUInt(value) {
- cameraMake = SanitizeString(value)
- } else if value, ok = exifMap["Make"]; ok && !IsUInt(value) {
- cameraMake = SanitizeString(value)
- }
- if cameraMake != "" {
- metas = append(metas, driver.MediaMeta{
- Key: CameraMake,
- Value: cameraMake,
- })
- }
-
- if value, ok := exifMap["CameraOwnerName"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: CameraOwnerName,
- Value: SanitizeString(value),
- })
- }
-
- if value, ok := exifMap["BodySerialNumber"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: BodySerialNumber,
- Value: SanitizeString(value),
- })
- }
-
- if value, ok := exifMap["LensMake"]; ok && !IsUInt(value) {
- metas = append(metas, driver.MediaMeta{
- Key: LensMake,
- Value: SanitizeString(value),
- })
- }
+// exifTagTable maps EXIF tag names (in fallback order) to MediaMeta keys.
+// skipUint entries ignore pure-integer values, which vendor APIs sometimes
+// return instead of the real string value.
+var exifTagTable = []struct {
+ keys []string
+ metaKey string
+ clean func(string) string
+ skipUint bool
+}{
+ {[]string{"Artist"}, Artist, SanitizeMeta, false},
+ {[]string{"Copyright"}, Copyright, SanitizeString, false},
+ {[]string{"CameraModel", "Model", "UniqueCameraModel"}, CameraModel, SanitizeString, true},
+ {[]string{"CameraMake", "Make"}, CameraMake, SanitizeString, true},
+ {[]string{"CameraOwnerName"}, CameraOwnerName, SanitizeString, false},
+ {[]string{"BodySerialNumber"}, BodySerialNumber, SanitizeString, false},
+ {[]string{"LensMake"}, LensMake, SanitizeString, true},
+ {[]string{"LensModel", "Lens"}, LensModel, SanitizeString, true},
+ {[]string{"Software"}, Software, SanitizeString, false},
+ {[]string{"ISOSpeedRatings"}, ISOSpeedRatings, nil, false},
+ {[]string{"PixelXDimension", "ImageWidth"}, PixelXDimension, nil, false},
+ {[]string{"PixelYDimension", "ImageLength"}, PixelYDimension, nil, false},
+ {[]string{"ImageDescription"}, ImageDescription, SanitizeDescription, false},
+ {[]string{"ProjectionType"}, ProjectionType, SanitizeString, false},
+}
- lens := ""
- if value, ok := exifMap["LensModel"]; ok && !IsUInt(value) {
- lens = SanitizeString(value)
- } else if value, ok = exifMap["Lens"]; ok && !IsUInt(value) {
- lens = SanitizeString(value)
- }
- if lens != "" {
- metas = append(metas, driver.MediaMeta{
- Key: LensModel,
- Value: lens,
- })
+func extractExifTags(metas []driver.MediaMeta, exifMap map[string]string, from, to int) []driver.MediaMeta {
+ for _, e := range exifTagTable[from:to] {
+ for _, k := range e.keys {
+ v, ok := exifMap[k]
+ if !ok || (e.skipUint && IsUInt(v)) {
+ continue
+ }
+ if e.clean != nil {
+ v = e.clean(v)
+ }
+ metas = append(metas, driver.MediaMeta{Key: e.metaKey, Value: v})
+ break
+ }
}
+ return metas
+}
- if value, ok := exifMap["Software"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: Software,
- Value: SanitizeString(value),
- })
- }
+func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.MediaMeta {
+ metas := make([]driver.MediaMeta, 0)
+ metas = extractExifTags(metas, exifMap, 0, 9)
if value, ok := exifMap["ExposureTime"]; ok {
value = strings.TrimSuffix(value, " sec.")
@@ -427,38 +388,9 @@ func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.Media
})
}
- if value, ok := exifMap["ISOSpeedRatings"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: ISOSpeedRatings,
- Value: value,
- })
- }
+ metas = extractExifTags(metas, exifMap, 9, 10)
- width := ""
- if value, ok := exifMap["PixelXDimension"]; ok {
- width = value
- } else if value, ok := exifMap["ImageWidth"]; ok {
- width = value
- }
- if width != "" {
- metas = append(metas, driver.MediaMeta{
- Key: PixelXDimension,
- Value: width,
- })
- }
-
- height := ""
- if value, ok := exifMap["PixelYDimension"]; ok {
- height = value
- } else if value, ok := exifMap["ImageLength"]; ok {
- height = value
- }
- if height != "" {
- metas = append(metas, driver.MediaMeta{
- Key: PixelYDimension,
- Value: height,
- })
- }
+ metas = extractExifTags(metas, exifMap, 10, 12)
orientation := "1"
if value, ok := exifMap["Orientation"]; ok {
@@ -498,19 +430,7 @@ func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.Media
})
}
- if value, ok := exifMap["ImageDescription"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: ImageDescription,
- Value: SanitizeDescription(value),
- })
- }
-
- if value, ok := exifMap["ProjectionType"]; ok {
- metas = append(metas, driver.MediaMeta{
- Key: ProjectionType,
- Value: SanitizeString(value),
- })
- }
+ metas = extractExifTags(metas, exifMap, 12, 14)
return metas
}
diff --git a/pkg/mediameta/exif_extract_test.go b/pkg/mediameta/exif_extract_test.go
new file mode 100644
index 00000000..19a8cd27
--- /dev/null
+++ b/pkg/mediameta/exif_extract_test.go
@@ -0,0 +1,98 @@
+package mediameta
+
+import (
+ "testing"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
+ "github.com/stretchr/testify/assert"
+)
+
+func findMeta(metas []driver.MediaMeta, key string) (driver.MediaMeta, bool) {
+ for _, m := range metas {
+ if m.Key == key {
+ return m, true
+ }
+ }
+ return driver.MediaMeta{}, false
+}
+
+func TestExtractExifMap(t *testing.T) {
+ a := assert.New(t)
+ metas := ExtractExifMap(map[string]string{
+ "Artist": "ann",
+ "CameraModel": "12345", // pure-uint vendor garbage, must fall back
+ "Model": "X-T5",
+ "CameraMake": "Fuji",
+ "LensModel": "9999", // uint, falls back
+ "Lens": "XF35",
+ "Software": "darktable",
+ "PixelXDimension": "6000",
+ "ImageLength": "4000",
+ "ImageDescription": "desc",
+ "ProjectionType": "equirectangular",
+ "DateTimeOriginal": "2020:01:02 03:04:05",
+ "ISOSpeedRatings": "200",
+ "Flash": "1",
+ "FNumber": "28/10",
+ "Orientation": "6",
+ "ImageWidth": "9999", // shadowed by PixelXDimension
+ }, time.Time{})
+
+ v, ok := findMeta(metas, CameraModel)
+ a.True(ok)
+ a.Equal("X-T5", v.Value)
+
+ v, ok = findMeta(metas, LensModel)
+ a.True(ok)
+ a.Equal("XF35", v.Value)
+
+ v, ok = findMeta(metas, PixelXDimension)
+ a.True(ok)
+ a.Equal("6000", v.Value)
+
+ v, ok = findMeta(metas, PixelYDimension)
+ a.True(ok)
+ a.Equal("4000", v.Value)
+
+ v, ok = findMeta(metas, Orientation)
+ a.True(ok)
+ a.Equal("6", v.Value)
+
+ v, ok = findMeta(metas, TakenAt)
+ a.True(ok)
+ a.Equal("2020-01-02T03:04:05Z", v.Value)
+
+ v, ok = findMeta(metas, Flash)
+ a.True(ok)
+ a.Equal("1", v.Value)
+
+ v, ok = findMeta(metas, FNumber)
+ a.True(ok)
+ a.Equal("2.800000", v.Value)
+
+ v, ok = findMeta(metas, ImageDescription)
+ a.True(ok)
+ a.Equal("desc", v.Value)
+
+ v, ok = findMeta(metas, ProjectionType)
+ a.True(ok)
+ a.Equal("equirectangular", v.Value)
+
+ _, ok = findMeta(metas, Copyright)
+ a.False(ok)
+}
+
+func TestExtractExifMapDefaults(t *testing.T) {
+ a := assert.New(t)
+ metas := ExtractExifMap(map[string]string{}, time.Time{})
+
+ // Orientation is always emitted, defaulting to "1".
+ v, ok := findMeta(metas, Orientation)
+ a.True(ok)
+ a.Equal("1", v.Value)
+
+ // No date tags and zero GPS time -> no TakenAt.
+ _, ok = findMeta(metas, TakenAt)
+ a.False(ok)
+}
diff --git a/pkg/mediameta/gps.go b/pkg/mediameta/gps.go
new file mode 100644
index 00000000..a2fb1b6f
--- /dev/null
+++ b/pkg/mediameta/gps.go
@@ -0,0 +1,65 @@
+package mediameta
+
+import (
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
+)
+
+// DMSDecimal converts degree/minute/second GPS components to a signed
+// decimal degree; negative flips the sign for south/west references.
+func DMSDecimal(deg, minutes, seconds float64, negative bool) float64 {
+ decimal := deg + minutes/60.0 + seconds/3600.0
+ if negative {
+ return -decimal
+ }
+ return decimal
+}
+
+// GpsRationalElem parses one rational GPS element of the form "n/d" into a
+// decimal value. Returns 0 for malformed or zero-denominator elements.
+func GpsRationalElem(elm string) float64 {
+ elements := strings.Split(elm, "/")
+ if len(elements) != 2 {
+ return 0
+ }
+
+ numerator, err := strconv.ParseFloat(elements[0], 64)
+ if err != nil {
+ return 0
+ }
+
+ denominator, err := strconv.ParseFloat(elements[1], 64)
+ if err != nil || denominator == 0 {
+ return 0
+ }
+
+ return numerator / denominator
+}
+
+// GpsMeta builds the latitude/longitude MediaMeta pair from raw EXIF parts.
+// parse converts a raw coordinate string and its hemisphere reference into
+// decimal degrees; drivers pass their vendor-specific parser.
+func GpsMeta(latitude, longitude, latRef, lonRef string, parse func(raw, ref string) float64) []driver.MediaMeta {
+ if latitude == "" || longitude == "" || latRef == "" || lonRef == "" {
+ return nil
+ }
+
+ lat := parse(latitude, latRef)
+ lon := parse(longitude, lonRef)
+ if math.IsNaN(lat) || math.IsNaN(lon) {
+ return nil
+ }
+
+ latN, lngN := NormalizeGPS(lat, lon)
+ return []driver.MediaMeta{{
+ Key: GpsLat,
+ Value: fmt.Sprintf("%f", latN),
+ }, {
+ Key: GpsLng,
+ Value: fmt.Sprintf("%f", lngN),
+ }}
+}
diff --git a/pkg/queue/queue.go b/pkg/queue/queue.go
index b47a90c1..efe6189c 100644
--- a/pkg/queue/queue.go
+++ b/pkg/queue/queue.go
@@ -23,6 +23,10 @@ type (
Shutdown()
// SubmitTask submits a Task to the queue.
QueueTask(ctx context.Context, t Task) error
+ // CancelTask marks a queued or suspending task as canceled. The
+ // scheduler entry is left in place; workers skip canceled tasks at
+ // pickup. Returns false if the task is not in this queue's registry.
+ CancelTask(ctx context.Context, taskID int) bool
// BusyWorkers returns the numbers of workers in the running process.
BusyWorkers() int
// BusyWorkers returns the numbers of success tasks.
@@ -218,6 +222,22 @@ func (q *queue) newContext(t Task) context.Context {
return ctx
}
+// CancelTask marks a queued or suspending task as canceled. The scheduler
+// heap entry is not removed; workers skip canceled tasks at pickup.
+func (q *queue) CancelTask(ctx context.Context, taskID int) bool {
+ if q.registry == nil {
+ return false
+ }
+ t, found := q.registry.Get(taskID)
+ if !found {
+ return false
+ }
+ if t.Status() != task.StatusQueued && t.Status() != task.StatusSuspending {
+ return false
+ }
+ return q.transitStatus(ctx, t, task.StatusCanceled) == nil
+}
+
func (q *queue) work(t Task) {
ctx := q.newContext(t)
l := logging.FromContext(ctx)
@@ -238,6 +258,11 @@ func (q *queue) work(t Task) {
q.schedule()
}()
+ // Canceled while waiting in the scheduler; nothing to run.
+ if t.Status() == task.StatusCanceled {
+ return
+ }
+
err = q.transitStatus(ctx, t, task.StatusProcessing)
if err != nil {
l.Error("failed to transit task %d to processing: %s", t.ID(), err.Error())
diff --git a/pkg/queue/task.go b/pkg/queue/task.go
index 0a2d9fc9..a648efbe 100644
--- a/pkg/queue/task.go
+++ b/pkg/queue/task.go
@@ -104,6 +104,7 @@ const (
RelocateTaskType = "relocate"
RemoteDownloadTaskType = "remote_download"
ImportTaskType = "import"
+ BlobAuditTaskType = "blob_audit"
FullTextIndexTaskType = "full_text_index"
FullTextCopyTaskType = "full_text_copy"
@@ -393,6 +394,12 @@ func init() {
q.metric.IncFailureTask()
return persistTask(ctx, task, newStatus, q)
},
+ task.StatusCanceled: func(ctx context.Context, task Task, newStatus task.Status, q *queue) error {
+ if q.registry != nil {
+ q.registry.Delete(task.ID())
+ }
+ return persistTask(ctx, task, newStatus, q)
+ },
},
task.StatusProcessing: {
task.StatusQueued: persistTask,
@@ -468,6 +475,18 @@ func init() {
q.metric.IncFailureTask()
return persistTask(ctx, task, newStatus, q)
},
+ task.StatusCanceled: func(ctx context.Context, task Task, newStatus task.Status, q *queue) error {
+ q.metric.DecSuspendingTask()
+ if q.registry != nil {
+ q.registry.Delete(task.ID())
+ }
+ return persistTask(ctx, task, newStatus, q)
+ },
+ },
+ task.StatusError: {
+ // Manual retry re-queues a failed task with its original args
+ // (#2823). RetryCount history is preserved in PublicState.
+ task.StatusQueued: persistTask,
},
}
diff --git a/pkg/request/options.go b/pkg/request/options.go
index e5b5d844..5b1693f6 100644
--- a/pkg/request/options.go
+++ b/pkg/request/options.go
@@ -20,6 +20,7 @@ type options struct {
header http.Header
sign auth.Auth
signTTL int64
+ signBaseTime time.Time
ctx context.Context
contentLength int64
masterMeta bool
@@ -88,6 +89,15 @@ func WithCredential(instance auth.Auth, ttl int64) Option {
})
}
+// WithSignBaseTime overrides the "now" used to compute the signature expiry
+// timestamp. Slave clients use it to compensate clock skew against the master
+// once the offset is learned from a signature-expired response (#3242).
+func WithSignBaseTime(t time.Time) Option {
+ return optionFunc(func(o *options) {
+ o.signBaseTime = t
+ })
+}
+
// WithHeader 设置请求Header
func WithHeader(header http.Header) Option {
return optionFunc(func(o *options) {
diff --git a/pkg/request/request.go b/pkg/request/request.go
index d987814b..38c2e9cb 100644
--- a/pkg/request/request.go
+++ b/pkg/request/request.go
@@ -21,9 +21,6 @@ import (
"github.com/samber/lo"
)
-// GeneralClient 通用 HTTP Client
-var GeneralClient Client = NewClientDeprecated()
-
const (
CorrelationHeader = constants.CrHeaderPrefix + "Correlation-Id"
SiteURLHeader = constants.CrHeaderPrefix + "Site-Url"
@@ -72,20 +69,6 @@ func NewClient(config conf.ConfigProvider, opts ...Option) Client {
return client
}
-// Deprecated
-func NewClientDeprecated(opts ...Option) Client {
- client := &HTTPClient{
- options: newDefaultOption(),
- tpsLimiter: globalTPSLimiter,
- }
-
- for _, o := range opts {
- o.apply(client.options)
- }
-
- return client
-}
-
func (c *HTTPClient) Apply(opts ...Option) {
for _, o := range opts {
o.apply(c.options)
@@ -176,7 +159,11 @@ func (c *HTTPClient) Request(method, target string, body io.Reader, opts ...Opti
if options.ctx == nil {
ctx = context.Background()
}
- expire := time.Now().Add(time.Second * time.Duration(options.signTTL))
+ expireBase := options.signBaseTime
+ if expireBase.IsZero() {
+ expireBase = time.Now()
+ }
+ expire := expireBase.Add(time.Second * time.Duration(options.signTTL))
switch method {
case "PUT", "POST", "PATCH":
auth.SignRequest(ctx, options.sign, req, &expire)
diff --git a/pkg/request/request_test.go b/pkg/request/request_test.go
index ce507d09..07e28083 100644
--- a/pkg/request/request_test.go
+++ b/pkg/request/request_test.go
@@ -6,9 +6,12 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/stretchr/testify/assert"
testMock "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
"io"
"io/ioutil"
"net/http"
+ "net/http/httptest"
+ "strconv"
"strings"
"testing"
"time"
@@ -53,7 +56,7 @@ func TestWithContext(t *testing.T) {
func TestHTTPClient_Request(t *testing.T) {
asserts := assert.New(t)
- client := NewClientDeprecated(WithSlaveMeta(1))
+ client := NewClient(nil, WithSlaveMeta(1))
// 正常
{
@@ -236,7 +239,7 @@ func TestBlackHole(t *testing.T) {
func TestHTTPClient_TPSLimit(t *testing.T) {
a := assert.New(t)
- client := NewClientDeprecated()
+ client := NewClient(nil)
finished := make(chan struct{})
go func() {
@@ -271,3 +274,43 @@ func TestHTTPClient_TPSLimit(t *testing.T) {
}
}
+
+func TestWithSignBaseTime(t *testing.T) {
+ asserts := assert.New(t)
+ options := newDefaultOption()
+ asserts.True(options.signBaseTime.IsZero())
+
+ future := time.Now().Add(time.Hour)
+ WithSignBaseTime(future).apply(options)
+ asserts.Equal(future, options.signBaseTime)
+}
+
+func TestSignBaseTimeShiftsExpiry(t *testing.T) {
+ asserts := assert.New(t)
+
+ var authHeader string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader = r.Header.Get("Authorization")
+ w.WriteHeader(200)
+ _, _ = w.Write([]byte(`{"code":0}`))
+ }))
+ defer srv.Close()
+
+ client := NewClient(nil, WithEndpoint(srv.URL))
+ base := time.Now().Add(2 * time.Hour)
+ resp := client.Request(
+ "POST",
+ "/upload",
+ strings.NewReader("{}"),
+ WithCredential(auth.HMACAuth{SecretKey: []byte("k")}, 60),
+ WithSignBaseTime(base),
+ )
+ asserts.NoError(resp.Err)
+
+ // Authorization: Cr :
+ parts := strings.Split(authHeader, ":")
+ require.GreaterOrEqual(t, len(parts), 2)
+ expires, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
+ asserts.NoError(err)
+ asserts.InDelta(base.Add(60*time.Second).Unix(), expires, 5)
+}
diff --git a/pkg/serializer/error.go b/pkg/serializer/error.go
index bb030a43..bccbfac5 100644
--- a/pkg/serializer/error.go
+++ b/pkg/serializer/error.go
@@ -285,7 +285,7 @@ const (
CodeNotSet = -1
)
-// DBErrDeprecated 数据库操作失败
+// DBErr 数据库操作失败
func DBErr(c context.Context, msg string, err error) Response {
if msg == "" {
msg = "Database operation failed."
@@ -293,14 +293,6 @@ func DBErr(c context.Context, msg string, err error) Response {
return ErrWithDetails(c, CodeDBError, msg, err)
}
-// DBErrDeprecated 数据库操作失败
-func DBErrDeprecated(msg string, err error) Response {
- if msg == "" {
- msg = "Database operation failed."
- }
- return ErrDeprecated(CodeDBError, msg, err)
-}
-
// ParamErr 各种参数错误
func ParamErr(c context.Context, msg string, err error) Response {
if msg == "" {
@@ -309,36 +301,6 @@ func ParamErr(c context.Context, msg string, err error) Response {
return ErrWithDetails(c, CodeParamErr, msg, err)
}
-// ParamErrDeprecated 各种参数错误
-// Deprecated
-func ParamErrDeprecated(msg string, err error) Response {
- if msg == "" {
- msg = "Invalid parameters."
- }
- return ErrDeprecated(CodeParamErr, msg, err)
-}
-
-// ErrDeprecated 通用错误处理
-func ErrDeprecated(errCode int, msg string, err error) Response {
- // 底层错误是AppError,则尝试从AppError中获取详细信息
- var appError AppError
- if errors.As(err, &appError) {
- errCode = appError.Code
- err = appError.RawError
- msg = appError.Msg
- }
-
- res := Response{
- Code: errCode,
- Msg: msg,
- }
- // 生产环境隐藏底层报错
- if err != nil && gin.Mode() != gin.ReleaseMode {
- res.Error = err.Error()
- }
- return res
-}
-
// ErrWithDetails 通用错误处理
func ErrWithDetails(c context.Context, errCode int, msg string, err error) Response {
res := Response{
diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go
index d0878a94..c547f9a8 100644
--- a/pkg/setting/provider.go
+++ b/pkg/setting/provider.go
@@ -246,6 +246,12 @@ type (
SSO(ctx context.Context) *SSO
// EmailFilter returns the sign-up email restriction settings.
EmailFilter(ctx context.Context) *EmailFilter
+ // ShareDefaults returns the site-wide share defaults applied when a
+ // user has not overridden them in their personal settings.
+ ShareDefaults(ctx context.Context) *ShareDefaults
+ // DownloadCDNRoutes returns the configured alternative download
+ // endpoints users can pick from (e.g. CDN mirrors of the site).
+ DownloadCDNRoutes(ctx context.Context) []CDNRoute
}
UseFirstSiteUrlCtxKey = struct{}
)
@@ -905,6 +911,7 @@ func (s *settingProvider) SSO(ctx context.Context) *SSO {
ClientSecret: s.getString(ctx, "sso_client_secret", ""),
Scopes: strings.Join(scopeList, " "),
RegisterEnabled: s.getBoolean(ctx, "sso_register_enabled", true),
+ AutoRedirect: s.getBoolean(ctx, "sso_auto_redirect", false),
}
}
@@ -929,6 +936,7 @@ func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter {
Mode: mode,
List: list,
DisableSubAddress: s.getBoolean(ctx, "email_disable_subaddress", false),
+ SubAddressChars: s.getString(ctx, "email_subaddress_chars", "+"),
}
}
@@ -936,6 +944,58 @@ func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool {
return s.getBoolean(ctx, "expose_user_email", true)
}
+// ShareDefaults holds the site-wide share defaults (#3390).
+type ShareDefaults struct {
+ // LinksInProfile is the default profile share visibility applied when a
+ // user's own share_links_in_profile is unset.
+ LinksInProfile types.ShareLinksInProfileLevel
+ // PrivateByDefault makes new shares default to private (random password).
+ PrivateByDefault bool
+}
+
+// CDNRoute is an alternative download endpoint offered to users when
+// downloading files, e.g. a CDN mirror fronting the site.
+type CDNRoute struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+}
+
+func (s *settingProvider) DownloadCDNRoutes(ctx context.Context) []CDNRoute {
+ raw := s.getString(ctx, "download_cdn_routes", "")
+ routes := make([]CDNRoute, 0)
+ for _, line := range strings.Split(raw, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ name, u, found := strings.Cut(line, "=")
+ if !found {
+ continue
+ }
+ u = strings.TrimRight(strings.TrimSpace(u), "/")
+ parsed, err := url.Parse(u)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" ||
+ (parsed.Scheme != "http" && parsed.Scheme != "https") {
+ continue
+ }
+ routes = append(routes, CDNRoute{Name: strings.TrimSpace(name), URL: u})
+ }
+ return routes
+}
+
+func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults {
+ level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", ""))
+ switch level {
+ case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare:
+ default:
+ level = types.ProfilePublicShareOnly
+ }
+ return &ShareDefaults{
+ LinksInProfile: level,
+ PrivateByDefault: s.getBoolean(ctx, "share_default_private", false),
+ }
+}
+
func (s *settingProvider) SiteBasic(ctx context.Context) *SiteBasic {
return &SiteBasic{
Name: s.getString(ctx, "siteName", ""),
diff --git a/pkg/setting/provider_test.go b/pkg/setting/provider_test.go
new file mode 100644
index 00000000..9645b4bf
--- /dev/null
+++ b/pkg/setting/provider_test.go
@@ -0,0 +1,37 @@
+package setting
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type stubAdapter map[string]any
+
+func (s stubAdapter) Get(_ context.Context, name string, defaultVal any) any {
+ if v, ok := s[name]; ok {
+ return v
+ }
+ return defaultVal
+}
+
+func TestDownloadCDNRoutes(t *testing.T) {
+ ctx := context.Background()
+
+ p := NewProvider(stubAdapter{})
+ require.Empty(t, p.DownloadCDNRoutes(ctx))
+
+ p = NewProvider(stubAdapter{
+ "download_cdn_routes": "Line 1=https://cdn1.example.com/\n\n" +
+ "not-a-route\n" +
+ "cdn2 = https://cdn2.example.com/base/\n" +
+ "bad=ftp://example.com\n" +
+ "also-bad=notaurl",
+ })
+ routes := p.DownloadCDNRoutes(ctx)
+ require.Equal(t, []CDNRoute{
+ {Name: "Line 1", URL: "https://cdn1.example.com"},
+ {Name: "cdn2", URL: "https://cdn2.example.com/base"},
+ }, routes)
+}
diff --git a/pkg/setting/types.go b/pkg/setting/types.go
index 67d3aece..32fd7730 100644
--- a/pkg/setting/types.go
+++ b/pkg/setting/types.go
@@ -82,6 +82,7 @@ type SSO struct {
ClientSecret string
Scopes string
RegisterEnabled bool
+ AutoRedirect bool
}
type EmailFilterMode int
@@ -97,6 +98,9 @@ type EmailFilter struct {
Mode EmailFilterMode
List []string
DisableSubAddress bool
+ // SubAddressChars are the characters treated as sub-address separators in
+ // the local part when DisableSubAddress is on. Empty falls back to "+".
+ SubAddressChars string
}
type DBFS struct {
@@ -244,6 +248,9 @@ type CustomNavItem struct {
Icon string `json:"icon"`
Name string `json:"name"`
URL string `json:"url"`
+ // Scope limits display: "" or "public" shows to everyone, "user" only
+ // to logged-in accounts (#3578).
+ Scope string `json:"scope,omitempty"`
}
type CustomHTML struct {
diff --git a/pkg/thumb/builtin.go b/pkg/thumb/builtin.go
index 0f6be179..2503769e 100644
--- a/pkg/thumb/builtin.go
+++ b/pkg/thumb/builtin.go
@@ -77,8 +77,8 @@ func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) {
}, nil
}
-// GetThumb 生成给定最大尺寸的缩略图
-func (image *Thumb) GetThumb(width, height uint) {
+// Resize 生成给定最大尺寸的缩略图(就地修改)
+func (image *Thumb) Resize(width, height uint) {
//image.src = resize.Thumbnail(width, height, image.src, resize.Lanczos3)
image.src = Thumbnail(width, height, image.src)
}
@@ -170,7 +170,7 @@ func (b Builtin) Generate(ctx context.Context, es entitysource.EntitySource, ext
}
w, h := b.settings.ThumbSize(ctx)
- img.GetThumb(uint(w), uint(h))
+ img.Resize(uint(w), uint(h))
tempPath := filepath.Join(
util.DataPath(b.settings.TempPath(ctx)),
thumbTempFolder,
diff --git a/pkg/thumb/ffmpeg.go b/pkg/thumb/ffmpeg.go
index e0742509..a520572f 100644
--- a/pkg/thumb/ffmpeg.go
+++ b/pkg/thumb/ffmpeg.go
@@ -30,7 +30,7 @@ type FfmpegGenerator struct {
}
func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
- if !util.IsInExtensionListExt(f.settings.FFMpegThumbExts(ctx), ext) {
+ if !util.IsExtInList(f.settings.FFMpegThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
@@ -94,7 +94,7 @@ func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySo
if err := cmd.Run(); err != nil {
f.l.Warning("Failed to invoke ffmpeg: %s", stdErr.String())
- return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s", err, stdErr.String())
+ return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s: %w", err, stdErr.String(), ErrPassThrough)
}
return &Result{Path: tempOutputPath}, nil
diff --git a/pkg/thumb/libraw.go b/pkg/thumb/libraw.go
index e43ba8b3..fa2bab64 100644
--- a/pkg/thumb/libraw.go
+++ b/pkg/thumb/libraw.go
@@ -30,7 +30,7 @@ type LibRawGenerator struct {
}
func (l *LibRawGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
- if !util.IsInExtensionListExt(l.settings.LibRawThumbExts(ctx), ext) {
+ if !util.IsExtInList(l.settings.LibRawThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
diff --git a/pkg/thumb/libreoffice.go b/pkg/thumb/libreoffice.go
index 89fbdef4..d73c10ee 100644
--- a/pkg/thumb/libreoffice.go
+++ b/pkg/thumb/libreoffice.go
@@ -27,7 +27,7 @@ type LibreOfficeGenerator struct {
}
func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
- if !util.IsInExtensionListExt(l.settings.LibreOfficeThumbExts(ctx), ext) {
+ if !util.IsExtInList(l.settings.LibreOfficeThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
@@ -79,7 +79,7 @@ func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.Ent
if err := cmd.Run(); err != nil {
l.l.Warning("Failed to invoke LibreOffice: %s", stdErr.String())
- return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s", err, stdErr.String())
+ return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s: %w", err, stdErr.String(), ErrPassThrough)
}
return &Result{
diff --git a/pkg/thumb/music.go b/pkg/thumb/music.go
index 4947336c..6520d814 100644
--- a/pkg/thumb/music.go
+++ b/pkg/thumb/music.go
@@ -23,7 +23,7 @@ type MusicCoverGenerator struct {
}
func (v *MusicCoverGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
- if !util.IsInExtensionListExt(v.settings.MusicCoverThumbExts(ctx), ext) {
+ if !util.IsExtInList(v.settings.MusicCoverThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported music format: %w", ErrPassThrough)
}
diff --git a/pkg/thumb/vips.go b/pkg/thumb/vips.go
index bd344fd2..d2a3e443 100644
--- a/pkg/thumb/vips.go
+++ b/pkg/thumb/vips.go
@@ -28,7 +28,7 @@ type VipsGenerator struct {
}
func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
- if !util.IsInExtensionListExt(v.settings.VipsThumbExts(ctx), ext) {
+ if !util.IsExtInList(v.settings.VipsThumbExts(ctx), ext) {
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
}
@@ -105,7 +105,10 @@ func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySour
if err := cmd.Run(); err != nil {
v.l.Warning("Failed to invoke vips: %s", vipsErr.String())
- return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s", err, vipsErr.String())
+ // A non-zero exit usually means vips cannot decode the format (e.g.
+ // psd, html). Pass through so later generators can try instead of
+ // failing the whole pipeline.
+ return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s: %w", err, vipsErr.String(), ErrPassThrough)
}
return &Result{Path: tempPath}, nil
diff --git a/pkg/util/common.go b/pkg/util/common.go
index 3791d7de..50dd56ca 100644
--- a/pkg/util/common.go
+++ b/pkg/util/common.go
@@ -3,19 +3,12 @@ package util
import (
"context"
cryptoRand "crypto/rand"
- "fmt"
"math/big"
"math/rand"
- "path"
- "path/filepath"
- "regexp"
- "strconv"
"strings"
"time"
- "unicode/utf8"
"github.com/gin-gonic/gin"
- "github.com/gofrs/uuid"
)
func init() {
@@ -85,7 +78,7 @@ func IsInExtensionList(extList []string, fileName string) bool {
}
// IsInExtensionList 返回文件的扩展名是否在给定的列表范围内
-func IsInExtensionListExt(extList []string, ext string) bool {
+func IsExtInList(extList []string, ext string) bool {
// 无扩展名时
if len(ext) == 0 {
return false
@@ -116,102 +109,6 @@ func Replace(table map[string]string, s string) string {
return s
}
-// ReplaceMagicVar 动态替换字符串中的魔法变量
-func ReplaceMagicVar(rawString string, fsSeparator string, pathAvailable bool, blobAvailable bool,
- timeConst time.Time, userId int, originName string, originPath string, completeBlobPath string) string {
- re := regexp.MustCompile(`\{[^{}]+\}`)
- return re.ReplaceAllStringFunc(rawString, func(match string) string {
- switch match {
- case "{randomkey16}":
- return RandStringRunes(16)
- case "{randomkey8}":
- return RandStringRunes(8)
- case "{timestamp}":
- return strconv.FormatInt(timeConst.Unix(), 10)
- case "{timestamp_nano}":
- return strconv.FormatInt(timeConst.UnixNano(), 10)
- case "{randomnum2}":
- return strconv.Itoa(rand.Intn(2))
- case "{randomnum3}":
- return strconv.Itoa(rand.Intn(3))
- case "{randomnum4}":
- return strconv.Itoa(rand.Intn(4))
- case "{randomnum8}":
- return strconv.Itoa(rand.Intn(8))
- case "{uid}":
- return strconv.Itoa(userId)
- case "{datetime}":
- return timeConst.Format("20060102150405")
- case "{date}":
- return timeConst.Format("20060102")
- case "{year}":
- return timeConst.Format("2006")
- case "{month}":
- return timeConst.Format("01")
- case "{day}":
- return timeConst.Format("02")
- case "{hour}":
- return timeConst.Format("15")
- case "{minute}":
- return timeConst.Format("04")
- case "{second}":
- return timeConst.Format("05")
- case "{uuid}":
- return uuid.Must(uuid.NewV4()).String()
- case "{ext}":
- return filepath.Ext(originName)
- case "{originname}":
- return originName
- case "{originname_without_ext}":
- return strings.TrimSuffix(originName, filepath.Ext(originName))
- case "{path}":
- if pathAvailable {
- return originPath + fsSeparator
- }
- return match
- case "{blob_name}":
- if blobAvailable {
- return filepath.Base(completeBlobPath)
- }
- return match
- case "{blob_name_without_ext}":
- if blobAvailable {
- return strings.TrimSuffix(filepath.Base(completeBlobPath), filepath.Ext(completeBlobPath))
- }
- return match
- case "{blob_path}":
- if blobAvailable {
- return path.Dir(completeBlobPath) + fsSeparator
- }
- return match
- default:
- return match
- }
- })
-}
-
-// BuildRegexp 构建用于SQL查询用的多条件正则
-func BuildRegexp(search []string, prefix, suffix, condition string) string {
- var res string
- for key, value := range search {
- res += prefix + regexp.QuoteMeta(value) + suffix
- if key < len(search)-1 {
- res += condition
- }
- }
- return res
-}
-
-// BuildConcat 根据数据库类型构建字符串连接表达式
-func BuildConcat(str1, str2 string, DBType string) string {
- switch DBType {
- case "mysql":
- return "CONCAT(" + str1 + "," + str2 + ")"
- default:
- return str1 + "||" + str2
- }
-}
-
// SliceIntersect 求两个切片交集
func SliceIntersect(slice1, slice2 []string) []string {
m := make(map[string]int)
@@ -260,101 +157,6 @@ func BoolToString(b bool) string {
return "0"
}
-func EncodeTimeFlowString(str string, timeNow int64) string {
- timeNow = timeNow / 1000
- timeDigits := []int{}
- timeDigitIndex := 0
-
- if len(str) == 0 {
- return ""
- }
-
- str = fmt.Sprintf("%d|%s", timeNow, str)
-
- res := make([]int32, 0, utf8.RuneCountInString(str))
- for timeNow > 0 {
- timeDigits = append(timeDigits, int(timeNow%int64(10)))
- timeNow = timeNow / 10
- }
-
- add := false
- for pos, rune := range str {
- // take single digit with index timeDigitIndex from timeNow
- newIndex := pos
- if add {
- newIndex = pos + timeDigits[timeDigitIndex]*timeDigitIndex
- } else {
- newIndex = 2*timeDigitIndex*timeDigits[timeDigitIndex] - pos
- }
-
- if newIndex < 0 {
- newIndex = newIndex * -1
- }
-
- res = append(res, rune)
- newIndex = newIndex % len(res)
-
- res[newIndex], res[len(res)-1] = res[len(res)-1], res[newIndex]
-
- add = !add
- // Add timeDigitIndex by 1, but does not exceed total digits in timeNow
- timeDigitIndex++
- timeDigitIndex = timeDigitIndex % len(timeDigits)
- }
-
- return string(res)
-}
-
-func DecodeTimeFlowStringTime(str string, timeNow int64) string {
- timeNow = timeNow / 1000
- timeDigits := []int{}
-
- if len(str) == 0 {
- return ""
- }
-
- for timeNow > 0 {
- timeDigits = append(timeDigits, int(timeNow%int64(10)))
- timeNow = timeNow / 10
- }
-
- res := make([]int32, utf8.RuneCountInString(str))
- secret := []rune(str)
- add := false
- if len(secret)%2 == 0 {
- add = true
- }
- timeDigitIndex := (len(secret) - 1) % len(timeDigits)
- for pos := range secret {
- // take single digit with index timeDigitIndex from timeNow
- newIndex := len(res) - 1 - pos
- if add {
- newIndex = newIndex + timeDigits[timeDigitIndex]*timeDigitIndex
- } else {
- newIndex = 2*timeDigitIndex*timeDigits[timeDigitIndex] - newIndex
- }
-
- if newIndex < 0 {
- newIndex = newIndex * -1
- }
-
- newIndex = newIndex % len(secret)
-
- res[len(res)-1-pos] = secret[newIndex]
- secret[newIndex], secret[len(res)-1-pos] = secret[len(res)-1-pos], secret[newIndex]
- secret = secret[:len(secret)-1]
-
- add = !add
- // Add timeDigitIndex by 1, but does not exceed total digits in timeNow
- timeDigitIndex--
- if timeDigitIndex < 0 {
- timeDigitIndex = len(timeDigits) - 1
- }
- }
-
- return string(res)
-}
-
func ToPtr[T any](v T) *T {
return &v
}
diff --git a/pkg/util/common_test.go b/pkg/util/common_test.go
index ae4c47fd..68905879 100644
--- a/pkg/util/common_test.go
+++ b/pkg/util/common_test.go
@@ -56,19 +56,6 @@ func TestReplace(t *testing.T) {
}, "123origin123"))
}
-func TestBuildRegexp(t *testing.T) {
- asserts := assert.New(t)
-
- asserts.Equal("^/dir/", BuildRegexp([]string{"/dir"}, "^", "/", "|"))
- asserts.Equal("^/dir/|^/dir/di\\*r/", BuildRegexp([]string{"/dir", "/dir/di*r"}, "^", "/", "|"))
-}
-
-func TestBuildConcat(t *testing.T) {
- asserts := assert.New(t)
- asserts.Equal("CONCAT(1,2)", BuildConcat("1", "2", "mysql"))
- asserts.Equal("1||2", BuildConcat("1", "2", "sqlite"))
-}
-
func TestSliceDifference(t *testing.T) {
asserts := assert.New(t)
diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go
index 78ec8027..0e3ab4b6 100644
--- a/pkg/webdav/webdav.go
+++ b/pkg/webdav/webdav.go
@@ -33,7 +33,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
- "golang.org/x/tools/container/intsets"
+ "math"
)
const (
@@ -41,7 +41,13 @@ const (
)
func stripPrefix(p string, u *ent.User) (string, *fs.URI, int, error) {
- base, err := fs.NewUriFromString(u.Edges.DavAccounts[0].URI)
+ // Bearer-authenticated users carry no dav account; mount them at their
+ // "my" root. Basic-auth users keep their configured mount point (#3548).
+ baseUri := fs.NewMyUri("")
+ if len(u.Edges.DavAccounts) > 0 {
+ baseUri = u.Edges.DavAccounts[0].URI
+ }
+ base, err := fs.NewUriFromString(baseUri)
if err != nil {
return "", nil, http.StatusInternalServerError, err
}
@@ -190,7 +196,29 @@ func confirmLock(c *gin.Context, fm manager.FileManager, user *ent.User, srcAnc,
return nil, nil, http.StatusPreconditionFailed, ErrLocked
}
+// davWriteForbidden reports whether the user is confined to read-only WebDAV
+// access, either by a group-level restriction or a read-only dav account.
+// Read-only access allows GET/HEAD/OPTIONS/PROPFIND and rejects every
+// state-changing method.
+func davWriteForbidden(user *ent.User) bool {
+ if user == nil {
+ return false
+ }
+ if len(user.Edges.DavAccounts) > 0 &&
+ user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
+ return true
+ }
+ if user.Edges.Group != nil &&
+ user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) {
+ return true
+ }
+ return false
+}
+
func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@@ -227,6 +255,9 @@ func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status
}
func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@@ -237,7 +268,8 @@ func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status i
return purposeStatusCodeFromError(err), err
}
- if user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) {
+ if len(user.Edges.DavAccounts) > 0 &&
+ user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) {
if strings.HasPrefix(reqPath.Name(), ".") {
return http.StatusMethodNotAllowed, nil
}
@@ -439,6 +471,12 @@ func handleRangedPut(ctx context.Context, c *gin.Context, user *ent.User, m mana
return http.StatusInternalServerError, err
}
if !allReceived {
+ // If the session record vanished mid-upload, coverage can never
+ // complete — fail so the client retries rather than leaving a stuck
+ // placeholder.
+ if _, ok := kv.Get(manager.UploadSessionCachePrefix + sessionKey); !ok {
+ return http.StatusConflict, errors.New("upload session expired")
+ }
return http.StatusCreated, nil
}
@@ -466,7 +504,7 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat
if target, _, err := fm.SharedAddressTranslation(c, reqPath); err == nil {
allow = allow[:1]
read, update, del, create := true, true, true, true
- if target.OwnerID() != user.ID {
+ if target.OwnerID() != user.ID || davWriteForbidden(user) {
update = false
del = false
create = false
@@ -475,7 +513,10 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat
allow = append(allow, "DELETE", "MOVE")
}
if read {
- allow = append(allow, "COPY", "PROPFIND")
+ if !davWriteForbidden(user) {
+ allow = append(allow, "COPY")
+ }
+ allow = append(allow, "PROPFIND")
if target.Type() == types.FileTypeFile {
allow = append(allow, "GET", "HEAD", "POST")
}
@@ -526,7 +567,8 @@ func handleGetHeadPost(c *gin.Context, user *ent.User, fm manager.FileManager) (
es.Apply(entitysource.WithSpeedLimit(int64(user.Edges.Group.SpeedLimit)))
if es.ShouldInternalProxy() ||
- (user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) &&
+ (len(user.Edges.DavAccounts) > 0 &&
+ user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) &&
user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVProxy))) {
es.Serve(c.Writer, c.Request)
} else {
@@ -559,6 +601,9 @@ func handleUnlock(c *gin.Context, user *ent.User, fm manager.FileManager) (retSt
}
func handleLock(c *gin.Context, user *ent.User, fm manager.FileManager) (retStatus int, retErr error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
duration, err := parseTimeout(c.Request.Header.Get("Timeout"))
if err != nil {
return http.StatusBadRequest, err
@@ -732,6 +777,9 @@ func handlePropfind(c *gin.Context, user *ent.User, fm manager.FileManager) (sta
}
func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@@ -759,6 +807,9 @@ func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (statu
}
func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
hdr := c.Request.Header.Get("Destination")
if hdr == "" {
return http.StatusBadRequest, errInvalidDestination
@@ -832,7 +883,7 @@ func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (sta
}
}
- return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, true, overwrite, dstExists)
+ return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, true, overwrite, dstExists, hashid.EncodeUserID(hasher, user.ID))
}
release, ls, status, err := confirmLock(c, fm, user, srcTarget, dstTarget, srcUri, dstUri)
@@ -850,13 +901,14 @@ func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (sta
return http.StatusBadRequest, errInvalidDepth
}
}
- return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, false, overwrite, dstExists)
+ return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, false, overwrite, dstExists, hashid.EncodeUserID(hasher, user.ID))
}
type copyMoveOperations interface {
Delete(ctx context.Context, path []*fs.URI, opts ...fs.Option) error
MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, isCopy bool) error
Rename(ctx context.Context, path *fs.URI, newName string) (fs.File, error)
+ SharedAddressTranslation(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, *fs.URI, error)
}
func parseOverwrite(value string) (bool, error) {
@@ -875,7 +927,20 @@ func performCopyMove(
fm copyMoveOperations,
srcUri, dstUri, dstFolderUri *fs.URI,
isCopy, overwrite, dstExists bool,
+ uid string,
) (int, error) {
+ if srcUri.Name() != dstUri.Name() {
+ // A renamed move/copy lands at dstFolder/srcName before the rename;
+ // a different resource already occupying that path would hit the
+ // UNIQUE(parent, name) constraint mid-operation.
+ _, intermediateUri, err := fm.SharedAddressTranslation(ctx, dstFolderUri.Join(srcUri.Name()))
+ if err == nil && !intermediateUri.IsSame(srcUri, uid) {
+ return http.StatusPreconditionFailed, errDestinationExists
+ } else if err != nil && !ent.IsNotFound(err) {
+ return purposeStatusCodeFromError(err), err
+ }
+ }
+
if dstExists {
if !overwrite {
return http.StatusPreconditionFailed, errDestinationExists
@@ -902,6 +967,9 @@ func performCopyMove(
}
func handleProppatch(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
+ if davWriteForbidden(user) {
+ return http.StatusForbidden, nil
+ }
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@@ -993,7 +1061,7 @@ func makePropstatResponse(href string, pstats []Propstat) *response {
}
const (
- infiniteDepth = intsets.MaxInt
+ infiniteDepth = math.MaxInt
invalidDepth = -2
)
diff --git a/pkg/webdav/webdav_test.go b/pkg/webdav/webdav_test.go
index 8c3682c6..80c9f251 100644
--- a/pkg/webdav/webdav_test.go
+++ b/pkg/webdav/webdav_test.go
@@ -6,15 +6,21 @@ import (
"net/http"
"testing"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
)
type copyMoveOperationsStub struct {
- calls []string
- deleteErr error
- moveErr error
- renameErr error
- moveIsCopy bool
+ calls []string
+ deleteErr error
+ moveErr error
+ renameErr error
+ moveIsCopy bool
+ translateURI *fs.URI
+ translateErr error
+ translateHits int
}
func (s *copyMoveOperationsStub) Delete(context.Context, []*fs.URI, ...fs.Option) error {
@@ -33,6 +39,11 @@ func (s *copyMoveOperationsStub) Rename(context.Context, *fs.URI, string) (fs.Fi
return nil, s.renameErr
}
+func (s *copyMoveOperationsStub) SharedAddressTranslation(context.Context, *fs.URI, ...fs.Option) (fs.File, *fs.URI, error) {
+ s.translateHits++
+ return nil, s.translateURI, s.translateErr
+}
+
func TestParseOverwrite(t *testing.T) {
tests := []struct {
value string
@@ -64,8 +75,8 @@ func TestPerformCopyMove(t *testing.T) {
dstFolder := dst.DirUri()
t.Run("overwrite disabled", func(t *testing.T) {
- operations := ©MoveOperationsStub{}
- status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, false, true)
+ operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}}
+ status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, false, true, "u1")
if status != http.StatusPreconditionFailed || !errors.Is(err, errDestinationExists) {
t.Fatalf("unexpected result: status=%d err=%v", status, err)
}
@@ -75,8 +86,8 @@ func TestPerformCopyMove(t *testing.T) {
})
t.Run("overwrite existing move", func(t *testing.T) {
- operations := ©MoveOperationsStub{}
- status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true)
+ operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}}
+ status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1")
if err != nil || status != http.StatusNoContent {
t.Fatalf("unexpected result: status=%d err=%v", status, err)
}
@@ -89,8 +100,8 @@ func TestPerformCopyMove(t *testing.T) {
})
t.Run("new copy", func(t *testing.T) {
- operations := ©MoveOperationsStub{}
- status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, true, true, false)
+ operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}}
+ status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, true, true, false, "u1")
if err != nil || status != http.StatusCreated {
t.Fatalf("unexpected result: status=%d err=%v", status, err)
}
@@ -101,6 +112,41 @@ func TestPerformCopyMove(t *testing.T) {
t.Fatal("copy was executed as move")
}
})
+
+ t.Run("intermediate src name occupied", func(t *testing.T) {
+ // dstFolder already holds a different file named src.Name(): the
+ // intermediate dstFolder/srcName state would collide mid-operation.
+ operations := ©MoveOperationsStub{translateURI: mustWebDAVTestURI(t, "cloudreve://my/elsewhere")}
+ status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1")
+ if status != http.StatusPreconditionFailed || !errors.Is(err, errDestinationExists) {
+ t.Fatalf("unexpected result: status=%d err=%v", status, err)
+ }
+ if len(operations.calls) != 0 {
+ t.Fatalf("unexpected operations: %v", operations.calls)
+ }
+ })
+
+ t.Run("intermediate is src itself", func(t *testing.T) {
+ // Same-folder rename: dstFolder/srcName resolves back to the source.
+ operations := ©MoveOperationsStub{translateURI: src}
+ status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1")
+ if err != nil || status != http.StatusNoContent {
+ t.Fatalf("unexpected result: status=%d err=%v", status, err)
+ }
+ })
+
+ t.Run("same-name move skips intermediate check", func(t *testing.T) {
+ sameNameDst := mustWebDAVTestURI(t, "cloudreve://my/other/source.temp")
+ sameNameFolder := sameNameDst.DirUri()
+ operations := ©MoveOperationsStub{translateURI: mustWebDAVTestURI(t, "cloudreve://my/other/source.temp")}
+ status, err := performCopyMove(context.Background(), operations, src, sameNameDst, sameNameFolder, false, true, true, "u1")
+ if err != nil || status != http.StatusNoContent {
+ t.Fatalf("unexpected result: status=%d err=%v", status, err)
+ }
+ if operations.translateHits != 0 {
+ t.Fatalf("intermediate check ran for same-name move")
+ }
+ })
}
func mustWebDAVTestURI(t *testing.T, raw string) *fs.URI {
@@ -156,3 +202,34 @@ func TestParseContentRange(t *testing.T) {
}
}
}
+
+func TestDavWriteForbidden(t *testing.T) {
+ readOnlyAccount := &boolset.BooleanSet{}
+ boolset.Set(types.DavAccountReadOnly, true, readOnlyAccount)
+ readOnlyGroup := &boolset.BooleanSet{}
+ boolset.Set(types.GroupPermissionWebDAVReadOnly, true, readOnlyGroup)
+
+ mkUser := func(group *boolset.BooleanSet, account *boolset.BooleanSet) *ent.User {
+ u := &ent.User{}
+ if group != nil {
+ u.SetGroup(&ent.Group{Permissions: group})
+ }
+ if account != nil {
+ u.Edges.DavAccounts = []*ent.DavAccount{{Options: account}}
+ }
+ return u
+ }
+
+ if !davWriteForbidden(mkUser(nil, readOnlyAccount)) {
+ t.Fatal("read-only dav account not blocked")
+ }
+ if !davWriteForbidden(mkUser(readOnlyGroup, &boolset.BooleanSet{})) {
+ t.Fatal("read-only group not blocked")
+ }
+ if davWriteForbidden(mkUser(&boolset.BooleanSet{}, &boolset.BooleanSet{})) {
+ t.Fatal("normal user blocked")
+ }
+ if davWriteForbidden(nil) {
+ t.Fatal("nil user blocked")
+ }
+}
diff --git a/routers/controllers/admin.go b/routers/controllers/admin.go
index 37dfc5b8..852a2c83 100644
--- a/routers/controllers/admin.go
+++ b/routers/controllers/admin.go
@@ -10,8 +10,7 @@ import (
func AdminSummary(c *gin.Context) {
service := ParametersFromContext[*admin.SummaryService](c, admin.SummaryParamCtx{})
res, err := service.Summary(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -22,8 +21,7 @@ func AdminSummary(c *gin.Context) {
func AdminGetSettings(c *gin.Context) {
service := ParametersFromContext[*admin.GetSettingService](c, admin.GetSettingParamCtx{})
res, err := service.GetSetting(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -33,8 +31,7 @@ func AdminGetSettings(c *gin.Context) {
func AdminSetSettings(c *gin.Context) {
service := ParametersFromContext[*admin.SetSettingService](c, admin.SetSettingParamCtx{})
res, err := service.SetSetting(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -45,8 +42,7 @@ func AdminSetSettings(c *gin.Context) {
func AdminListGroups(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.List(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -56,8 +52,7 @@ func AdminListGroups(c *gin.Context) {
func AdminFetchWopi(c *gin.Context) {
service := ParametersFromContext[*admin.FetchWOPIDiscoveryService](c, admin.FetchWOPIDiscoveryParamCtx{})
res, err := service.Fetch(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -68,8 +63,7 @@ func AdminFetchWopi(c *gin.Context) {
func AdminTestThumbGenerator(c *gin.Context) {
service := ParametersFromContext[*admin.ThumbGeneratorTestService](c, admin.ThumbGeneratorTestParamCtx{})
res, err := service.Test(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -78,8 +72,7 @@ func AdminTestThumbGenerator(c *gin.Context) {
func AdminGetQueueMetrics(c *gin.Context) {
res, err := admin.GetQueueMetrics(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -88,8 +81,7 @@ func AdminGetQueueMetrics(c *gin.Context) {
func AdminListPolicies(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Policies(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -98,8 +90,7 @@ func AdminListPolicies(c *gin.Context) {
func AdminGetPolicy(c *gin.Context) {
service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -109,8 +100,7 @@ func AdminGetPolicy(c *gin.Context) {
func AdminSendTestMail(c *gin.Context) {
service := ParametersFromContext[*admin.TestSMTPService](c, admin.TestSMTPParamCtx{})
err := service.Test(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -119,8 +109,7 @@ func AdminSendTestMail(c *gin.Context) {
func AdminCreatePolicy(c *gin.Context) {
service := ParametersFromContext[*admin.CreateStoragePolicyService](c, admin.CreateStoragePolicyParamCtx{})
res, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -129,8 +118,7 @@ func AdminCreatePolicy(c *gin.Context) {
func AdminUpdatePolicy(c *gin.Context) {
service := ParametersFromContext[*admin.UpdateStoragePolicyService](c, admin.UpdateStoragePolicyParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -139,8 +127,7 @@ func AdminUpdatePolicy(c *gin.Context) {
func AdminListNodes(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Nodes(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -149,8 +136,7 @@ func AdminListNodes(c *gin.Context) {
func AdminGetNode(c *gin.Context) {
service := ParametersFromContext[*admin.SingleNodeService](c, admin.SingleNodeParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -164,8 +150,7 @@ func AdminClearEntityUrlCache(c *gin.Context) {
func AdminCreateStoragePolicyCors(c *gin.Context) {
service := ParametersFromContext[*admin.CreateStoragePolicyCorsService](c, admin.CreateStoragePolicyCorsParamCtx{})
err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -175,8 +160,7 @@ func AdminCreateStoragePolicyCors(c *gin.Context) {
func AdminOdOAuthURL(c *gin.Context) {
service := ParametersFromContext[*admin.GetOauthRedirectService](c, admin.GetOauthRedirectParamCtx{})
res, err := service.GetOAuth(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -190,8 +174,7 @@ func AdminGetPolicyOAuthCallbackURL(c *gin.Context) {
func AdminGetPolicyOAuthStatus(c *gin.Context) {
service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{})
res, err := service.GetOauthCredentialStatus(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -200,8 +183,7 @@ func AdminGetPolicyOAuthStatus(c *gin.Context) {
func AdminFinishOauthCallback(c *gin.Context) {
service := ParametersFromContext[*admin.FinishOauthCallbackService](c, admin.FinishOauthCallbackParamCtx{})
err := service.Finish(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -210,8 +192,7 @@ func AdminFinishOauthCallback(c *gin.Context) {
func AdminGetSharePointDriverRoot(c *gin.Context) {
service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{})
res, err := service.GetSharePointDriverRoot(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -220,8 +201,7 @@ func AdminGetSharePointDriverRoot(c *gin.Context) {
func AdminDeletePolicy(c *gin.Context) {
service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -230,8 +210,7 @@ func AdminDeletePolicy(c *gin.Context) {
func AdminGetGroup(c *gin.Context) {
service := ParametersFromContext[*admin.SingleGroupService](c, admin.SingleGroupParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -240,8 +219,7 @@ func AdminGetGroup(c *gin.Context) {
func AdminCreateGroup(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertGroupService](c, admin.UpsertGroupParamCtx{})
res, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -250,8 +228,7 @@ func AdminCreateGroup(c *gin.Context) {
func AdminUpdateGroup(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertGroupService](c, admin.UpsertGroupParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -260,8 +237,7 @@ func AdminUpdateGroup(c *gin.Context) {
func AdminListUsers(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Users(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -270,8 +246,7 @@ func AdminListUsers(c *gin.Context) {
func AdminGetUser(c *gin.Context) {
service := ParametersFromContext[*admin.SingleUserService](c, admin.SingleUserParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -280,8 +255,7 @@ func AdminGetUser(c *gin.Context) {
func AdminUpdateUser(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertUserService](c, admin.UpsertUserParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -290,8 +264,7 @@ func AdminUpdateUser(c *gin.Context) {
func AdminCreateUser(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertUserService](c, admin.UpsertUserParamCtx{})
res, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -357,8 +330,7 @@ func AdminCreateUser(c *gin.Context) {
func AdminDeleteGroup(c *gin.Context) {
service := ParametersFromContext[*admin.SingleGroupService](c, admin.SingleGroupParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -368,8 +340,7 @@ func AdminDeleteGroup(c *gin.Context) {
func AdminTestSlave(c *gin.Context) {
service := ParametersFromContext[*admin.TestNodeService](c, admin.TestNodeParamCtx{})
err := service.Test(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -379,8 +350,7 @@ func AdminTestSlave(c *gin.Context) {
func AdminTestDownloader(c *gin.Context) {
service := ParametersFromContext[*admin.TestNodeDownloaderService](c, admin.TestNodeDownloaderParamCtx{})
res, err := service.Test(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -389,8 +359,7 @@ func AdminTestDownloader(c *gin.Context) {
func AdminCreateNode(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertNodeService](c, admin.UpsertNodeParamCtx{})
res, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -399,8 +368,7 @@ func AdminCreateNode(c *gin.Context) {
func AdminUpdateNode(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertNodeService](c, admin.UpsertNodeParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -409,8 +377,7 @@ func AdminUpdateNode(c *gin.Context) {
func AdminDeleteNode(c *gin.Context) {
service := ParametersFromContext[*admin.SingleNodeService](c, admin.SingleNodeParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -420,8 +387,17 @@ func AdminDeleteNode(c *gin.Context) {
func AdminDeleteUser(c *gin.Context) {
service := ParametersFromContext[*admin.BatchUserService](c, admin.BatchUserParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
+ return
+ }
+ c.JSON(200, serializer.Response{})
+}
+
+// AdminBatchUpdateUser 批量更新用户
+func AdminBatchUpdateUser(c *gin.Context) {
+ service := ParametersFromContext[*admin.BatchUserUpdateService](c, admin.BatchUserUpdateParamCtx{})
+ err := service.Update(c)
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -430,8 +406,7 @@ func AdminDeleteUser(c *gin.Context) {
func AdminListFiles(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Files(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -440,8 +415,7 @@ func AdminListFiles(c *gin.Context) {
func AdminGetFile(c *gin.Context) {
service := ParametersFromContext[*admin.SingleFileService](c, admin.SingleFileParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -450,8 +424,7 @@ func AdminGetFile(c *gin.Context) {
func AdminUpdateFile(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertFileService](c, admin.UpsertFileParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -460,8 +433,7 @@ func AdminUpdateFile(c *gin.Context) {
func AdminGetFileUrl(c *gin.Context) {
service := ParametersFromContext[*admin.SingleFileService](c, admin.SingleFileParamCtx{})
res, err := service.Url(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -470,8 +442,7 @@ func AdminGetFileUrl(c *gin.Context) {
func AdminBatchDeleteFile(c *gin.Context) {
service := ParametersFromContext[*admin.BatchFileService](c, admin.BatchFileParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -481,8 +452,7 @@ func AdminBatchDeleteFile(c *gin.Context) {
func AdminListEntities(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Entities(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -492,8 +462,7 @@ func AdminListEntities(c *gin.Context) {
func AdminGetEntity(c *gin.Context) {
service := ParametersFromContext[*admin.SingleEntityService](c, admin.SingleEntityParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -502,8 +471,7 @@ func AdminGetEntity(c *gin.Context) {
func AdminGetEntityUrl(c *gin.Context) {
service := ParametersFromContext[*admin.SingleEntityService](c, admin.SingleEntityParamCtx{})
res, err := service.Url(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -512,8 +480,7 @@ func AdminGetEntityUrl(c *gin.Context) {
func AdminBatchDeleteEntity(c *gin.Context) {
service := ParametersFromContext[*admin.BatchEntityService](c, admin.BatchEntityParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
}
@@ -521,8 +488,7 @@ func AdminBatchDeleteEntity(c *gin.Context) {
func AdminCleanupTask(c *gin.Context) {
service := ParametersFromContext[*admin.CleanupTaskService](c, admin.CleanupTaskParameterCtx{})
err := service.CleanupTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -532,8 +498,7 @@ func AdminCleanupTask(c *gin.Context) {
func AdminListTasks(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Tasks(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -542,8 +507,7 @@ func AdminListTasks(c *gin.Context) {
func AdminGetTask(c *gin.Context) {
service := ParametersFromContext[*admin.SingleTaskService](c, admin.SingleTaskParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -552,8 +516,7 @@ func AdminGetTask(c *gin.Context) {
func AdminBatchDeleteTask(c *gin.Context) {
service := ParametersFromContext[*admin.BatchTaskService](c, admin.BatchTaskParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -562,8 +525,7 @@ func AdminBatchDeleteTask(c *gin.Context) {
func AdminListShares(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Shares(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -572,8 +534,7 @@ func AdminListShares(c *gin.Context) {
func AdminGetShare(c *gin.Context) {
service := ParametersFromContext[*admin.SingleShareService](c, admin.SingleShareParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -582,8 +543,7 @@ func AdminGetShare(c *gin.Context) {
func AdminBatchDeleteShare(c *gin.Context) {
service := ParametersFromContext[*admin.BatchShareService](c, admin.BatchShareParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -592,8 +552,7 @@ func AdminBatchDeleteShare(c *gin.Context) {
func AdminCalibrateStorage(c *gin.Context) {
service := ParametersFromContext[*admin.SingleUserService](c, admin.SingleUserParamCtx{})
res, err := service.CalibrateStorage(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -603,8 +562,7 @@ func AdminCalibrateStorage(c *gin.Context) {
func AdminListOAuthClients(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.OAuthClients(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -614,8 +572,7 @@ func AdminListOAuthClients(c *gin.Context) {
func AdminGetOAuthClient(c *gin.Context) {
service := ParametersFromContext[*admin.SingleOAuthClientService](c, admin.SingleOAuthClientParamCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -625,8 +582,7 @@ func AdminGetOAuthClient(c *gin.Context) {
func AdminCreateOAuthClient(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertOAuthClientService](c, admin.UpsertOAuthClientParamCtx{})
res, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -636,8 +592,7 @@ func AdminCreateOAuthClient(c *gin.Context) {
func AdminUpdateOAuthClient(c *gin.Context) {
service := ParametersFromContext[*admin.UpsertOAuthClientService](c, admin.UpsertOAuthClientParamCtx{})
res, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -647,8 +602,7 @@ func AdminUpdateOAuthClient(c *gin.Context) {
func AdminDeleteOAuthClient(c *gin.Context) {
service := ParametersFromContext[*admin.SingleOAuthClientService](c, admin.SingleOAuthClientParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -658,8 +612,7 @@ func AdminDeleteOAuthClient(c *gin.Context) {
func AdminBatchDeleteOAuthClient(c *gin.Context) {
service := ParametersFromContext[*admin.BatchOAuthClientService](c, admin.BatchOAuthClientParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
diff --git a/routers/controllers/callback.go b/routers/controllers/callback.go
index ef00b796..ef17a86d 100644
--- a/routers/controllers/callback.go
+++ b/routers/controllers/callback.go
@@ -71,7 +71,7 @@ func OSSCallbackValidate(c *gin.Context) {
c.Next()
} else {
- c.JSON(401, ErrorResponse(err))
+ c.JSON(401, ErrorResponse(c, err))
c.Abort()
}
}
@@ -104,7 +104,7 @@ func OneDriveOAuth(c *gin.Context) {
// redirect.RawQuery = queries.Encode()
// c.Redirect(303, redirect.String())
//} else {
- // c.JSON(200, ErrorResponse(err))
+ // c.JSON(200, ErrorResponse(c, err))
//}
}
@@ -122,6 +122,6 @@ func GoogleDriveOAuth(c *gin.Context) {
// redirect.RawQuery = queries.Encode()
// c.Redirect(303, redirect.String())
//} else {
- // c.JSON(200, ErrorResponse(err))
+ // c.JSON(200, ErrorResponse(c, err))
//}
}
diff --git a/routers/controllers/file.go b/routers/controllers/file.go
index a956c6b2..d1d2c7f4 100644
--- a/routers/controllers/file.go
+++ b/routers/controllers/file.go
@@ -10,9 +10,7 @@ import (
func DownloadArchive(c *gin.Context) {
service := ParametersFromContext[*explorer.ArchiveService](c, explorer.ArchiveParamCtx{})
err := service.DownloadArchived(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
}
@@ -21,9 +19,7 @@ func DownloadArchive(c *gin.Context) {
func CreateArchive(c *gin.Context) {
service := ParametersFromContext[*explorer.ArchiveWorkflowService](c, explorer.CreateArchiveParamCtx{})
resp, err := service.CreateCompressTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -38,9 +34,7 @@ func CreateArchive(c *gin.Context) {
func ImportFiles(c *gin.Context) {
service := ParametersFromContext[*explorer.ImportWorkflowService](c, explorer.CreateImportParamCtx{})
resp, err := service.CreateImportTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -55,9 +49,7 @@ func ImportFiles(c *gin.Context) {
func CreateRemoteDownload(c *gin.Context) {
service := ParametersFromContext[*explorer.DownloadWorkflowService](c, explorer.CreateDownloadParamCtx{})
resp, err := service.CreateDownloadTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -72,9 +64,20 @@ func CreateRemoteDownload(c *gin.Context) {
func RebuildFTSIndex(c *gin.Context) {
service := ParametersFromContext[*explorer.RebuildFTSIndexWorkflowService](c, explorer.CreateRebuildFTSIndexParamCtx{})
resp, err := service.CreateRebuildFTSIndexTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
+ return
+ }
+
+ c.JSON(200, serializer.Response{
+ Data: resp,
+ })
+}
+
+// BlobAudit creates a blob-vs-database audit task
+func BlobAudit(c *gin.Context) {
+ service := ParametersFromContext[*explorer.BlobAuditWorkflowService](c, explorer.BlobAuditParamCtx{})
+ resp, err := service.CreateBlobAuditTask(c)
+ if respondErr(c, err) {
return
}
@@ -87,9 +90,7 @@ func RebuildFTSIndex(c *gin.Context) {
func ExtractArchive(c *gin.Context) {
service := ParametersFromContext[*explorer.ArchiveWorkflowService](c, explorer.CreateArchiveParamCtx{})
resp, err := service.CreateExtractTask(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -135,8 +136,7 @@ func GetSource(c *gin.Context) {
func DeleteDirectLink(c *gin.Context) {
err := explorer.DeleteDirectLink(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -147,9 +147,7 @@ func DeleteDirectLink(c *gin.Context) {
func Thumb(c *gin.Context) {
service := ParametersFromContext[*explorer.FileThumbService](c, explorer.FileThumbParameterCtx{})
res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -160,9 +158,7 @@ func Thumb(c *gin.Context) {
func FileURL(c *gin.Context) {
service := ParametersFromContext[*explorer.FileURLService](c, explorer.FileURLParameterCtx{})
resp, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -177,9 +173,7 @@ func FileURL(c *gin.Context) {
func ServeEntity(c *gin.Context) {
service := ParametersFromContext[*explorer.EntityDownloadService](c, explorer.EntityDownloadParameterCtx{})
err := service.Serve(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
}
@@ -188,9 +182,7 @@ func ServeEntity(c *gin.Context) {
func CreateViewerSession(c *gin.Context) {
service := ParametersFromContext[*explorer.CreateViewerSessionService](c, explorer.CreateViewerSessionParamCtx{})
resp, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -233,9 +225,7 @@ func FileUpload(c *gin.Context) {
func DeleteUploadSession(c *gin.Context) {
service := ParametersFromContext[*explorer.DeleteUploadSessionService](c, explorer.DeleteUploadSessionParameterCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -246,9 +236,7 @@ func DeleteUploadSession(c *gin.Context) {
func CreateUploadSession(c *gin.Context) {
service := ParametersFromContext[*explorer.CreateUploadSessionService](c, explorer.CreateUploadSessionParameterCtx{})
resp, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -261,9 +249,7 @@ func CreateUploadSession(c *gin.Context) {
func CreateFile(c *gin.Context) {
service := ParametersFromContext[*explorer.CreateFileService](c, explorer.CreateFileParameterCtx{})
resp, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -276,9 +262,7 @@ func CreateFile(c *gin.Context) {
func RenameFile(c *gin.Context) {
service := ParametersFromContext[*explorer.RenameFileService](c, explorer.RenameFileParameterCtx{})
resp, err := service.Rename(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -290,22 +274,14 @@ func RenameFile(c *gin.Context) {
// MoveFile Moves or Copy files.
func MoveFile(c *gin.Context) {
service := ParametersFromContext[*explorer.MoveFileService](c, explorer.MoveFileParameterCtx{})
- if err := service.Move(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, service.Move(c), serializer.Response{})
}
// Delete 删除文件或目录
func Delete(c *gin.Context) {
service := ParametersFromContext[*explorer.DeleteFileService](c, explorer.DeleteFileParameterCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -316,9 +292,7 @@ func Delete(c *gin.Context) {
func Restore(c *gin.Context) {
service := ParametersFromContext[*explorer.DeleteFileService](c, explorer.DeleteFileParameterCtx{})
err := service.Restore(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -328,9 +302,7 @@ func Restore(c *gin.Context) {
// EmptyTrash hard-deletes every top-level item in the current user's trash bin.
func EmptyTrash(c *gin.Context) {
err := explorer.EmptyTrash(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -341,9 +313,7 @@ func EmptyTrash(c *gin.Context) {
func Unlock(c *gin.Context) {
service := ParametersFromContext[*explorer.UnlockFileService](c, explorer.UnlockFileParameterCtx{})
err := service.Unlock(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -354,8 +324,7 @@ func Unlock(c *gin.Context) {
func Pin(c *gin.Context) {
service := ParametersFromContext[*explorer.PinFileService](c, explorer.PinFileParameterCtx{})
err := service.PinFile(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -366,8 +335,7 @@ func Pin(c *gin.Context) {
func Unpin(c *gin.Context) {
service := ParametersFromContext[*explorer.PinFileService](c, explorer.PinFileParameterCtx{})
err := service.UnpinFile(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -378,9 +346,7 @@ func Unpin(c *gin.Context) {
func PatchMetadata(c *gin.Context) {
service := ParametersFromContext[*explorer.PatchMetadataService](c, explorer.PatchMetadataParameterCtx{})
err := service.Patch(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -391,9 +357,7 @@ func PatchMetadata(c *gin.Context) {
func GetFileInfo(c *gin.Context) {
service := ParametersFromContext[*explorer.GetFileInfoService](c, explorer.GetFileInfoParameterCtx{})
resp, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -406,9 +370,7 @@ func GetFileInfo(c *gin.Context) {
func SetCurrentVersion(c *gin.Context) {
service := ParametersFromContext[*explorer.SetCurrentVersionService](c, explorer.SetCurrentVersionParamCtx{})
err := service.Set(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -419,9 +381,7 @@ func SetCurrentVersion(c *gin.Context) {
func DeleteVersion(c *gin.Context) {
service := ParametersFromContext[*explorer.DeleteVersionService](c, explorer.DeleteVersionParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -431,9 +391,7 @@ func DeleteVersion(c *gin.Context) {
func PatchView(c *gin.Context) {
service := ParametersFromContext[*explorer.PatchViewService](c, explorer.PatchViewParameterCtx{})
err := service.Patch(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -443,9 +401,7 @@ func PatchView(c *gin.Context) {
func ListArchiveFiles(c *gin.Context) {
service := ParametersFromContext[*explorer.ArchiveListFilesService](c, explorer.ArchiveListFilesParamCtx{})
resp, err := service.List(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -457,9 +413,7 @@ func ListArchiveFiles(c *gin.Context) {
func HandleExplorerEventsPush(c *gin.Context) {
service := ParametersFromContext[*explorer.ExplorerEventService](c, explorer.ExplorerEventParamCtx{})
err := service.HandleExplorerEventsPush(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
}
@@ -467,9 +421,7 @@ func HandleExplorerEventsPush(c *gin.Context) {
func FulltextSearch(c *gin.Context) {
service := ParametersFromContext[*explorer.FulltextSearchService](c, explorer.FulltextSearchParamCtx{})
resp, err := service.Search(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/main.go b/routers/controllers/main.go
index 48ac68f2..e5feb80e 100644
--- a/routers/controllers/main.go
+++ b/routers/controllers/main.go
@@ -9,6 +9,25 @@ import (
"github.com/go-playground/validator/v10"
)
+// respondErr writes the standard serialized error body and aborts the chain
+// when err is non-nil. Handlers use it as `if respondErr(c, err) { return }`.
+func respondErr(c *gin.Context, err error) bool {
+ if err == nil {
+ return false
+ }
+ c.JSON(200, serializer.Err(c, err))
+ c.Abort()
+ return true
+}
+
+// respond writes err as an error body, or ok as the success body.
+func respond(c *gin.Context, err error, ok any) {
+ if respondErr(c, err) {
+ return
+ }
+ c.JSON(200, ok)
+}
+
// ParamErrorMsg 根据Validator返回的错误信息给出错误提示
func ParamErrorMsg(filed string, tag string) string {
// 未通过验证的表单域与中文对应
@@ -40,11 +59,12 @@ func ParamErrorMsg(filed string, tag string) string {
}
// ErrorResponse 返回错误消息
-func ErrorResponse(err error) serializer.Response {
+func ErrorResponse(c *gin.Context, err error) serializer.Response {
// 处理 Validator 产生的错误
if ve, ok := err.(validator.ValidationErrors); ok {
for _, e := range ve {
- return serializer.ParamErrDeprecated(
+ return serializer.ParamErr(
+ c,
ParamErrorMsg(e.Field(), e.Tag()),
err,
)
@@ -52,10 +72,10 @@ func ErrorResponse(err error) serializer.Response {
}
if _, ok := err.(*json.UnmarshalTypeError); ok {
- return serializer.ParamErrDeprecated("JSON marshall error", err)
+ return serializer.ParamErr(c, "JSON marshall error", err)
}
- return serializer.ParamErrDeprecated("Parameter error", err)
+ return serializer.ParamErr(c, "Parameter error", err)
}
// FromJSON Parse and validate JSON from request body
@@ -66,7 +86,7 @@ func FromJSON[T any](ctxKey any) gin.HandlerFunc {
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service))
c.Next()
} else {
- c.JSON(200, ErrorResponse(err))
+ c.JSON(200, ErrorResponse(c, err))
c.Abort()
}
}
@@ -80,7 +100,7 @@ func FromQuery[T any](ctxKey any) gin.HandlerFunc {
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service))
c.Next()
} else {
- c.JSON(200, ErrorResponse(err))
+ c.JSON(200, ErrorResponse(c, err))
c.Abort()
}
}
@@ -93,7 +113,7 @@ func FromForm[T any](ctxKey any) gin.HandlerFunc {
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service))
c.Next()
} else {
- c.JSON(200, ErrorResponse(err))
+ c.JSON(200, ErrorResponse(c, err))
c.Abort()
}
}
@@ -107,7 +127,7 @@ func FromUri[T any](ctxKey any) gin.HandlerFunc {
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service))
c.Next()
} else {
- c.JSON(200, ErrorResponse(err))
+ c.JSON(200, ErrorResponse(c, err))
c.Abort()
}
}
diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go
index bc196af2..621d90f3 100644
--- a/routers/controllers/oauth.go
+++ b/routers/controllers/oauth.go
@@ -26,9 +26,7 @@ func OpenIDJWKS(c *gin.Context) {
func GetAppRegistration(c *gin.Context) {
service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{})
app, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -37,10 +35,8 @@ func GetAppRegistration(c *gin.Context) {
func GrantAppConsent(c *gin.Context) {
service := ParametersFromContext[*oauth.GrantService](c, oauth.GrantParamCtx{})
- res, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ res, err := service.Issue(c)
+ if respondErr(c, err) {
return
}
@@ -75,9 +71,7 @@ func ExchangeToken(c *gin.Context) {
func OpenIDUserInfo(c *gin.Context) {
service := ParametersFromContext[*oauth.UserInfoService](c, oauth.UserInfoParamCtx{})
res, err := service.GetUserInfo(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -87,9 +81,7 @@ func OpenIDUserInfo(c *gin.Context) {
func DeleteOAuthGrant(c *gin.Context) {
service := ParametersFromContext[*oauth.DeleteOAuthGrantService](c, oauth.DeleteOAuthGrantParamCtx{})
err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/respond_test.go b/routers/controllers/respond_test.go
new file mode 100644
index 00000000..235a1bfb
--- /dev/null
+++ b/routers/controllers/respond_test.go
@@ -0,0 +1,49 @@
+package controllers
+
+import (
+ "errors"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRespondErr(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ a := assert.New(t)
+
+ // nil err: no write, no abort, returns false.
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ a.False(respondErr(c, nil))
+ a.False(c.IsAborted())
+ a.Equal(200, w.Code)
+ a.Empty(w.Body.String())
+
+ // non-nil err: error body written, chain aborted, returns true.
+ w = httptest.NewRecorder()
+ c, _ = gin.CreateTestContext(w)
+ a.True(respondErr(c, errors.New("boom")))
+ a.True(c.IsAborted())
+ a.Contains(w.Body.String(), "boom")
+}
+
+func TestRespond(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ a := assert.New(t)
+
+ // error path writes the error body, not the success body.
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ respond(c, errors.New("nope"), serializer.Response{})
+ a.Contains(w.Body.String(), "nope")
+
+ // success path writes the success body.
+ w = httptest.NewRecorder()
+ c, _ = gin.CreateTestContext(w)
+ respond(c, nil, serializer.Response{})
+ a.Equal(200, w.Code)
+ a.NotContains(w.Body.String(), "nope")
+}
diff --git a/routers/controllers/share.go b/routers/controllers/share.go
index 9899d98b..479d0eb9 100644
--- a/routers/controllers/share.go
+++ b/routers/controllers/share.go
@@ -12,8 +12,7 @@ import (
func CreateShare(c *gin.Context) {
service := ParametersFromContext[*share.ShareCreateService](c, share.ShareCreateParamCtx{})
uri, err := service.Upsert(c, 0)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -24,8 +23,7 @@ func CreateShare(c *gin.Context) {
func EditShare(c *gin.Context) {
service := ParametersFromContext[*share.ShareCreateService](c, share.ShareCreateParamCtx{})
uri, err := service.Upsert(c, hashid.FromContext(c))
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -36,8 +34,7 @@ func EditShare(c *gin.Context) {
func GetShare(c *gin.Context) {
service := ParametersFromContext[*share.ShareInfoService](c, share.ShareInfoParamCtx{})
info, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -48,9 +45,7 @@ func GetShare(c *gin.Context) {
func ListShare(c *gin.Context) {
service := ParametersFromContext[*share.ListShareService](c, share.ListShareParamCtx{})
resp, err := service.List(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -64,8 +59,7 @@ func ListShare(c *gin.Context) {
// DeleteShare 删除分享
func DeleteShare(c *gin.Context) {
err := share.DeleteShare(c, hashid.FromContext(c))
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/site.go b/routers/controllers/site.go
index 38b803df..bb1f7b56 100644
--- a/routers/controllers/site.go
+++ b/routers/controllers/site.go
@@ -13,9 +13,7 @@ func SiteConfig(c *gin.Context) {
service := ParametersFromContext[*basic.GetSettingService](c, basic.GetSettingParamCtx{})
resp, err := service.GetSiteConfig(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/slave.go b/routers/controllers/slave.go
index 82133b3b..3bdf710e 100644
--- a/routers/controllers/slave.go
+++ b/routers/controllers/slave.go
@@ -33,25 +33,13 @@ func SlaveUpload(c *gin.Context) {
// SlaveGetUploadSession 从机创建上传会话
func SlaveGetUploadSession(c *gin.Context) {
service := ParametersFromContext[*explorer.SlaveCreateUploadSessionService](c, explorer.SlaveCreateUploadSessionParamCtx{})
- if err := service.Create(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, service.Create(c), serializer.Response{})
}
// SlaveDeleteUploadSession 从机删除上传会话
func SlaveDeleteUploadSession(c *gin.Context) {
service := ParametersFromContext[*explorer.SlaveDeleteUploadSessionService](c, explorer.SlaveDeleteUploadSessionParamCtx{})
- if err := service.Delete(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, service.Delete(c), serializer.Response{})
}
// SlaveServeEntity download entity content
@@ -69,9 +57,7 @@ func SlaveServeEntity(c *gin.Context) {
func SlaveMeta(c *gin.Context) {
service := ParametersFromContext[*explorer.SlaveMetaService](c, explorer.SlaveMetaParamCtx{})
res, err := service.MediaMeta(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -82,9 +68,7 @@ func SlaveMeta(c *gin.Context) {
func SlaveThumb(c *gin.Context) {
service := ParametersFromContext[*explorer.SlaveThumbService](c, explorer.SlaveThumbParamCtx{})
err := service.Thumb(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
}
@@ -107,22 +91,14 @@ func SlaveDelete(c *gin.Context) {
// SlavePing 从机测试
func SlavePing(c *gin.Context) {
service := ParametersFromContext[*admin.SlavePingService](c, admin.SlavePingParameterCtx{})
- if err := service.Test(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, service.Test(c), serializer.Response{})
}
// SlaveList 从机列出文件
func SlaveList(c *gin.Context) {
service := ParametersFromContext[*explorer.SlaveListService](c, explorer.SlaveListParamCtx{})
objects, err := service.List(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -134,9 +110,7 @@ func SlaveDownloadTaskCreate(c *gin.Context) {
service := ParametersFromContext[*slave.CreateSlaveDownload](c, node.CreateSlaveDownloadTaskParamCtx{})
d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader)
handle, err := d.CreateTask(c, service.Url, service.Options)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -168,9 +142,7 @@ func SlaveCancelDownloadTask(c *gin.Context) {
service := ParametersFromContext[*slave.CancelSlaveDownload](c, node.CancelSlaveDownloadTaskParamCtx{})
d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader)
err := d.Cancel(c, service.Handle)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -182,9 +154,7 @@ func SlaveSelectFilesToDownload(c *gin.Context) {
service := ParametersFromContext[*slave.SetSlaveFilesToDownload](c, node.SelectSlaveDownloadFilesParamCtx{})
d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader)
err := d.SetFilesToDownload(c, service.Handle, service.Args...)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -195,9 +165,7 @@ func SlaveSelectFilesToDownload(c *gin.Context) {
func SlaveTestDownloader(c *gin.Context) {
d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader)
res, err := d.Test(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -208,9 +176,7 @@ func SlaveTestDownloader(c *gin.Context) {
func SlaveGetCredential(c *gin.Context) {
service := ParametersFromContext[*node.OauthCredentialService](c, node.OauthCredentialParamCtx{})
cred, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -221,22 +187,18 @@ func SlaveGetCredential(c *gin.Context) {
func SlaveCreateTask(c *gin.Context) {
service := ParametersFromContext[*cluster.CreateSlaveTask](c, node.CreateSlaveTaskParamCtx{})
taskId, err := node.CreateTaskInSlave(service, c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.NewResponseWithGobData(c, taskId))
}
-// SlaveCreateTask creates tasks and register it in registry
+// SlaveGetTask returns the slave node's task info
func SlaveGetTask(c *gin.Context) {
service := ParametersFromContext[*node.GetSlaveTaskService](c, node.GetSlaveTaskParamCtx{})
task, err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -245,21 +207,13 @@ func SlaveGetTask(c *gin.Context) {
func SlaveCleanupFolder(c *gin.Context) {
service := ParametersFromContext[*cluster.FolderCleanup](c, node.FolderCleanupParamCtx{})
- if err := node.Cleanup(service, c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, node.Cleanup(service, c), serializer.Response{})
}
func StatelessPrepareUpload(c *gin.Context) {
service := ParametersFromContext[*fs.StatelessPrepareUploadService](c, node.StatelessPrepareUploadParamCtx{})
uploadSession, err := node.StatelessPrepareUpload(service, c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -269,9 +223,7 @@ func StatelessPrepareUpload(c *gin.Context) {
func StatelessCompleteUpload(c *gin.Context) {
service := ParametersFromContext[*fs.StatelessCompleteUploadService](c, node.StatelessCompleteUploadParamCtx{})
_, err := node.StatelessCompleteUpload(service, c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -280,22 +232,10 @@ func StatelessCompleteUpload(c *gin.Context) {
func StatelessOnUploadFailed(c *gin.Context) {
service := ParametersFromContext[*fs.StatelessOnUploadFailedService](c, node.StatelessOnUploadFailedParamCtx{})
- if err := node.StatelessOnUploadFailed(service, c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, node.StatelessOnUploadFailed(service, c), serializer.Response{})
}
func StatelessCreateFile(c *gin.Context) {
service := ParametersFromContext[*fs.StatelessCreateFileService](c, node.StatelessCreateFileParamCtx{})
- if err := node.StatelessCreateFile(service, c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, node.StatelessCreateFile(service, c), serializer.Response{})
}
diff --git a/routers/controllers/user.go b/routers/controllers/user.go
index e4363e73..65f36419 100644
--- a/routers/controllers/user.go
+++ b/routers/controllers/user.go
@@ -16,8 +16,7 @@ import (
// StartLoginAuthn 开始注册WebAuthn登录
func StartLoginAuthn(c *gin.Context) {
res, err := user.PreparePasskeyLogin(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -28,9 +27,7 @@ func StartLoginAuthn(c *gin.Context) {
func FinishLoginAuthn(c *gin.Context) {
service := ParametersFromContext[*user.FinishPasskeyLoginService](c, user.FinishPasskeyLoginParameterCtx{})
u, err := service.FinishPasskeyLogin(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -40,8 +37,7 @@ func FinishLoginAuthn(c *gin.Context) {
// StartRegAuthn 开始注册WebAuthn信息
func StartRegAuthn(c *gin.Context) {
res, err := user.PreparePasskeyRegister(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -52,8 +48,7 @@ func StartRegAuthn(c *gin.Context) {
func FinishRegAuthn(c *gin.Context) {
service := ParametersFromContext[*user.FinishPasskeyRegisterService](c, user.FinishPasskeyRegisterParameterCtx{})
res, err := service.FinishPasskeyRegister(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -64,8 +59,7 @@ func FinishRegAuthn(c *gin.Context) {
func UserDeletePasskey(c *gin.Context) {
service := ParametersFromContext[*user.DeletePasskeyService](c, user.DeletePasskeyParameterCtx{})
err := service.DeletePasskey(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
+ if respondErr(c, err) {
return
}
@@ -76,9 +70,7 @@ func UserDeletePasskey(c *gin.Context) {
func UserLoginValidation(c *gin.Context) {
service := ParametersFromContext[*user.UserLoginService](c, user.LoginParameterCtx{})
expectedUser, twoFaSession, err := service.Login(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -97,9 +89,7 @@ func UserLoginValidation(c *gin.Context) {
func UserLogin2FAValidation(c *gin.Context) {
service := ParametersFromContext[*user.OtpValidationService](c, user.OtpValidationParameterCtx{})
expectedUser, err := service.Verify2FA(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -110,9 +100,7 @@ func UserLogin2FAValidation(c *gin.Context) {
// UserIssueToken generates new token pair for user
func UserIssueToken(c *gin.Context) {
resp, err := user.IssueToken(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -125,9 +113,7 @@ func UserIssueToken(c *gin.Context) {
func UserRefreshToken(c *gin.Context) {
service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{})
resp, err := service.Refresh(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -145,9 +131,7 @@ func UserRegister(c *gin.Context) {
// UserSendReset 发送密码重设邮件
func UserSendReset(c *gin.Context) {
service := ParametersFromContext[*user.UserResetEmailService](c, user.UserResetEmailParameterCtx{})
- if err := service.Reset(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if err := service.Reset(c); respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
@@ -157,9 +141,7 @@ func UserSendReset(c *gin.Context) {
func UserReset(c *gin.Context) {
service := ParametersFromContext[*user.UserResetService](c, user.UserResetParameterCtx{})
res, err := service.Reset(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{Data: res})
@@ -170,13 +152,23 @@ func UserActivate(c *gin.Context) {
c.JSON(200, user.ActivateUser(c))
}
+// UserRequestEmailChange starts the self-service email change flow.
+func UserRequestEmailChange(c *gin.Context) {
+ service := ParametersFromContext[*user.RequestEmailChangeService](c, user.RequestEmailChangeParamCtx{})
+ respond(c, service.Request(c), serializer.Response{})
+}
+
+// UserActivateEmailChange applies the pending email change from the signed
+// confirmation link.
+func UserActivateEmailChange(c *gin.Context) {
+ respond(c, user.ActivateEmailChange(c), serializer.Response{})
+}
+
// UserSignOut 用户退出登录
func UserSignOut(c *gin.Context) {
service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{})
res, err := service.Delete(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -196,9 +188,7 @@ func UserMe(c *gin.Context) {
// UserGet 获取用户信息
func UserGet(c *gin.Context) {
u, err := user.GetUser(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -215,9 +205,7 @@ func UserGet(c *gin.Context) {
// UserStorage 获取用户的存储信息
func UserStorage(c *gin.Context) {
res, err := user.GetUserCapacity(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -229,9 +217,7 @@ func UserStorage(c *gin.Context) {
// UserSetting 获取用户设定
func UserSetting(c *gin.Context) {
res, err := user.GetUserSettings(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -242,21 +228,14 @@ func UserSetting(c *gin.Context) {
// UploadAvatar 从文件上传头像
func UploadAvatar(c *gin.Context) {
- if err := user.UpdateUserAvatar(c); err != nil {
- c.JSON(200, serializer.Err(c, err))
- return
- }
-
- c.JSON(200, serializer.Response{})
+ respond(c, user.UpdateUserAvatar(c), serializer.Response{})
}
// GetUserAvatar 获取用户头像
func GetUserAvatar(c *gin.Context) {
service := ParametersFromContext[*user.GetAvatarService](c, user.GetAvatarServiceParamsCtx{})
err := service.Get(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
}
@@ -265,9 +244,7 @@ func GetUserAvatar(c *gin.Context) {
func UpdateOption(c *gin.Context) {
service := ParametersFromContext[*user.PatchUserSetting](c, user.PatchUserSettingParamsCtx{})
err := service.Patch(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -320,9 +297,7 @@ func UpdateOption(c *gin.Context) {
// UserInit2FA 初始化二步验证
func UserInit2FA(c *gin.Context) {
secret, err := user.Init2FA(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -346,9 +321,7 @@ func UserPerformCopySession(c *gin.Context) {
func UserPrepareLogin(c *gin.Context) {
service := ParametersFromContext[*user.PrepareLoginService](c, user.PrepareLoginParameterCtx{})
res, err := service.Prepare(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -372,9 +345,7 @@ func UserSSOCallback(c *gin.Context) {
func UserSSOExchange(c *gin.Context) {
service := ParametersFromContext[*user.SSOExchangeService](c, user.SSOExchangeParameterCtx{})
res, err := service.SSOExchange(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -385,9 +356,7 @@ func UserSSOExchange(c *gin.Context) {
func UserSearch(c *gin.Context) {
service := ParametersFromContext[*user.SearchUserService](c, user.SearchUserParamCtx{})
u, err := service.Search(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -403,9 +372,7 @@ func UserSearch(c *gin.Context) {
func ListPublicShare(c *gin.Context) {
service := ParametersFromContext[*share.ListShareService](c, share.ListShareParamCtx{})
resp, err := service.ListInUserProfile(c, hashid.FromContext(c))
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/webdav.go b/routers/controllers/webdav.go
index 3b3033ab..9d2569f7 100644
--- a/routers/controllers/webdav.go
+++ b/routers/controllers/webdav.go
@@ -10,9 +10,7 @@ import (
func ListDavAccounts(c *gin.Context) {
service := ParametersFromContext[*setting.ListDavAccountsService](c, setting.ListDavAccountParamCtx{})
resp, err := service.List(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -27,9 +25,7 @@ func ListDavAccounts(c *gin.Context) {
func CreateDAVAccounts(c *gin.Context) {
service := ParametersFromContext[*setting.CreateDavAccountService](c, setting.CreateDavAccountParamCtx{})
resp, err := service.Create(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -42,9 +38,7 @@ func CreateDAVAccounts(c *gin.Context) {
func UpdateDAVAccounts(c *gin.Context) {
service := ParametersFromContext[*setting.CreateDavAccountService](c, setting.CreateDavAccountParamCtx{})
resp, err := service.Update(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -56,9 +50,7 @@ func UpdateDAVAccounts(c *gin.Context) {
// DeleteDAVAccounts deletes WebDAV accounts.
func DeleteDAVAccounts(c *gin.Context) {
err := setting.DeleteDavAccount(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
diff --git a/routers/controllers/workflow.go b/routers/controllers/workflow.go
index f2a8a3a2..12c6afe2 100644
--- a/routers/controllers/workflow.go
+++ b/routers/controllers/workflow.go
@@ -11,9 +11,7 @@ import (
func ListTasks(c *gin.Context) {
service := ParametersFromContext[*explorer.ListTaskService](c, explorer.ListTaskParamCtx{})
resp, err := service.ListTasks(c)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -27,9 +25,7 @@ func ListTasks(c *gin.Context) {
func GetTaskPhaseProgress(c *gin.Context) {
taskId := hashid.FromContext(c)
resp, err := explorer.TaskPhaseProgress(c, taskId)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -46,9 +42,7 @@ func SetDownloadTaskTarget(c *gin.Context) {
taskId := hashid.FromContext(c)
service := ParametersFromContext[*explorer.SetDownloadFilesService](c, explorer.SetDownloadFilesParamCtx{})
err := service.SetDownloadFiles(c, taskId)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
return
}
@@ -58,9 +52,40 @@ func SetDownloadTaskTarget(c *gin.Context) {
func CancelDownloadTask(c *gin.Context) {
taskId := hashid.FromContext(c)
err := explorer.CancelDownloadTask(c, taskId)
- if err != nil {
- c.JSON(200, serializer.Err(c, err))
- c.Abort()
+ if respondErr(c, err) {
+ return
+ }
+
+ c.JSON(200, serializer.Response{})
+}
+
+// CancelTask terminates a queued or suspending task.
+func CancelTask(c *gin.Context) {
+ taskId := hashid.FromContext(c)
+ err := explorer.CancelTask(c, taskId)
+ if respondErr(c, err) {
+ return
+ }
+
+ c.JSON(200, serializer.Response{})
+}
+
+// DeleteTask hides a finished task record from the owner's list.
+func DeleteTask(c *gin.Context) {
+ taskId := hashid.FromContext(c)
+ err := explorer.DeleteTask(c, taskId)
+ if respondErr(c, err) {
+ return
+ }
+
+ c.JSON(200, serializer.Response{})
+}
+
+// RetryTask re-queues a failed task with its original args.
+func RetryTask(c *gin.Context) {
+ taskId := hashid.FromContext(c)
+ err := explorer.RetryTask(c, taskId)
+ if respondErr(c, err) {
return
}
diff --git a/routers/router.go b/routers/router.go
index 0b124e06..f573268b 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -431,6 +431,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.HashID(hashid.UserID),
controllers.UserActivate,
)
+ // 邮箱更换确认 Done
+ user.GET("activate_email/:id",
+ middleware.SignRequired(dep.GeneralAuth()),
+ middleware.HashID(hashid.UserID),
+ controllers.UserActivateEmailChange,
+ )
// 获取用户头像
user.GET("avatar/:id",
middleware.HashID(hashid.UserID),
@@ -590,6 +596,24 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.HashID(hashid.TaskID),
controllers.GetTaskPhaseProgress,
)
+ // Retry a failed task with its original args
+ wf.POST(":id/retry",
+ middleware.RequiredScopes(types.ScopeWorkflowWrite),
+ middleware.HashID(hashid.TaskID),
+ controllers.RetryTask,
+ )
+ // Cancel a queued or suspending task
+ wf.POST(":id/cancel",
+ middleware.RequiredScopes(types.ScopeWorkflowWrite),
+ middleware.HashID(hashid.TaskID),
+ controllers.CancelTask,
+ )
+ // Delete (hide) a finished task record
+ wf.DELETE(":id",
+ middleware.RequiredScopes(types.ScopeWorkflowWrite),
+ middleware.HashID(hashid.TaskID),
+ controllers.DeleteTask,
+ )
// Create task to create an archive file
wf.POST("archive",
middleware.RequiredScopes(types.ScopeWorkflowWrite),
@@ -796,6 +820,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[explorer.RebuildFTSIndexWorkflowService](explorer.CreateRebuildFTSIndexParamCtx{}),
controllers.RebuildFTSIndex,
)
+ // Create task to audit physical blobs against the entities table
+ wf.POST("blobAudit",
+ middleware.IsAdmin(),
+ middleware.RequiredScopes(types.ScopeWorkflowWrite, types.ScopeAdminWrite),
+ controllers.FromJSON[explorer.BlobAuditWorkflowService](explorer.BlobAuditParamCtx{}),
+ controllers.BlobAudit,
+ )
// 取得文件外链
source := file.Group("source")
@@ -899,7 +930,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
auth.Use(middleware.LoginRequired())
{
// 管理
- admin := auth.Group("admin", middleware.IsAdmin())
+ admin := auth.Group("admin", middleware.IsAdminOrDelegated())
admin.Use(middleware.RequiredScopes(types.ScopeAdminRead))
{
admin.GET("summary",
@@ -907,7 +938,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.AdminSummary,
)
- settings := admin.Group("settings")
+ settings := admin.Group("settings", middleware.AdminSection(types.GroupPermissionAdminSettings))
{
// Get settings
settings.POST("",
@@ -923,7 +954,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
}
// 用户组管理
- group := admin.Group("group")
+ group := admin.Group("group", middleware.AdminSection(types.GroupPermissionAdminGroups))
{
// 列出用户组
group.POST("",
@@ -955,7 +986,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- tool := admin.Group("tool")
+ tool := admin.Group("tool", middleware.AdminSection(types.GroupPermissionAdminSettings))
{
tool.GET("wopi",
middleware.RequiredScopes(types.ScopeAdminWrite),
@@ -977,7 +1008,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- queue := admin.Group("queue")
+ queue := admin.Group("queue", middleware.AdminSection(types.GroupPermissionAdminQueue))
{
queue.GET("metrics", controllers.AdminGetQueueMetrics)
// List tasks
@@ -1009,7 +1040,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
}
// 存储策略管理
- policy := admin.Group("policy")
+ policy := admin.Group("policy", middleware.AdminSection(types.GroupPermissionAdminStorage))
{
// 列出存储策略
policy.POST("",
@@ -1075,7 +1106,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- node := admin.Group("node")
+ node := admin.Group("node", middleware.AdminSection(types.GroupPermissionAdminStorage))
{
node.POST("",
controllers.FromJSON[adminsvc.AdminListService](adminsvc.AdminListServiceParamsCtx{}),
@@ -1112,7 +1143,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- oauthClient := admin.Group("oauthClient")
+ oauthClient := admin.Group("oauthClient", middleware.AdminSection(types.GroupPermissionAdminSettings))
{
// List OAuth clients
oauthClient.POST("",
@@ -1150,7 +1181,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- user := admin.Group("user")
+ user := admin.Group("user", middleware.AdminSection(types.GroupPermissionAdminUsers))
{
// 列出用户
user.POST("",
@@ -1182,6 +1213,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}),
controllers.AdminDeleteUser,
)
+ // 批量更新用户
+ batch.POST("update",
+ middleware.RequiredScopes(types.ScopeAdminWrite),
+ controllers.FromJSON[adminsvc.BatchUserUpdateService](adminsvc.BatchUserUpdateParamCtx{}),
+ controllers.AdminBatchUpdateUser,
+ )
}
user.POST(":id/calibrate",
middleware.RequiredScopes(types.ScopeAdminWrite),
@@ -1190,7 +1227,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- file := admin.Group("file")
+ file := admin.Group("file", middleware.AdminSection(types.GroupPermissionAdminFiles))
{
// 列出文件
file.POST("",
@@ -1221,7 +1258,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- entity := admin.Group("entity")
+ entity := admin.Group("entity", middleware.AdminSection(types.GroupPermissionAdminFiles))
{
// List blobs
entity.POST("",
@@ -1246,7 +1283,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
- share := admin.Group("share")
+ share := admin.Group("share", middleware.AdminSection(types.GroupPermissionAdminShares))
{
// List shares
share.POST("",
@@ -1315,6 +1352,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
// 获得二步验证初始化信息
setting.GET("2fa", controllers.UserInit2FA)
+ // 请求更换邮箱(向新地址发送确认链接)
+ setting.POST("email",
+ middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
+ middleware.RateLimitByIP("email_change", 5, time.Hour),
+ controllers.FromJSON[usersvc.RequestEmailChangeService](usersvc.RequestEmailChangeParamCtx{}),
+ controllers.UserRequestEmailChange,
+ )
}
}
diff --git a/routers/router_test.go b/routers/router_test.go
new file mode 100644
index 00000000..a8397dc5
--- /dev/null
+++ b/routers/router_test.go
@@ -0,0 +1,59 @@
+package routers
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/pkg/auth"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+// stubSettingProvider satisfies setting.Provider for route registration;
+// per-request setting reads are not exercised here.
+type stubSettingProvider struct {
+ setting.Provider
+}
+
+// TestMasterRouteWiring verifies the master router mounts the core route
+// groups end-to-end — a renamed path or dropped middleware chain fails here.
+func TestMasterRouteWiring(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ logger := logging.NewConsoleLogger(logging.LevelError)
+ cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger)
+ require.NoError(t, err)
+
+ dep := dependency.NewDependency(
+ dependency.WithLogger(logger),
+ dependency.WithConfigProvider(cfg),
+ dependency.WithGeneralAuth(auth.HMACAuth{SecretKey: []byte("test")}),
+ dependency.WithSettingProvider(&stubSettingProvider{}),
+ )
+
+ engine := InitRouter(dep)
+
+ have := make(map[string]bool)
+ for _, r := range engine.Routes() {
+ have[r.Method+" "+r.Path] = true
+ }
+
+ expected := []string{
+ "GET /api/v4/site/ping",
+ "GET /api/v4/site/config/:section",
+ "POST /api/v4/session/token",
+ "GET /api/v4/session/prepare",
+ "POST /api/v4/user",
+ "GET /api/v4/user/activate/:id",
+ "GET /api/v4/user/info/:id",
+ "PUT /api/v4/file/upload",
+ "POST /api/v4/file/upload/:sessionId/:index",
+ "GET /f/:id/:name",
+ }
+ for _, e := range expected {
+ require.True(t, have[e], "route missing: %s", e)
+ }
+}
diff --git a/service/admin/file.go b/service/admin/file.go
index e7c690f0..4fd7ecea 100644
--- a/service/admin/file.go
+++ b/service/admin/file.go
@@ -46,13 +46,11 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response {
// // 列取存储策略中的目录
// policy, err := model.GetPolicyByID(service.ID)
// if err != nil {
- // return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", err)
// }
//
// // 创建文件系统
// fs, err := filesystem.NewAnonymousFileSystem()
// if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
@@ -60,7 +58,6 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response {
// fs.Policy = &policy
// res, err := fs.ListPhysical(c.Request.Context(), service.Path)
// if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err)
// }
//
// return serializer.Response{
@@ -73,20 +70,17 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response {
//// 查找用户
//user, err := model.GetUserByID(service.ID)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeUserNotFound, "", err)
//}
//
//// 创建文件系统
//fs, err := filesystem.NewFileSystem(&user)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
//}
//defer fs.Recycle()
//
//// 列取目录
//res, err := fs.List(c.Request.Context(), service.Path, nil)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err)
//}
//return serializer.Response{
@@ -100,7 +94,6 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response {
func (service *FileBatchService) Delete(c *gin.Context) serializer.Response {
//files, err := model.GetFilesByIDs(service.ID, 0)
//if err != nil {
- // return serializer.DBErrDeprecated("Failed to list files for deleting", err)
//}
//
//// 根据用户分组
@@ -157,6 +150,7 @@ const (
fileMetadataCondition = "file_metadata"
fileSharedCondition = "file_shared"
fileDirectLinkCondition = "file_direct_link"
+ fileDeletedCondition = "file_deleted"
)
func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) {
@@ -177,6 +171,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
metadata string
shared bool
directLink bool
+ deleted *bool
)
if service.Conditions[fileUserCondition] != "" {
@@ -205,6 +200,13 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
directLink = true
}
+ switch service.Conditions[fileDeletedCondition] {
+ case "true":
+ deleted = lo.ToPtr(true)
+ case "false":
+ deleted = lo.ToPtr(false)
+ }
+
res, err := fileClient.FlattenListFiles(ctx, &inventory.FlattenListFileParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
@@ -218,6 +220,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error
HasMetadata: metadata,
Shared: shared,
HasDirectLink: directLink,
+ Deleted: deleted,
})
if err != nil {
@@ -398,6 +401,9 @@ const (
entityUserCondition = "entity_user"
entityPolicyCondition = "entity_policy"
entityTypeCondition = "entity_type"
+ // entityRefCountCondition filters by reference count: "stale" (<=0, i.e.
+ // awaiting recycle) or "gt:N" / "lt:N" / "eq:N" comparisons.
+ entityRefCountCondition = "entity_ref_count"
)
func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) {
@@ -412,6 +418,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
policyID int
err error
entityType *types.EntityType
+ refCount *inventory.ReferenceCountFilter
)
if s.Conditions[entityUserCondition] != "" {
@@ -438,6 +445,13 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
entityType = &t
}
+ if s.Conditions[entityRefCountCondition] != "" {
+ refCount, err = inventory.ParseReferenceCountFilter(s.Conditions[entityRefCountCondition])
+ if err != nil {
+ return nil, serializer.NewError(serializer.CodeParamErr, "Invalid reference count filter", err)
+ }
+ }
+
res, err := fileClient.ListEntities(ctx, &inventory.ListEntityParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
@@ -448,6 +462,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
UserID: userID,
StoragePolicyID: policyID,
EntityType: entityType,
+ ReferenceCount: refCount,
})
if err != nil {
diff --git a/service/admin/group.go b/service/admin/group.go
index e2cddf84..3b2eddc1 100644
--- a/service/admin/group.go
+++ b/service/admin/group.go
@@ -8,6 +8,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
@@ -26,7 +27,6 @@ type GroupService struct {
func (service *GroupService) Get() serializer.Response {
//group, err := model.GetGroupByID(service.ID)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err)
//}
//
//return serializer.Response{Data: group}
@@ -39,12 +39,10 @@ func (service *GroupService) Delete() serializer.Response {
//// 查找用户组
//group, err := model.GetGroupByID(service.ID)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err)
//}
//
//// 是否为系统用户组
//if group.ID <= 3 {
- // return serializer.ErrDeprecated(serializer.CodeInvalidActionOnSystemGroup, "", err)
//}
//
//// 检查是否有用户使用
@@ -53,7 +51,6 @@ func (service *GroupService) Delete() serializer.Response {
// Select("count(id)").Row()
//row.Scan(&total)
//if total > 0 {
- // return serializer.ErrDeprecated(serializer.CodeGroupUsedByUser, strconv.Itoa(total), nil)
//}
//
//model.DB.Delete(&group)
@@ -165,6 +162,12 @@ func (s *UpsertGroupService) Update(c *gin.Context) (*GetGroupResponse, error) {
return nil, serializer.NewError(serializer.CodeParamErr, "Initial admin group have to be admin", nil)
}
+ // Delegated admins cannot modify admin-capable permission bits — the
+ // target group's existing admin bits are preserved.
+ if err := s.maskAdminPermissions(c, groupClient); err != nil {
+ return nil, err
+ }
+
group, err := groupClient.Upsert(c, s.Group)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update group", err)
@@ -182,6 +185,11 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil)
}
+ // Delegated admins cannot create groups carrying admin-capable bits.
+ if err := s.maskAdminPermissions(c, groupClient); err != nil {
+ return nil, err
+ }
+
group, err := groupClient.Upsert(c, s.Group)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create group", err)
@@ -190,3 +198,34 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) {
service := &SingleGroupService{ID: group.ID}
return service.Get(c)
}
+
+// maskAdminPermissions strips admin-capable permission bits from the payload
+// when the acting user is not a full administrator, preventing delegated
+// admins from escalating privileges. For updates, the target group's existing
+// admin bits are preserved.
+func (s *UpsertGroupService) maskAdminPermissions(c *gin.Context, groupClient inventory.GroupClient) error {
+ actor := inventory.UserFromContext(c)
+ if actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil &&
+ actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ return nil
+ }
+
+ if s.Group.Permissions == nil {
+ s.Group.Permissions = &boolset.BooleanSet{}
+ }
+ if s.Group.ID > 0 {
+ existing, err := groupClient.GetByID(c, s.Group.ID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to get group", err)
+ }
+ for _, p := range types.AdminPermissionBits() {
+ boolset.Set(int(p), existing.Permissions != nil && existing.Permissions.Enabled(int(p)), s.Group.Permissions)
+ }
+ } else {
+ for _, p := range types.AdminPermissionBits() {
+ boolset.Set(int(p), false, s.Group.Permissions)
+ }
+ }
+
+ return nil
+}
diff --git a/service/admin/policy.go b/service/admin/policy.go
index 0d323622..1ef6d989 100644
--- a/service/admin/policy.go
+++ b/service/admin/policy.go
@@ -136,7 +136,6 @@ func (service *SlavePingService) Test(c *gin.Context) error {
func (service *SlaveTestService) Test() serializer.Response {
//slave, err := url.Parse(service.Server)
//if err != nil {
- // return serializer.ParamErrDeprecated("Failed to parse slave node server URL: "+err.Error(), nil)
//}
//
//controller, _ := url.Parse("/api/v3/slave/ping")
@@ -147,7 +146,6 @@ func (service *SlaveTestService) Test() serializer.Response {
//}
//bodyByte, _ := json.Marshal(body)
//
- //r := request.NewClientDeprecated()
//res, err := r.Request(
// "POST",
// slave.ResolveReference(controller).String(),
@@ -159,11 +157,9 @@ func (service *SlaveTestService) Test() serializer.Response {
// ),
//).DecodeResponse()
//if err != nil {
- // return serializer.ParamErrDeprecated("Failed to connect to slave node: "+err.Error(), nil)
//}
//
//if res.Code != 0 {
- // return serializer.ParamErrDeprecated("Successfully connected to slave node, but slave returns: "+res.Msg, nil)
//}
return serializer.Response{}
@@ -176,7 +172,6 @@ func (service *PathTestService) Test() serializer.Response {
//path = filepath.Join(path, "test.txt")
//file, err := util.CreatNestedFile(util.RelativePath(path))
//if err != nil {
- // return serializer.ParamErrDeprecated(fmt.Sprintf("Failed to create \"%s\": %s", path, err.Error()), nil)
//}
//
//file.Close()
diff --git a/service/admin/site.go b/service/admin/site.go
index c03347b2..2031f9a2 100644
--- a/service/admin/site.go
+++ b/service/admin/site.go
@@ -52,14 +52,12 @@ func (service *BatchSettingChangeService) Change() serializer.Response {
// if err := tx.Model(&model.Setting{}).Where("name = ?", setting.Key).Update("value", setting.Value).Error; err != nil {
// cache.Deletes(cacheClean, "setting_")
// tx.Rollback()
- // return serializer.ErrDeprecated(serializer.CodeUpdateSetting, "Setting "+setting.Key+" failed to update", err)
// }
//
// cacheClean = append(cacheClean, setting.Key)
//}
//
//if err := tx.Commit().Error; err != nil {
- // return serializer.DBErrDeprecated("Failed to update setting", err)
//}
//
//cache.Deletes(cacheClean, "setting_")
diff --git a/service/admin/user.go b/service/admin/user.go
index fea859fb..319eb2d6 100644
--- a/service/admin/user.go
+++ b/service/admin/user.go
@@ -3,12 +3,14 @@ package admin
import (
"context"
"strconv"
+ "strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
@@ -38,6 +40,8 @@ const (
userGroupCondition = "user_group"
userNickCondition = "user_nick"
userEmailCondition = "user_email"
+ // userIDsCondition filters by a comma-separated list of numeric user IDs.
+ userIDsCondition = "user_ids"
)
func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error) {
@@ -51,6 +55,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
var (
err error
groupID int
+ ids []int
)
if service.Conditions[userGroupCondition] != "" {
groupID, err = strconv.Atoi(service.Conditions[userGroupCondition])
@@ -59,6 +64,20 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
}
}
+ if service.Conditions[userIDsCondition] != "" {
+ for _, part := range strings.Split(service.Conditions[userIDsCondition], ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ id, err := strconv.Atoi(part)
+ if err != nil {
+ return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user ID list", err)
+ }
+ ids = append(ids, id)
+ }
+ }
+
res, err := userClient.ListUsers(ctx, &inventory.ListUserParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
@@ -70,6 +89,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
GroupID: groupID,
Nick: service.Conditions[userNickCondition],
Email: service.Conditions[userEmailCondition],
+ IDs: ids,
})
if err != nil {
@@ -149,6 +169,58 @@ type adminUserEmailValidation struct {
Email string `binding:"required,email"`
}
+// groupIsAdminCapable reports whether a permission set grants full or
+// delegated admin access.
+func groupIsAdminCapable(permissions *boolset.BooleanSet) bool {
+ if permissions == nil {
+ return false
+ }
+ for _, p := range types.AdminPermissionBits() {
+ if permissions.Enabled(int(p)) {
+ return true
+ }
+ }
+ return false
+}
+
+// actorIsFullAdmin reports whether the acting user belongs to a group with the
+// full admin permission.
+func actorIsFullAdmin(c *gin.Context) bool {
+ actor := inventory.UserFromContext(c)
+ return actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil &&
+ actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin))
+}
+
+// guardAdminGroupChange rejects delegated admins that try to move users into
+// or out of admin-capable groups. newGroupID == 0 keeps the current group.
+func guardAdminGroupChange(c *gin.Context, dep dependency.Dep, existing *ent.User, newGroupID int) error {
+ if actorIsFullAdmin(c) {
+ return nil
+ }
+
+ if existing != nil && existing.Edges.Group != nil && groupIsAdminCapable(existing.Edges.Group.Permissions) {
+ return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil)
+ }
+
+ targetGroupID := newGroupID
+ if targetGroupID == 0 && existing != nil {
+ targetGroupID = existing.GroupUsers
+ }
+ if targetGroupID == 0 {
+ return nil
+ }
+
+ target, err := dep.GroupClient().GetByID(c, targetGroupID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to get group", err)
+ }
+ if groupIsAdminCapable(target.Permissions) {
+ return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil)
+ }
+
+ return nil
+}
+
func (s *UpsertUserService) validateEmail() error {
if s.User == nil {
return serializer.NewError(serializer.CodeParamErr, "Email format error", nil)
@@ -184,6 +256,10 @@ func (s *UpsertUserService) Update(c *gin.Context) (*GetUserResponse, error) {
}
+ if err := guardAdminGroupChange(c, dep, existing, s.User.GroupUsers); err != nil {
+ return nil, err
+ }
+
newUser, err := userClient.Upsert(ctx, s.User, s.Password, s.TwoFA)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update user", err)
@@ -209,6 +285,10 @@ func (s *UpsertUserService) Create(c *gin.Context) (*GetUserResponse, error) {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil)
}
+ if err := guardAdminGroupChange(c, dep, nil, s.User.GroupUsers); err != nil {
+ return nil, err
+ }
+
user, err := userClient.Upsert(c, s.User, s.Password, s.TwoFA)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create user", err)
@@ -232,6 +312,8 @@ func (s *BatchUserService) Delete(c *gin.Context) error {
fileClient := dep.FileClient()
current := inventory.UserFromContext(c)
+ fullAdmin := actorIsFullAdmin(c)
+ groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true)
ae := serializer.NewAggregateError()
for _, id := range s.IDs {
if current.ID == id || id == 1 {
@@ -239,6 +321,19 @@ func (s *BatchUserService) Delete(c *gin.Context) error {
continue
}
+ // Delegated admins cannot delete members of admin-capable groups.
+ if !fullAdmin {
+ target, err := userClient.GetByID(groupCtx, id)
+ if err != nil {
+ ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err))
+ continue
+ }
+ if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) {
+ ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot delete an administrator", nil))
+ continue
+ }
+ }
+
fc, tx, ctx, err := inventory.WithTx(c, fileClient)
if err != nil {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err))
@@ -271,3 +366,71 @@ func (s *BatchUserService) Delete(c *gin.Context) error {
return ae.Aggregate()
}
+
+type (
+ // BatchUserUpdateService applies a status and/or group change to a set of
+ // users. At least one of the two fields must be set.
+ BatchUserUpdateService struct {
+ IDs []int `json:"ids" binding:"min=1"`
+ Status string `json:"status" binding:"omitempty,oneof=active inactive manual_banned"`
+ GroupID int `json:"group_id" binding:"omitempty,min=1"`
+ }
+ BatchUserUpdateParamCtx struct{}
+)
+
+func (s *BatchUserUpdateService) Update(c *gin.Context) error {
+ if s.Status == "" && s.GroupID == 0 {
+ return serializer.NewError(serializer.CodeParamErr, "Nothing to update", nil)
+ }
+
+ dep := dependency.FromContext(c)
+ userClient := dep.UserClient()
+ current := inventory.UserFromContext(c)
+ fullAdmin := actorIsFullAdmin(c)
+
+ // Delegated admins cannot move users into an admin-capable group.
+ if !fullAdmin && s.GroupID > 0 {
+ target, err := dep.GroupClient().GetByID(c, s.GroupID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to get group", err)
+ }
+ if groupIsAdminCapable(target.Permissions) {
+ return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil)
+ }
+ }
+
+ // The caller and the reserved initial admin cannot be modified in bulk.
+ ae := serializer.NewAggregateError()
+ groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true)
+ ids := lo.Filter(s.IDs, func(id int, _ int) bool {
+ if id == current.ID || id == 1 {
+ ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil))
+ return false
+ }
+ // Delegated admins cannot modify members of admin-capable groups.
+ if !fullAdmin {
+ target, err := userClient.GetByID(groupCtx, id)
+ if err != nil {
+ ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err))
+ return false
+ }
+ if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) {
+ ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil))
+ return false
+ }
+ }
+ return true
+ })
+
+ var status *user.Status
+ if s.Status != "" {
+ st := user.Status(s.Status)
+ status = &st
+ }
+
+ if _, err := userClient.BatchUpdate(c, ids, status, s.GroupID); err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to update users", err)
+ }
+
+ return ae.Aggregate()
+}
diff --git a/service/basic/site.go b/service/basic/site.go
index 87e9cd5e..196be9b6 100644
--- a/service/basic/site.go
+++ b/service/basic/site.go
@@ -27,6 +27,10 @@ type SiteConfig struct {
CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"`
CustomHTML *setting.CustomHTML `json:"custom_html,omitempty"`
+ // Share section
+ ShareDefaultPrivate bool `json:"share_default_private,omitempty"`
+ DefaultShareLinksInProfile string `json:"default_share_links_in_profile,omitempty"`
+
// Login Section
LoginCaptcha bool `json:"login_captcha,omitempty"`
RegCaptcha bool `json:"reg_captcha,omitempty"`
@@ -43,6 +47,11 @@ type SiteConfig struct {
PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"`
SSOEnabled bool `json:"sso_enabled,omitempty"`
SSODisplayName string `json:"sso_display_name,omitempty"`
+ SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"`
+
+ // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients
+ // can offer a download-route picker (#2987).
+ DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"`
// Explorer section
Icons string `json:"icons,omitempty"`
@@ -58,6 +67,10 @@ type SiteConfig struct {
CustomProps []types.CustomProps `json:"custom_props,omitempty"`
ShowEncryptionStatus bool `json:"show_encryption_status,omitempty"`
FullTextSearch bool `json:"full_text_search,omitempty"`
+ // RemoteDownloadProviders lists distinct downloader providers offered by
+ // active remote-download-capable nodes, so clients can let users pick
+ // between e.g. Aria2 and qBittorrent per download.
+ RemoteDownloadProviders []string `json:"remote_download_providers,omitempty"`
// Thumbnail section
ThumbExts []string `json:"thumb_exts,omitempty"`
@@ -102,6 +115,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
TosUrl: legalDocs.TermsOfService,
SSOEnabled: sso.Enabled && sso.Issuer != "" && sso.ClientID != "",
SSODisplayName: sso.DisplayName,
+ SSOAutoRedirect: sso.AutoRedirect,
}, nil
case "explorer":
explorerSettings := settings.ExplorerFrontendSettings(c)
@@ -117,18 +131,19 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
}
}
return &SiteConfig{
- MaxBatchSize: maxBatchSize,
- FileViewers: fileViewers,
- DefaultViewerMapping: settings.DefaultViewerMapping(c),
- Icons: explorerSettings.Icons,
- MapProvider: mapSettings.Provider,
- GoogleMapTileType: mapSettings.GoogleTileType,
- MapboxAK: mapSettings.MapboxAK,
- ThumbnailWidth: w,
- ThumbnailHeight: h,
- CustomProps: customProps,
- ShowEncryptionStatus: showEncryptionStatus,
- FullTextSearch: settings.FTSEnabled(c),
+ MaxBatchSize: maxBatchSize,
+ FileViewers: fileViewers,
+ DefaultViewerMapping: settings.DefaultViewerMapping(c),
+ Icons: explorerSettings.Icons,
+ MapProvider: mapSettings.Provider,
+ GoogleMapTileType: mapSettings.GoogleTileType,
+ MapboxAK: mapSettings.MapboxAK,
+ ThumbnailWidth: w,
+ ThumbnailHeight: h,
+ CustomProps: customProps,
+ ShowEncryptionStatus: showEncryptionStatus,
+ FullTextSearch: settings.FTSEnabled(c),
+ RemoteDownloadProviders: remoteDownloadProviders(c, dep),
}, nil
case "emojis":
emojis := settings.EmojiPresets(c)
@@ -196,23 +211,27 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
appSetting := settings.AppSetting(c)
customNavItems := settings.CustomNavItems(c)
customHTML := settings.CustomHTML(c)
+ shareDefaults := settings.ShareDefaults(c)
return &SiteConfig{
- InstanceID: siteBasic.ID,
- SiteName: siteBasic.Name,
- Themes: themes.Themes,
- DefaultTheme: themes.DefaultTheme,
- User: &userRes,
- Logo: logo.Normal,
- LogoLight: logo.Light,
- CaptchaType: settings.CaptchaType(c),
- TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
- ReCaptchaKey: reCaptcha.Key,
- CapInstanceURL: capCaptcha.InstanceURL,
- CapSiteKey: capCaptcha.SiteKey,
- CapAssetServer: capCaptcha.AssetServer,
- AppPromotion: appSetting.Promotion,
- CustomNavItems: customNavItems,
- CustomHTML: customHTML,
+ InstanceID: siteBasic.ID,
+ SiteName: siteBasic.Name,
+ Themes: themes.Themes,
+ DefaultTheme: themes.DefaultTheme,
+ User: &userRes,
+ Logo: logo.Normal,
+ LogoLight: logo.Light,
+ CaptchaType: settings.CaptchaType(c),
+ TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
+ ReCaptchaKey: reCaptcha.Key,
+ CapInstanceURL: capCaptcha.InstanceURL,
+ CapSiteKey: capCaptcha.SiteKey,
+ CapAssetServer: capCaptcha.AssetServer,
+ AppPromotion: appSetting.Promotion,
+ CustomNavItems: customNavItems,
+ CustomHTML: customHTML,
+ ShareDefaultPrivate: shareDefaults.PrivateByDefault,
+ DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
+ DownloadCDNRoutes: settings.DownloadCDNRoutes(c),
}, nil
}
@@ -256,3 +275,24 @@ func GetCaptchaImage(c *gin.Context) *CaptchaResponse {
Ticket: idKeyD,
}
}
+
+// remoteDownloadProviders returns the distinct downloader providers offered by
+// active nodes with remote-download capability, in stable sorted order.
+func remoteDownloadProviders(c *gin.Context, dep dependency.Dep) []string {
+ nodes, err := dep.NodeClient().ListActiveNodes(c, nil)
+ if err != nil {
+ return nil
+ }
+ seen := map[string]bool{}
+ var providers []string
+ for _, n := range nodes {
+ if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) ||
+ n.Settings == nil || n.Settings.Provider == "" || seen[string(n.Settings.Provider)] {
+ continue
+ }
+ seen[string(n.Settings.Provider)] = true
+ providers = append(providers, string(n.Settings.Provider))
+ }
+ sort.Strings(providers)
+ return providers
+}
diff --git a/service/callback/oauth.go b/service/callback/oauth.go
index 3f9b6f3a..3a553a8f 100644
--- a/service/callback/oauth.go
+++ b/service/callback/oauth.go
@@ -17,42 +17,35 @@ type OauthService struct {
//// GDriveAuth Google Drive 更新认证信息
//func (service *OauthService) GDriveAuth(c *gin.Context) serializer.Response {
// if service.Error != "" {
-// return serializer.ParamErrDeprecated(service.Error, nil)
// }
//
// // validate required scope
// if missing, found := lo.Find[string](googledrive.RequiredScope, func(item string) bool {
// return !strings.Contains(service.Scope, item)
// }); found {
-// return serializer.ParamErrDeprecated(fmt.Sprintf("Missing required scope: %s", missing), nil)
// }
//
// policyID, ok := util.GetSession(c, "googledrive_oauth_policy").(uint)
// if !ok {
-// return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil)
// }
//
// util.DeleteSession(c, "googledrive_oauth_policy")
//
// policy, err := model.GetPolicyByID(policyID)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil)
// }
//
// client, err := googledrive.NewClient(&policy)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize Google Drive client", err)
// }
//
// credential, err := client.ObtainToken(c, service.Code, "")
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err)
// }
//
// // 更新存储策略的 RefreshToken
// client.Policy.AccessKey = credential.RefreshToken
// if err := client.Policy.SaveAndClearCache(); err != nil {
-// return serializer.DBErrDeprecated("Failed to update RefreshToken", err)
// }
//
// cache.Deletes([]string{client.Policy.AccessKey}, googledrive.TokenCachePrefix)
@@ -62,41 +55,34 @@ type OauthService struct {
// OdAuth OneDrive 更新认证信息
func (service *OauthService) OdAuth(c *gin.Context) serializer.Response {
//if service.Error != "" {
- // return serializer.ParamErrDeprecated(service.ErrorMsg, nil)
//}
//
//policyID, ok := util.GetSession(c, "onedrive_oauth_policy").(uint)
//if !ok {
- // return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil)
//}
//
//util.DeleteSession(c, "onedrive_oauth_policy")
//
//policy, err := model.GetPolicyByID(policyID)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil)
//}
//
//client, err := onedrive.NewClient(&policy)
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize OneDrive client", err)
//}
//
//credential, err := client.ObtainToken(c, onedrive.WithCode(service.Code))
//if err != nil {
- // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err)
//}
//
//// 更新存储策略的 RefreshToken
//client.Policy.AccessKey = credential.RefreshToken
//if err := client.Policy.SaveAndClearCache(); err != nil {
- // return serializer.DBErrDeprecated("Failed to update RefreshToken", err)
//}
//
//cache.Deletes([]string{client.Policy.AccessKey}, "onedrive_")
//if client.Policy.OptionsSerialized.OdDriver != "" && strings.Contains(client.Policy.OptionsSerialized.OdDriver, "http") {
// if err := querySharePointSiteID(c, client.Policy); err != nil {
- // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to query SharePoint basic ID", err)
// }
//}
diff --git a/service/callback/upload.go b/service/callback/upload.go
index 057c75eb..01c108e5 100644
--- a/service/callback/upload.go
+++ b/service/callback/upload.go
@@ -64,7 +64,6 @@ func ProcessCallback(c *gin.Context) error {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
@@ -74,7 +73,6 @@ func ProcessCallback(c *gin.Context) error {
// // 获取文件信息
// info, err := fs.Handler.(onedrive.Driver).Client.Meta(context.Background(), "", uploadSession.SavePath)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeQueryMetaFailed, "", err)
// }
//
// // 验证与回调会话中是否一致
@@ -89,7 +87,6 @@ func ProcessCallback(c *gin.Context) error {
//
// if isSizeCheckFailed || !strings.EqualFold(info.GetSourcePath(), actualPath) {
// fs.Handler.(onedrive.Driver).Client.Delete(context.Background(), []string{info.GetSourcePath()})
-// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
// service.Meta = info
// return ProcessCallback(c)
@@ -102,7 +99,6 @@ func ProcessCallback(c *gin.Context) error {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
@@ -112,12 +108,10 @@ func ProcessCallback(c *gin.Context) error {
// // 获取文件信息
// info, err := fs.Handler.(*s3.Driver).Meta(context.Background(), uploadSession.SavePath)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// // 验证实际文件信息与回调会话中是否一致
// if uploadSession.Size != info.Size {
-// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// return ProcessCallback(service, c)
@@ -128,7 +122,6 @@ func ProcessCallback(c *gin.Context) error {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
-// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
@@ -138,7 +131,6 @@ func ProcessCallback(c *gin.Context) error {
// // 验证文件大小
// if uploadSession.Size != service.Size {
// fs.Handler.Delete(context.Background(), []string{uploadSession.SavePath})
-// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// return ProcessCallback(service, c)
diff --git a/service/explorer/file.go b/service/explorer/file.go
index 0552664f..6544bfb6 100644
--- a/service/explorer/file.go
+++ b/service/explorer/file.go
@@ -246,6 +246,10 @@ type (
RenameFileService struct {
Uri string `json:"uri" binding:"required"`
NewName string `json:"new_name" binding:"required,min=1,max=255"`
+ // ExpectID optionally carries the hashid of the file the caller
+ // believes sits at Uri; the rename fails with a conflict if the
+ // resolved file differs (retried request after path reuse, #3565).
+ ExpectID string `json:"expect_id"`
}
)
@@ -260,6 +264,14 @@ func (service *RenameFileService) Rename(c *gin.Context) (*FileResponse, error)
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
}
+ if service.ExpectID != "" {
+ expectID, err := dep.HashIDEncoder().Decode(service.ExpectID, hashid.FileID)
+ if err != nil {
+ return nil, serializer.NewError(serializer.CodeParamErr, "unknown expect_id", err)
+ }
+ util.WithValue(c, dbfs.ExpectedSourceIDsCtxKey{}, []int{expectID})
+ }
+
file, err := m.Rename(c, uri, service.NewName)
if err != nil {
return nil, err
@@ -274,6 +286,16 @@ type (
Uris []string `json:"uris" binding:"required,min=1"`
Dst string `json:"dst" binding:"required"`
Copy bool `json:"copy"`
+ // ExpectIDs optionally carries the hashids of the source files the
+ // caller selected, positionally aligned with Uris; a mismatch fails
+ // that entry with a conflict (#3565). An empty string entry skips
+ // the check for that position.
+ ExpectIDs []string `json:"expect_ids"`
+ // OnConflict selects the behaviour when a destination child with
+ // the same name exists: "skip" drops the colliding source,
+ // "overwrite" deletes the destination object first. Empty keeps
+ // the default fail-fast behaviour (#3159).
+ OnConflict string `json:"on_conflict" binding:"omitempty,eq=skip|eq=overwrite"`
}
)
@@ -297,6 +319,28 @@ func (s *MoveFileService) Move(c *gin.Context) error {
return serializer.NewError(serializer.CodeParamErr, "unknown destination uri", err)
}
+ if len(s.ExpectIDs) > 0 {
+ if len(s.ExpectIDs) != len(s.Uris) {
+ return serializer.NewError(serializer.CodeParamErr, "expect_ids must align with uris", nil)
+ }
+ ids := make([]int, len(s.ExpectIDs))
+ for i, e := range s.ExpectIDs {
+ if e == "" {
+ continue
+ }
+ id, err := dep.HashIDEncoder().Decode(e, hashid.FileID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeParamErr, "unknown expect_ids entry", err)
+ }
+ ids[i] = id
+ }
+ util.WithValue(c, dbfs.ExpectedSourceIDsCtxKey{}, ids)
+ }
+
+ if s.OnConflict != "" {
+ util.WithValue(c, dbfs.MoveConflictCtxKey{}, s.OnConflict)
+ }
+
return m.MoveOrCopy(c, uris, dst, s.Copy)
}
diff --git a/service/explorer/response.go b/service/explorer/response.go
index 7b4688d8..fd94a473 100644
--- a/service/explorer/response.go
+++ b/service/explorer/response.go
@@ -342,6 +342,7 @@ type Share struct {
AllowEdit bool `json:"allow_edit,omitempty"`
PreviewOnly bool `json:"preview_only,omitempty"`
UploadOnly bool `json:"upload_only,omitempty"`
+ Note string `json:"note,omitempty"`
// Only viewable if explicitly unlocked by owner
SourceUri string `json:"source_uri,omitempty"`
@@ -393,6 +394,8 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.
res.AllowEdit = s.Props.AllowEdit
res.PreviewOnly = s.Props.PreviewOnly
res.UploadOnly = s.Props.UploadOnly
+ // Owner-private note; never sent to share visitors (#3570).
+ res.Note = s.Props.Note
}
}
diff --git a/service/explorer/response_share_test.go b/service/explorer/response_share_test.go
new file mode 100644
index 00000000..67fadda4
--- /dev/null
+++ b/service/explorer/response_share_test.go
@@ -0,0 +1,72 @@
+package explorer
+
+import (
+ "context"
+ "net/url"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/stretchr/testify/require"
+)
+
+// stubSettingProvider satisfies the SettingProvider dependency; only
+// ExposeUserEmail and ShareDefaults are exercised by BuildUserRedacted.
+type stubSettingProvider struct {
+ setting.Provider
+}
+
+func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false }
+
+func (stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults {
+ return &setting.ShareDefaults{}
+}
+
+// TestBuildShareNoteVisibility ensures the owner-defined share note is only
+// exposed to the share owner, never to visitors (upstream #3570).
+func TestBuildShareNoteVisibility(t *testing.T) {
+ dep := dependency.NewDependency(dependency.WithSettingProvider(stubSettingProvider{}))
+ ctx := context.WithValue(context.Background(), dependency.DepCtx{}, dep)
+
+ hasher, err := hashid.New("test-salt")
+ require.NoError(t, err)
+ base := &url.URL{Scheme: "https", Host: "example.com"}
+
+ owner := &ent.User{ID: 1, Email: "owner@example.com", Settings: &types.UserSetting{}}
+ visitor := &ent.User{ID: 2, Email: "visitor@example.com", Settings: &types.UserSetting{}}
+ anonymous := &ent.User{ID: 0, Settings: &types.UserSetting{}}
+
+ newShare := func() *ent.Share {
+ return &ent.Share{
+ ID: 1,
+ Props: &types.ShareProps{Note: "tax receipts Q3"},
+ }
+ }
+
+ t.Run("owner sees note", func(t *testing.T) {
+ res := BuildShare(ctx, newShare(), base, hasher, owner, owner,
+ "receipts.zip", types.FileTypeFile, true, false)
+ require.Equal(t, "tax receipts Q3", res.Note)
+ })
+
+ t.Run("unlocked visitor does not see note", func(t *testing.T) {
+ res := BuildShare(ctx, newShare(), base, hasher, visitor, owner,
+ "receipts.zip", types.FileTypeFile, true, false)
+ require.Empty(t, res.Note)
+ })
+
+ t.Run("anonymous visitor does not see note", func(t *testing.T) {
+ res := BuildShare(ctx, newShare(), base, hasher, anonymous, owner,
+ "receipts.zip", types.FileTypeFile, true, false)
+ require.Empty(t, res.Note)
+ })
+
+ t.Run("locked share does not leak note to visitor", func(t *testing.T) {
+ res := BuildShare(ctx, newShare(), base, hasher, visitor, owner,
+ "receipts.zip", types.FileTypeFile, false, false)
+ require.Empty(t, res.Note)
+ })
+}
diff --git a/service/explorer/slave.go b/service/explorer/slave.go
index c4335aff..40e26367 100644
--- a/service/explorer/slave.go
+++ b/service/explorer/slave.go
@@ -151,6 +151,9 @@ type (
SlaveThumbService struct {
Src string `uri:"src" binding:"required"`
Ext string `uri:"ext" binding:"required"`
+ // Eid optionally carries the entity ID of the requested blob so the
+ // slave can resolve encryption metadata from the shared DB (#3164).
+ Eid int `form:"eid"`
}
)
@@ -165,12 +168,32 @@ func (s *SlaveThumbService) Thumb(c *gin.Context) error {
}
settings := dep.SettingProvider()
- var entity fs.Entity
- entity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, string(src)+settings.ThumbSlaveSidecarSuffix(c))
+ var (
+ entity fs.Entity
+ srcEntity fs.Entity
+ )
+ // Resolve the DB entity when the caller knows its ID: it carries the
+ // encryption metadata needed to serve/generate encrypted thumbnails.
+ if s.Eid > 0 {
+ if e, err := dep.FileClient().GetEntityByID(c, s.Eid); err == nil {
+ dbEntity := fs.NewEntity(e)
+ if dbEntity.Type() == types.EntityTypeThumbnail {
+ entity = dbEntity
+ } else {
+ srcEntity = dbEntity
+ }
+ }
+ }
+
+ if entity == nil {
+ entity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, string(src)+settings.ThumbSlaveSidecarSuffix(c))
+ }
if err != nil {
- srcEntity, err := local.NewLocalFileEntity(types.EntityTypeVersion, string(src))
- if err != nil {
- return fs.ErrPathNotExist.WithError(err)
+ if srcEntity == nil {
+ srcEntity, err = local.NewLocalFileEntity(types.EntityTypeVersion, string(src))
+ if err != nil {
+ return fs.ErrPathNotExist.WithError(err)
+ }
}
entity, err = m.SubmitAndAwaitThumbnailTask(c, nil, s.Ext, srcEntity)
diff --git a/service/explorer/upload.go b/service/explorer/upload.go
index 4332a110..7643732e 100644
--- a/service/explorer/upload.go
+++ b/service/explorer/upload.go
@@ -205,6 +205,15 @@ func processChunkUpload(c *gin.Context, m manager.FileManager, session *fs.Uploa
if err != nil {
return err
}
+ if !allReceived {
+ // A missing session record (expired TTL, eviction, or a racing
+ // cancel) means completion can never trigger — fail loudly so the
+ // client retries instead of leaving a permanently stuck placeholder.
+ dep := dependency.FromContext(c)
+ if _, ok := dep.KV().Get(manager.UploadSessionCachePrefix + session.Props.UploadSessionID); !ok {
+ return serializer.NewError(serializer.CodeUploadSessionExpired, "", nil)
+ }
+ }
if allReceived {
_, err := m.CompleteUpload(ctx, session)
if err != nil {
diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go
index 8bef8c95..fb2496ed 100644
--- a/service/explorer/workflows.go
+++ b/service/explorer/workflows.go
@@ -1,8 +1,11 @@
package explorer
import (
+ "context"
"encoding/gob"
+ "fmt"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
@@ -20,7 +23,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/samber/lo"
- "golang.org/x/tools/container/intsets"
+ "math"
)
// ItemMoveService 处理多文件/目录移动
@@ -71,9 +74,14 @@ func init() {
type (
DownloadWorkflowService struct {
- Src []string `json:"src"`
- SrcFile string `json:"src_file"`
- Dst string `json:"dst" binding:"required"`
+ Src []string `json:"src"`
+ SrcFile string `json:"src_file"`
+ Dst string `json:"dst" binding:"required"`
+ FileName string `json:"file_name" binding:"omitempty,max=255"`
+ Username string `json:"username" binding:"omitempty,max=255"`
+ Password string `json:"password" binding:"omitempty,max=255"`
+ Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"`
+ Provider string `json:"provider" binding:"omitempty,max=64"`
}
CreateDownloadParamCtx struct{}
)
@@ -129,6 +137,37 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T
}
}
+ // Validate custom request headers: "Name: value" lines, no CRLF injection.
+ for _, h := range service.Headers {
+ name, _, ok := strings.Cut(h, ":")
+ if !ok || strings.TrimSpace(name) == "" || strings.ContainsAny(h, "\r\n") {
+ return nil, serializer.NewError(serializer.CodeParamErr, "Invalid header", nil)
+ }
+ }
+
+ // Validate a requested downloader provider against what the node pool
+ // actually offers.
+ if service.Provider != "" && providerNodeAvailable(c, dep, service.Provider) == 0 {
+ return nil, serializer.NewError(serializer.CodeParamErr, "Invalid downloader provider", nil)
+ }
+
+ // Custom file name only applies to single-source tasks; HTTP credentials
+ // and headers only apply to plain HTTP(S) source URLs.
+ taskOpts := &workflows.RemoteDownloadTaskOption{
+ Provider: service.Provider,
+ HTTPUsername: service.Username,
+ HTTPPassword: service.Password,
+ HTTPHeaders: service.Headers,
+ }
+ if len(service.Src) <= 1 {
+ taskOpts.FileName = service.FileName
+ }
+ if service.SrcFile != "" {
+ taskOpts.HTTPUsername = ""
+ taskOpts.HTTPPassword = ""
+ taskOpts.HTTPHeaders = nil
+ }
+
// batch creating tasks
ae := serializer.NewAggregateError()
tasks := make([]queue.Task, 0, len(service.Src))
@@ -137,7 +176,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T
continue
}
- t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst)
+ t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst, taskOpts)
if err != nil {
ae.Add(src, err)
continue
@@ -151,7 +190,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T
}
if service.SrcFile != "" {
- t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst)
+ t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst, taskOpts)
if err != nil {
ae.Add(service.SrcFile, err)
}
@@ -333,14 +372,15 @@ func (service *ListTaskService) ListTasks(c *gin.Context) (*TaskListResponse, er
PageToken: service.NextPageToken,
PageSize: service.PageSize,
},
- Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType},
- UserID: user.ID,
+ Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType},
+ UserID: user.ID,
+ ExcludeHidden: true,
}
if service.Category != "general" {
args.Types = []string{queue.RemoteDownloadTaskType}
if service.Category == "downloading" {
- args.PageSize = intsets.MaxInt
+ args.PageSize = math.MaxInt
args.Status = []task.Status{task.StatusSuspending, task.StatusProcessing, task.StatusQueued}
} else if service.Category == "downloaded" {
args.Status = []task.Status{task.StatusCanceled, task.StatusError, task.StatusCompleted}
@@ -413,6 +453,138 @@ func CancelDownloadTask(c *gin.Context, taskID int) error {
return nil
}
+// queueForTaskType maps a persisted task type to the dependency queue that
+// owns it. Slave-side task types are not retryable from the master UI.
+func queueForTaskType(c *gin.Context, dep dependency.Dep, taskType string) (queue.Queue, error) {
+ switch taskType {
+ case queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType:
+ return dep.IoIntenseQueue(c), nil
+ case queue.RemoteDownloadTaskType:
+ return dep.RemoteDownloadQueue(c), nil
+ case queue.MediaMetaTaskType, queue.FullTextIndexTaskType, queue.FullTextDeleteTaskType,
+ queue.FullTextRebuildTaskType, queue.FullTextCopyTaskType, queue.FullTextChangeOwnerTaskType:
+ return dep.MediaMetaQueue(c), nil
+ case queue.EntityRecycleRoutineTaskType, queue.ExplicitEntityRecycleTaskType, queue.UploadSentinelCheckTaskType:
+ return dep.EntityRecycleQueue(c), nil
+ }
+ return nil, fmt.Errorf("task type %q is not retryable", taskType)
+}
+
+// RetryTask re-queues a failed task with its original args (#2823).
+func RetryTask(c *gin.Context, taskID int) error {
+ dep := dependency.FromContext(c)
+ u := inventory.UserFromContext(c)
+ taskClient := dep.TaskClient()
+
+ ctx := context.WithValue(c, inventory.LoadTaskUser{}, true)
+ ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
+ model, err := taskClient.GetTaskByID(ctx, taskID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", err)
+ }
+
+ if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
+ }
+
+ if model.Status != task.StatusError {
+ return serializer.NewError(serializer.CodeParamErr, "Only failed tasks can be retried", nil)
+ }
+
+ resumed, err := queue.NewTaskFromModel(model)
+ if err != nil {
+ return serializer.NewError(serializer.CodeInternalSetting, "Failed to rebuild task", err)
+ }
+
+ q, err := queueForTaskType(c, dep, model.Type)
+ if err != nil {
+ return serializer.NewError(serializer.CodeParamErr, "Task type is not retryable", err)
+ }
+
+ if err := q.QueueTask(c, resumed); err != nil {
+ return serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
+ }
+
+ return nil
+}
+
+// CancelTask terminates a queued or suspending task; running remote
+// downloads are canceled through their downloader handle (#2270).
+func CancelTask(c *gin.Context, taskID int) error {
+ dep := dependency.FromContext(c)
+ u := inventory.UserFromContext(c)
+ taskClient := dep.TaskClient()
+
+ ctx := context.WithValue(c, inventory.LoadTaskUser{}, true)
+ model, err := taskClient.GetTaskByID(ctx, taskID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", err)
+ }
+
+ if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
+ }
+
+ switch model.Status {
+ case task.StatusQueued, task.StatusSuspending:
+ q, err := queueForTaskType(c, dep, model.Type)
+ if err != nil {
+ return serializer.NewError(serializer.CodeParamErr, "Task type cannot be canceled", err)
+ }
+ if q.CancelTask(ctx, taskID) {
+ return nil
+ }
+ // Not in the in-memory registry (e.g. after a restart before
+ // resume); persist the cancel so it won't be picked up later.
+ if err := taskClient.SetStatusByID(ctx, taskID, task.StatusCanceled); err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to cancel task", err)
+ }
+ return nil
+ case task.StatusProcessing:
+ // Only remote downloads expose a runtime cancel handle; other
+ // running tasks cannot be interrupted safely.
+ if t, found := dep.TaskRegistry().Get(taskID); found {
+ if dl, ok := t.(*workflows.RemoteDownloadTask); ok {
+ if err := dl.CancelDownload(c); err != nil {
+ return serializer.NewError(serializer.CodeInternalSetting, "Failed to cancel download task", err)
+ }
+ return nil
+ }
+ }
+ return serializer.NewError(serializer.CodeParamErr, "Running task cannot be canceled", nil)
+ default:
+ return serializer.NewError(serializer.CodeParamErr, "Only queued or running tasks can be canceled", nil)
+ }
+}
+
+// DeleteTask hides a finished task record from its owner's list. The row
+// is kept so admins retain the history (#2270 follow-up, user request).
+func DeleteTask(c *gin.Context, taskID int) error {
+ dep := dependency.FromContext(c)
+ u := inventory.UserFromContext(c)
+ taskClient := dep.TaskClient()
+
+ model, err := taskClient.GetTaskByID(c, taskID)
+ if err != nil {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", err)
+ }
+
+ if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
+ }
+
+ switch model.Status {
+ case task.StatusCompleted, task.StatusError, task.StatusCanceled:
+ default:
+ return serializer.NewError(serializer.CodeParamErr, "Only finished tasks can be deleted", nil)
+ }
+
+ if err := taskClient.HideByIDs(c, model.UserTasks, taskID); err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to delete task", err)
+ }
+ return nil
+}
+
type (
SetDownloadFilesService struct {
Files []*downloader.SetFileToDownloadArgs `json:"files" binding:"required"`
@@ -481,3 +653,47 @@ func (service *RebuildFTSIndexWorkflowService) CreateRebuildFTSIndexTask(c *gin.
return BuildTaskResponse(t, nil, hasher), nil
}
+
+type (
+ BlobAuditWorkflowService struct {
+ PolicyID int `json:"policy_id" binding:"required,min=1"`
+ Delete bool `json:"delete"`
+ }
+ BlobAuditParamCtx struct{}
+)
+
+// CreateBlobAuditTask queues a blob-vs-database audit for one storage policy.
+func (service *BlobAuditWorkflowService) CreateBlobAuditTask(c *gin.Context) (*TaskResponse, error) {
+ dep := dependency.FromContext(c)
+ user := inventory.UserFromContext(c)
+ hasher := dep.HashIDEncoder()
+
+ if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
+ return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Only admin can run a blob audit", nil)
+ }
+
+ if _, err := dep.StoragePolicyClient().GetPolicyByID(c, service.PolicyID); err != nil {
+ return nil, serializer.NewError(serializer.CodeNotFound, "Storage policy not found", err)
+ }
+
+ t, err := workflows.NewBlobAuditTask(c, user, service.PolicyID, service.Delete)
+ if err != nil {
+ return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
+ }
+
+ if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
+ return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
+ }
+
+ return BuildTaskResponse(t, nil, hasher), nil
+}
+
+// providerNodeAvailable returns the ID of an active remote-download node
+// offering the given provider, or 0 when none does.
+func providerNodeAvailable(c *gin.Context, dep dependency.Dep, provider string) int {
+ nodes, err := dep.NodeClient().ListActiveNodes(c, nil)
+ if err != nil {
+ return 0
+ }
+ return workflows.PickProviderNode(nodes, provider)
+}
diff --git a/service/explorer/workflows_retry_test.go b/service/explorer/workflows_retry_test.go
new file mode 100644
index 00000000..0649b855
--- /dev/null
+++ b/service/explorer/workflows_retry_test.go
@@ -0,0 +1,297 @@
+package explorer
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ enttask "github.com/cloudreve/Cloudreve/v4/ent/task"
+ entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/queue"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+ "github.com/gofrs/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+// captureQueue records submitted tasks without running workers.
+type captureQueue struct {
+ queue.Queue
+ submitted []queue.Task
+ cancelable bool
+}
+
+func (q *captureQueue) QueueTask(ctx context.Context, t queue.Task) error {
+ q.submitted = append(q.submitted, t)
+ return nil
+}
+
+func (q *captureQueue) CancelTask(ctx context.Context, taskID int) bool {
+ return q.cancelable
+}
+
+// retryDepStub exposes only the dependencies RetryTask/CancelTask touch.
+type retryDepStub struct {
+ dependency.Dep
+ taskClient inventory.TaskClient
+ ioQueue queue.Queue
+ downloadQ queue.Queue
+ recycleQ queue.Queue
+ mediaMetaQ queue.Queue
+ registry queue.TaskRegistry
+}
+
+func (d *retryDepStub) TaskClient() inventory.TaskClient { return d.taskClient }
+func (d *retryDepStub) IoIntenseQueue(context.Context) queue.Queue { return d.ioQueue }
+func (d *retryDepStub) RemoteDownloadQueue(context.Context) queue.Queue { return d.downloadQ }
+func (d *retryDepStub) EntityRecycleQueue(context.Context) queue.Queue { return d.recycleQ }
+func (d *retryDepStub) MediaMetaQueue(context.Context) queue.Queue { return d.mediaMetaQ }
+func (d *retryDepStub) TaskRegistry() queue.TaskRegistry {
+ if d.registry == nil {
+ d.registry = queue.NewTaskRegistry()
+ }
+ return d.registry
+}
+
+// TestRetryTask verifies failed-task retry: owner/admin gating, status gate,
+// and queue routing by task type (upstream #2823).
+func TestRetryTask(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ newUser := func(email string) *ent.User {
+ u := client.User.Create().SetEmail(email).SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
+ return client.User.Query().WithGroup().Where(entuser.ID(u.ID)).OnlyX(ctx)
+ }
+ owner := newUser("owner@example.com")
+ other := newUser("other@example.com")
+
+ ioQ := &captureQueue{}
+ downloadQ := &captureQueue{}
+ dep := &retryDepStub{
+ taskClient: inventory.NewTaskClient(client, conf.SQLiteDB, nil),
+ ioQueue: ioQ,
+ downloadQ: downloadQ,
+ recycleQ: &captureQueue{},
+ mediaMetaQ: &captureQueue{},
+ }
+
+ newCtx := func(u *ent.User) *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("POST", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dep)
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+ }
+
+ newTask := func(ownerID int, taskType string, status enttask.Status) *ent.Task {
+ return client.Task.Create().
+ SetType(taskType).
+ SetStatus(status).
+ SetPublicState(&types.TaskPublicState{}).
+ SetCorrelationID(uuid.Must(uuid.NewV4())).
+ SetUserTasks(ownerID).
+ SaveX(ctx)
+ }
+
+ t.Run("failed extract task re-queues to io-intense queue", func(t *testing.T) {
+ ioQ.submitted = nil
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusError)
+ require.NoError(t, RetryTask(newCtx(owner), m.ID))
+ require.Len(t, ioQ.submitted, 1)
+ require.Equal(t, m.ID, ioQ.submitted[0].ID())
+ })
+
+ t.Run("failed download task re-queues to download queue", func(t *testing.T) {
+ downloadQ.submitted = nil
+ m := newTask(owner.ID, queue.RemoteDownloadTaskType, enttask.StatusError)
+ require.NoError(t, RetryTask(newCtx(owner), m.ID))
+ require.Len(t, downloadQ.submitted, 1)
+ })
+
+ t.Run("non-error task rejected", func(t *testing.T) {
+ ioQ.submitted = nil
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusCompleted)
+ require.Error(t, RetryTask(newCtx(owner), m.ID))
+ require.Empty(t, ioQ.submitted)
+ })
+
+ t.Run("other user's task rejected", func(t *testing.T) {
+ ioQ.submitted = nil
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusError)
+ require.Error(t, RetryTask(newCtx(other), m.ID))
+ require.Empty(t, ioQ.submitted)
+ })
+
+ t.Run("slave task type not retryable", func(t *testing.T) {
+ m := newTask(owner.ID, queue.SlaveUploadTaskType, enttask.StatusError)
+ require.Error(t, RetryTask(newCtx(owner), m.ID))
+ })
+}
+
+// TestCancelTask verifies queued-task cancellation falls back to a persisted
+// status update when the task is not in the queue's in-memory registry
+// (upstream #2270).
+func TestCancelTask(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ owner := client.User.Create().SetEmail("cancel-owner@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
+ owner = client.User.Query().WithGroup().Where(entuser.ID(owner.ID)).OnlyX(ctx)
+ other := client.User.Create().SetEmail("cancel-other@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
+ other = client.User.Query().WithGroup().Where(entuser.ID(other.ID)).OnlyX(ctx)
+
+ ioQ := &captureQueue{}
+ dep := &retryDepStub{
+ taskClient: inventory.NewTaskClient(client, conf.SQLiteDB, nil),
+ ioQueue: ioQ,
+ downloadQ: &captureQueue{},
+ recycleQ: &captureQueue{},
+ mediaMetaQ: &captureQueue{},
+ }
+
+ newCtx := func(u *ent.User) *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("POST", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dep)
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+ }
+
+ newTask := func(ownerID int, taskType string, status enttask.Status) *ent.Task {
+ return client.Task.Create().
+ SetType(taskType).
+ SetStatus(status).
+ SetPublicState(&types.TaskPublicState{}).
+ SetCorrelationID(uuid.Must(uuid.NewV4())).
+ SetUserTasks(ownerID).
+ SaveX(ctx)
+ }
+
+ t.Run("queued task canceled via registry", func(t *testing.T) {
+ ioQ.cancelable = true
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued)
+ require.NoError(t, CancelTask(newCtx(owner), m.ID))
+ })
+
+ t.Run("queued task falls back to persisted cancel", func(t *testing.T) {
+ ioQ.cancelable = false
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued)
+ require.NoError(t, CancelTask(newCtx(owner), m.ID))
+ require.Equal(t, enttask.StatusCanceled, client.Task.GetX(ctx, m.ID).Status)
+ })
+
+ t.Run("suspending task cancelable", func(t *testing.T) {
+ ioQ.cancelable = false
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusSuspending)
+ require.NoError(t, CancelTask(newCtx(owner), m.ID))
+ require.Equal(t, enttask.StatusCanceled, client.Task.GetX(ctx, m.ID).Status)
+ })
+
+ t.Run("completed task rejected", func(t *testing.T) {
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusCompleted)
+ require.Error(t, CancelTask(newCtx(owner), m.ID))
+ })
+
+ t.Run("processing non-download task rejected", func(t *testing.T) {
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusProcessing)
+ require.Error(t, CancelTask(newCtx(owner), m.ID))
+ })
+
+ t.Run("other user's task rejected", func(t *testing.T) {
+ m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued)
+ require.Error(t, CancelTask(newCtx(other), m.ID))
+ })
+
+ t.Run("slave task type not cancelable", func(t *testing.T) {
+ m := newTask(owner.ID, queue.SlaveUploadTaskType, enttask.StatusQueued)
+ require.Error(t, CancelTask(newCtx(owner), m.ID))
+ })
+}
+
+func TestDeleteTask(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ owner := client.User.Create().SetEmail("del-owner@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
+ owner = client.User.Query().WithGroup().Where(entuser.ID(owner.ID)).OnlyX(ctx)
+ other := client.User.Create().SetEmail("del-other@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
+ other = client.User.Query().WithGroup().Where(entuser.ID(other.ID)).OnlyX(ctx)
+
+ tc := inventory.NewTaskClient(client, conf.SQLiteDB, nil)
+ dep := &retryDepStub{
+ taskClient: tc,
+ ioQueue: &captureQueue{},
+ downloadQ: &captureQueue{},
+ recycleQ: &captureQueue{},
+ mediaMetaQ: &captureQueue{},
+ }
+
+ newCtx := func(u *ent.User) *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("DELETE", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dep)
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+ }
+
+ newTask := func(ownerID int, status enttask.Status) *ent.Task {
+ return client.Task.Create().
+ SetType(queue.ExtractArchiveTaskType).
+ SetStatus(status).
+ SetPublicState(&types.TaskPublicState{}).
+ SetCorrelationID(uuid.Must(uuid.NewV4())).
+ SetUserTasks(ownerID).
+ SaveX(ctx)
+ }
+
+ t.Run("finished task hidden from owner list", func(t *testing.T) {
+ m := newTask(owner.ID, enttask.StatusCompleted)
+ require.NoError(t, DeleteTask(newCtx(owner), m.ID))
+ require.True(t, client.Task.GetX(ctx, m.ID).Hidden)
+
+ res, err := tc.List(ctx, &inventory.ListTaskArgs{
+ PaginationArgs: &inventory.PaginationArgs{PageSize: 10},
+ UserID: owner.ID,
+ ExcludeHidden: true,
+ })
+ require.NoError(t, err)
+ for _, task := range res.Tasks {
+ require.NotEqual(t, m.ID, task.ID)
+ }
+ })
+
+ t.Run("running task rejected", func(t *testing.T) {
+ m := newTask(owner.ID, enttask.StatusProcessing)
+ require.Error(t, DeleteTask(newCtx(owner), m.ID))
+ require.False(t, client.Task.GetX(ctx, m.ID).Hidden)
+ })
+
+ t.Run("other user's task rejected", func(t *testing.T) {
+ m := newTask(owner.ID, enttask.StatusCompleted)
+ require.Error(t, DeleteTask(newCtx(other), m.ID))
+ require.False(t, client.Task.GetX(ctx, m.ID).Hidden)
+ })
+}
diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go
index 064799ad..24023314 100644
--- a/service/oauth/oauth.go
+++ b/service/oauth/oauth.go
@@ -58,7 +58,7 @@ type (
}
)
-func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) {
+func (s *GrantService) Issue(c *gin.Context) (*GrantResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
kv := dep.KV()
@@ -129,7 +129,7 @@ type (
ExchangeTokenParamCtx struct{}
ExchangeTokenService struct {
ClientID string `form:"client_id" binding:"required"`
- ClientSecret string `form:"client_secret" binding:"required"`
+ ClientSecret string `form:"client_secret"`
GrantType string `form:"grant_type" binding:"required,eq=authorization_code"`
Code string `form:"code" binding:"required"`
RedirectURI string `form:"redirect_uri"`
@@ -178,14 +178,15 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error)
}
}
- // 4. Validate client secret
+ // 4. Validate client secret. Public clients (empty stored secret) cannot
+ // hold a credential — they must have authorized with PKCE instead.
app, err := oAuthClient.GetByGUID(c, s.ClientID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
}
- if app.Secret != s.ClientSecret {
- return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil)
+ if err := validateClientAuth(app, s.ClientSecret, authCode.CodeChallenge); err != nil {
+ return nil, err
}
// 5. Validate scopes are still valid for this app
@@ -250,6 +251,22 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error)
return resp, nil
}
+// validateClientAuth enforces client authentication at token exchange:
+// confidential clients must present their secret; public clients (empty
+// stored secret) must have authorized with PKCE per RFC 8252.
+func validateClientAuth(app *ent.OAuthClient, clientSecret, codeChallenge string) error {
+ if app.Secret == "" {
+ if codeChallenge == "" {
+ return serializer.NewError(serializer.CodeCredentialInvalid, "Public clients must authorize with PKCE", nil)
+ }
+ return nil
+ }
+ if app.Secret != clientSecret {
+ return serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil)
+ }
+ return nil
+}
+
func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) {
sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID)
claims := &auth.OIDCIDTokenClaims{
diff --git a/service/oauth/oauth_test.go b/service/oauth/oauth_test.go
new file mode 100644
index 00000000..803fe772
--- /dev/null
+++ b/service/oauth/oauth_test.go
@@ -0,0 +1,24 @@
+package oauth
+
+import (
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateClientAuth(t *testing.T) {
+ confidential := &ent.OAuthClient{Secret: "s3cret"}
+ public := &ent.OAuthClient{Secret: ""}
+
+ // Confidential clients: secret required and must match.
+ require.Error(t, validateClientAuth(confidential, "", "challenge"))
+ require.Error(t, validateClientAuth(confidential, "wrong", "challenge"))
+ require.NoError(t, validateClientAuth(confidential, "s3cret", ""))
+
+ // Public clients: secret ignored, PKCE challenge mandatory.
+ require.Error(t, validateClientAuth(public, "", ""))
+ require.Error(t, validateClientAuth(public, "anything", ""))
+ require.NoError(t, validateClientAuth(public, "", "challenge"))
+ require.NoError(t, validateClientAuth(public, "stale-known-secret", "challenge"))
+}
diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go
index c37861ac..4d24179e 100644
--- a/service/oauth/oidc.go
+++ b/service/oauth/oidc.go
@@ -37,6 +37,7 @@ func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse {
},
TokenEndpointAuthMethods: []string{
"client_secret_post",
+ "none",
},
CodeChallengeMethodsSupported: []string{
"S256",
diff --git a/service/setting/webdav.go b/service/setting/webdav.go
index 3ed88001..9d194996 100644
--- a/service/setting/webdav.go
+++ b/service/setting/webdav.go
@@ -42,11 +42,9 @@ type WebDAVMountCreateService struct {
// folderID, _ := c.Get("object_id")
// folder, err := model.GetFoldersByIDs([]uint{folderID.(uint)}, user.ID)
// if err != nil || len(folder) == 0 {
-// return serializer.ErrDeprecated(serializer.CodeParentNotExist, "", err)
// }
//
// if err := folder[0].Mount(0); err != nil {
-// return serializer.DBErrDeprecated("Failed to update folder record", err)
// }
//
// return serializer.Response{}
diff --git a/service/share/manage.go b/service/share/manage.go
index 45ef01b1..bb266af4 100644
--- a/service/share/manage.go
+++ b/service/share/manage.go
@@ -22,7 +22,7 @@ type (
ShareCreateService struct {
Uri string `json:"uri" binding:"required"`
IsPrivate bool `json:"is_private"`
- Password string `json:"password" binding:"omitempty,max=32,alphanum"`
+ Password string `json:"password" binding:"omitempty,max=32"`
RemainDownloads int `json:"downloads"`
Expire int `json:"expire"`
ShareView bool `json:"share_view"`
@@ -31,6 +31,8 @@ type (
AllowEdit bool `json:"allow_edit"`
PreviewOnly bool `json:"preview_only"`
UploadOnly bool `json:"upload_only"`
+ // Optional owner-defined note shown on My Shares (#3570).
+ Note string `json:"note" binding:"omitempty,max=255"`
}
ShareCreateParamCtx struct{}
@@ -98,6 +100,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
AllowEdit: service.AllowEdit,
PreviewOnly: service.PreviewOnly,
UploadOnly: service.UploadOnly,
+ Note: service.Note,
})
if err != nil {
return "", err
diff --git a/service/share/visit.go b/service/share/visit.go
index 51168a93..22f96fc8 100644
--- a/service/share/visit.go
+++ b/service/share/visit.go
@@ -162,11 +162,20 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
- if targetUser.Settings != nil && targetUser.Settings.ShareLinksInProfile == types.ProfileHideShare {
+ // An unset user preference ("") inherits the site-wide default; explicit
+ // values (public_share/all_share/hide_share) always win.
+ level := types.ProfilePublicShareOnly
+ if targetUser.Settings != nil {
+ level = targetUser.Settings.ShareLinksInProfile
+ }
+ if level == types.ProfilePublicShareOnly {
+ level = dep.SettingProvider().ShareDefaults(c).LinksInProfile
+ }
+ if level == types.ProfileHideShare {
return nil, serializer.NewError(serializer.CodeParamErr, "User has disabled share links in profile", nil)
}
- publicOnly := targetUser.Settings == nil || targetUser.Settings.ShareLinksInProfile == types.ProfilePublicShareOnly
+ publicOnly := level != types.ProfileAllShare
args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
diff --git a/service/share/visit_test.go b/service/share/visit_test.go
new file mode 100644
index 00000000..edd25920
--- /dev/null
+++ b/service/share/visit_test.go
@@ -0,0 +1,124 @@
+package share
+
+import (
+ "context"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cache"
+ "github.com/cloudreve/Cloudreve/v4/pkg/conf"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/logging"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+// stubSettingProvider satisfies the SettingProvider dependency; only
+// ShareDefaults, SiteURL and ExposeUserEmail are exercised by
+// ListInUserProfile and the share response builder.
+type stubSettingProvider struct {
+ setting.Provider
+ defaults *setting.ShareDefaults
+}
+
+func (s stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults {
+ return s.defaults
+}
+
+func (stubSettingProvider) SiteURL(context.Context) *url.URL {
+ return &url.URL{Scheme: "https", Host: "example.com"}
+}
+
+func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false }
+
+// TestListInUserProfileVisibilityResolution ensures a user's empty
+// share_links_in_profile inherits the site-wide default while explicit
+// values always win (upstream #3390).
+func TestListInUserProfileVisibilityResolution(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ hasher, err := hashid.New("test-salt")
+ require.NoError(t, err)
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+
+ newOwner := func(email string, level types.ShareLinksInProfileLevel) int {
+ owner := client.User.Create().
+ SetEmail(email).
+ SetNick("owner").
+ SetStatus("active").
+ SetGroup(group).
+ SetSettings(&types.UserSetting{ShareLinksInProfile: level}).
+ SaveX(ctx)
+ file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SaveX(ctx)
+ client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx) // public share
+ client.Share.Create().SetUser(owner).SetFile(file).SetPassword("pw").SaveX(ctx) // private share
+ return owner.ID
+ }
+
+ newCtx := func(dep dependency.Dep, requesterID int) *gin.Context {
+ w := httptest.NewRecorder()
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(w, engine)
+ c.Request = httptest.NewRequest("GET", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dep)
+ util.WithValue(c, inventory.UserCtx{}, client.User.GetX(context.Background(), requesterID))
+ return c
+ }
+
+ newDep := func(defaults *setting.ShareDefaults) dependency.Dep {
+ return dependency.NewDependency(
+ dependency.WithKV(cache.NewMemoStore("", nil)),
+ dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)),
+ dependency.WithUserClient(inventory.NewUserClient(client)),
+ dependency.WithShareClient(inventory.NewShareClient(client, conf.SQLiteDB, hasher)),
+ dependency.WithHashIDEncoder(hasher),
+ dependency.WithSettingProvider(stubSettingProvider{defaults: defaults}),
+ )
+ }
+
+ svc := &ListShareService{PageSize: 20}
+
+ tests := []struct {
+ name string
+ userLevel types.ShareLinksInProfileLevel
+ siteDefault types.ShareLinksInProfileLevel
+ wantCount int
+ wantErr bool
+ }{
+ {name: "unset inherits site default all_share", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileAllShare, wantCount: 2},
+ {name: "unset inherits site default public only", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfilePublicShareOnly, wantCount: 1},
+ {name: "unset inherits site default hide", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileHideShare, wantErr: true},
+ {name: "explicit all_share overrides site public", userLevel: types.ProfileAllShare, siteDefault: types.ProfilePublicShareOnly, wantCount: 2},
+ {name: "explicit public_share overrides site all", userLevel: types.ProfileSharePublic, siteDefault: types.ProfileAllShare, wantCount: 1},
+ {name: "explicit hide overrides site all", userLevel: types.ProfileHideShare, siteDefault: types.ProfileAllShare, wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ uid := newOwner(tt.name+"@example.com", tt.userLevel)
+ dep := newDep(&setting.ShareDefaults{LinksInProfile: tt.siteDefault})
+ c := newCtx(dep, uid)
+
+ res, err := svc.ListInUserProfile(c, uid)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Len(t, res.Shares, tt.wantCount)
+ })
+ }
+}
diff --git a/service/user/email_change.go b/service/user/email_change.go
new file mode 100644
index 00000000..e0c3817c
--- /dev/null
+++ b/service/user/email_change.go
@@ -0,0 +1,123 @@
+package user
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/auth"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
+ "github.com/cloudreve/Cloudreve/v4/pkg/email"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ emailChangeSessionKey = "email_change_"
+ emailChangeTTL = 24 * 3600
+)
+
+type (
+ // RequestEmailChangeService starts the email-change flow: validates the new
+ // address and the current password, stores the pending address in KV, and
+ // sends a signed confirmation link to the new mailbox.
+ RequestEmailChangeService struct {
+ NewEmail string `json:"new_email" binding:"required,email"`
+ Password string `json:"password" binding:"required"`
+ }
+ RequestEmailChangeParamCtx struct{}
+)
+
+func (s *RequestEmailChangeService) Request(c *gin.Context) error {
+ dep := dependency.FromContext(c)
+ u := inventory.UserFromContext(c)
+ userClient := dep.UserClient()
+
+ if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
+ return err
+ }
+
+ if err := inventory.CheckPassword(u, s.Password); err != nil {
+ return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)
+ }
+
+ if err := CheckEmailAllowed(dep.SettingProvider().EmailFilter(c), s.NewEmail); err != nil {
+ return err
+ }
+
+ if _, err := userClient.GetByEmail(c, s.NewEmail); err == nil {
+ return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
+ }
+
+ // The pending address lives in KV — the signed link carries only the user
+ // id, so the target address can't be tampered with.
+ kv := dep.KV()
+ if err := kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), s.NewEmail, emailChangeTTL); err != nil {
+ return serializer.NewError(serializer.CodeInternalSetting, "Failed to store email change session", err)
+ }
+
+ base := dep.SettingProvider().SiteURL(c)
+ userID := hashid.EncodeUserID(dep.HashIDEncoder(), u.ID)
+ ttl := time.Now().Add(emailChangeTTL * time.Second)
+ activateURL, err := auth.SignURI(c, dep.GeneralAuth(), routes.MasterUserActivateEmailAPIUrl(base, userID).String(), &ttl)
+ if err != nil {
+ return serializer.NewError(serializer.CodeEncryptError, "Failed to sign the activation link", err)
+ }
+
+ credential := activateURL.Query().Get("sign")
+ finalURL := routes.MasterUserActivateEmailUrl(base)
+ queries := finalURL.Query()
+ queries.Add("id", userID)
+ queries.Add("sign", credential)
+ finalURL.RawQuery = queries.Encode()
+
+ title, body, err := email.NewActivationEmail(c, dep.SettingProvider(), u, finalURL.String())
+ if err != nil {
+ return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
+ }
+
+ if err := dep.EmailClient(c).Send(c, s.NewEmail, title, body); err != nil {
+ return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
+ }
+
+ return nil
+}
+
+// ActivateEmailChange applies a pending email change once the signed link
+// from the confirmation mail is opened. The target address comes from KV so a
+// valid signature alone can't pick an arbitrary address.
+func ActivateEmailChange(c *gin.Context) error {
+ uid := hashid.FromContext(c)
+ dep := dependency.FromContext(c)
+ userClient := dep.UserClient()
+ kv := dep.KV()
+
+ pending, ok := kv.Get(fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
+ if !ok {
+ return serializer.NewError(serializer.CodeParamErr, "No pending email change", nil)
+ }
+ newEmail, ok := pending.(string)
+ if !ok {
+ return serializer.NewError(serializer.CodeInternalSetting, "Invalid pending email change", nil)
+ }
+
+ target, err := userClient.GetByID(c, uid)
+ if err != nil {
+ return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
+ }
+
+ if _, err := userClient.GetByEmail(c, newEmail); err == nil {
+ return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
+ }
+
+ target.Email = newEmail
+ if _, err := userClient.Upsert(c, target, "", ""); err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to update email", err)
+ }
+
+ kv.Delete("", fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
+ return nil
+}
diff --git a/service/user/email_change_test.go b/service/user/email_change_test.go
new file mode 100644
index 00000000..e1829a6e
--- /dev/null
+++ b/service/user/email_change_test.go
@@ -0,0 +1,77 @@
+package user
+
+import (
+ "context"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/cache"
+ "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+// TestActivateEmailChange verifies the signed-link confirmation path: pending
+// address comes from KV, the change applies once, and replays/concurrent
+// claims are rejected.
+func TestActivateEmailChange(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ u := client.User.Create().
+ SetEmail("old@example.com").
+ SetNick("change").
+ SetStatus("active").
+ SetGroup(group).
+ SetSettings(&types.UserSetting{}).
+ SaveX(ctx)
+ client.User.Create().
+ SetEmail("taken@example.com").
+ SetNick("taken").
+ SetStatus("active").
+ SetGroup(group).
+ SetSettings(&types.UserSetting{}).
+ SaveX(ctx)
+
+ kv := cache.NewMemoStore("", nil)
+ dep := dependency.NewDependency(
+ dependency.WithUserClient(inventory.NewUserClient(client)),
+ dependency.WithKV(kv),
+ )
+
+ newCtx := func(uid int) *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("GET", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dep)
+ util.WithValue(c, hashid.ObjectIDCtx{}, uid)
+ return c
+ }
+
+ // No pending change → rejected.
+ require.Error(t, ActivateEmailChange(newCtx(u.ID)))
+
+ // Pending change applies.
+ require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "new@example.com", 60))
+ require.NoError(t, ActivateEmailChange(newCtx(u.ID)))
+ require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
+
+ // Replay after success is rejected (KV entry consumed).
+ require.Error(t, ActivateEmailChange(newCtx(u.ID)))
+
+ // Address claimed between request and confirm is rejected.
+ require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "taken@example.com", 60))
+ require.Error(t, ActivateEmailChange(newCtx(u.ID)))
+ require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
+}
diff --git a/service/user/login.go b/service/user/login.go
index d466ccdb..6e775285 100644
--- a/service/user/login.go
+++ b/service/user/login.go
@@ -3,6 +3,8 @@ package user
import (
"context"
"fmt"
+ "net/netip"
+ "strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
@@ -88,8 +90,12 @@ func (service *UserResetEmailService) Reset(c *gin.Context) error {
return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
+ if u, err = userClient.LiftExpiredBan(c, u); err != nil {
+ return serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err)
+ }
+
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
- return serializer.NewError(serializer.CodeUserBaned, "This user is banned", nil)
+ return banError(u, "This user is banned")
}
if u.Status == user.StatusInactive {
@@ -127,6 +133,9 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := userClient.GetByEmail(ctx, service.UserName)
+ if err == nil {
+ expectedUser, err = userClient.LiftExpiredBan(ctx, expectedUser)
+ }
// 一系列校验
if err != nil {
@@ -134,9 +143,11 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error
} else if checkErr := inventory.CheckPassword(expectedUser, service.Password); checkErr != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned {
- err = serializer.NewError(serializer.CodeUserBaned, "This account has been blocked", nil)
+ err = banError(expectedUser, "This account has been blocked")
} else if expectedUser.Status == user.StatusInactive {
err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil)
+ } else if ipErr := checkLoginIPWhitelist(c.ClientIP(), expectedUser.Edges.Group); ipErr != nil {
+ err = ipErr
}
if err != nil {
@@ -159,6 +170,12 @@ type (
func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
+
+ // Best-effort last-login stamp; a failed update must not block sign-in.
+ if err := dep.UserClient().UpdateLastLogin(c, u.ID); err != nil {
+ dep.Logger().Warning("Failed to update last_login for user %d: %s", u.ID, err)
+ }
+
token, err := dep.TokenAuth().Issue(c, &auth.IssueTokenArgs{
User: u,
RootTokenID: nil,
@@ -263,3 +280,35 @@ func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginRespon
PasswordEnabled: expectedUser.Password != "",
}, nil
}
+
+// checkLoginIPWhitelist enforces a group's login IP whitelist. Entries are
+// exact IPs or CIDR ranges; an empty or nil list allows all addresses.
+// Malformed entries are ignored so a bad admin value cannot lock everyone out.
+func checkLoginIPWhitelist(clientIP string, group *ent.Group) error {
+ if group == nil || group.Settings == nil || len(group.Settings.LoginIPWhitelist) == 0 {
+ return nil
+ }
+
+ addr, err := netip.ParseAddr(clientIP)
+ if err != nil {
+ return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot determine client IP", err)
+ }
+
+ for _, entry := range group.Settings.LoginIPWhitelist {
+ entry = strings.TrimSpace(entry)
+ if entry == "" {
+ continue
+ }
+ if prefix, perr := netip.ParsePrefix(entry); perr == nil {
+ if prefix.Contains(addr) {
+ return nil
+ }
+ continue
+ }
+ if entryAddr, aerr := netip.ParseAddr(entry); aerr == nil && entryAddr == addr {
+ return nil
+ }
+ }
+
+ return serializer.NewError(serializer.CodeNoPermissionErr, "Login from this IP address is not allowed", nil)
+}
diff --git a/service/user/response.go b/service/user/response.go
index ac063a0d..d2c053a9 100644
--- a/service/user/response.go
+++ b/service/user/response.go
@@ -24,15 +24,18 @@ type PreparePasskeyLoginResponse struct {
}
type UserSettings struct {
- VersionRetentionEnabled bool `json:"version_retention_enabled"`
- VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
- VersionRetentionMax int `json:"version_retention_max,omitempty"`
- Paswordless bool `json:"passwordless"`
- TwoFAEnabled bool `json:"two_fa_enabled"`
- Passkeys []Passkey `json:"passkeys,omitempty"`
- DisableViewSync bool `json:"disable_view_sync"`
- ShareLinksInProfile string `json:"share_links_in_profile"`
- OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
+ VersionRetentionEnabled bool `json:"version_retention_enabled"`
+ VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
+ VersionRetentionMax int `json:"version_retention_max,omitempty"`
+ Paswordless bool `json:"passwordless"`
+ TwoFAEnabled bool `json:"two_fa_enabled"`
+ Passkeys []Passkey `json:"passkeys,omitempty"`
+ DisableViewSync bool `json:"disable_view_sync"`
+ ShareLinksInProfile string `json:"share_links_in_profile"`
+ ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
+ PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
+ TrashRetention int `json:"trash_retention,omitempty"`
+ OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings {
@@ -47,6 +50,9 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
}),
DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
+ ShareDefaultPrivate: u.Settings.ShareDefaultPrivate,
+ PreferredViewers: u.Settings.PreferredViewers,
+ TrashRetention: u.Settings.TrashRetention,
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item)
}),
@@ -118,6 +124,12 @@ type User struct {
Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"`
ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
+ // ShareDefaultPrivate is the user's own private-share override; nil means
+ // the site default applies. Never resolved here — the dialog computes the
+ // effective default from user ?? site.
+ ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
+ // PreferredViewers maps file extensions to viewer IDs ("always open with").
+ PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
}
type Group struct {
@@ -175,6 +187,8 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile,
+ ShareDefaultPrivate: user.Settings.ShareDefaultPrivate,
+ PreferredViewers: user.Settings.PreferredViewers,
}
}
@@ -230,12 +244,17 @@ func BuildUserRedacted(ctx context.Context, u *ent.User, level int, idEncoder ha
userRaw := BuildUser(u, idEncoder)
user := User{
- ID: userRaw.ID,
- Nickname: userRaw.Nickname,
- Avatar: userRaw.Avatar,
- CreatedAt: userRaw.CreatedAt,
+ ID: userRaw.ID,
+ Nickname: userRaw.Nickname,
+ Avatar: userRaw.Avatar,
+ CreatedAt: userRaw.CreatedAt,
+ // Profile visitors see the effective visibility: an unset user
+ // preference resolves to the site-wide default (#3390).
ShareLinksInProfile: userRaw.ShareLinksInProfile,
}
+ if user.ShareLinksInProfile == types.ProfilePublicShareOnly {
+ user.ShareLinksInProfile = dependency.FromContext(ctx).SettingProvider().ShareDefaults(ctx).LinksInProfile
+ }
if userRaw.Group != nil {
user.Group = RedactedGroup(userRaw.Group)
diff --git a/service/user/setting.go b/service/user/setting.go
index 58d821c9..293282e9 100644
--- a/service/user/setting.go
+++ b/service/user/setting.go
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "regexp"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
@@ -230,10 +231,39 @@ type (
TwoFACode *string `json:"two_fa_code" binding:"omitempty"`
DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"`
ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"`
+ // ShareDefaultPrivate accepts "true", "false" or "" (clear the
+ // override and inherit the site default).
+ ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"`
+ // PreferredViewers replaces the whole extension → viewer-id map.
+ PreferredViewers *map[string]string `json:"preferred_viewers" binding:"omitempty"`
+ // TrashRetention overrides group trash retention, in seconds.
+ // 0 clears the override. Capped at ~10 years.
+ TrashRetention *int `json:"trash_retention" binding:"omitempty,min=0,max=315360000"`
}
PatchUserSettingParamsCtx struct{}
)
+var (
+ preferredViewerExtPattern = regexp.MustCompile(`^[a-z0-9]{1,20}$`)
+ preferredViewerIDPattern = regexp.MustCompile(`^[\w.\-:]{1,64}$`)
+)
+
+const preferredViewersMaxEntries = 200
+
+// validatePreferredViewers bounds the extension → viewer-id map to sane
+// shapes so it can't be abused as arbitrary JSON storage.
+func validatePreferredViewers(m map[string]string) error {
+ if len(m) > preferredViewersMaxEntries {
+ return serializer.NewError(serializer.CodeParamErr, "Too many preferred viewers", nil)
+ }
+ for ext, viewerID := range m {
+ if !preferredViewerExtPattern.MatchString(ext) || !preferredViewerIDPattern.MatchString(viewerID) {
+ return serializer.NewError(serializer.CodeParamErr, "Invalid preferred viewer entry", nil)
+ }
+ }
+ return nil
+}
+
func (s *PatchUserSetting) Patch(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
@@ -277,7 +307,38 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
}
if s.ShareLinksInProfile != nil {
- u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
+ level := types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
+ switch level {
+ case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare, types.ProfileSharePublic:
+ default:
+ return serializer.NewError(serializer.CodeParamErr, "Invalid share links visibility", nil)
+ }
+ u.Settings.ShareLinksInProfile = level
+ saveSetting = true
+ }
+
+ if s.ShareDefaultPrivate != nil {
+ if *s.ShareDefaultPrivate != "" && *s.ShareDefaultPrivate != "true" && *s.ShareDefaultPrivate != "false" {
+ return serializer.NewError(serializer.CodeParamErr, "Invalid share privacy default", nil)
+ }
+ u.Settings.ShareDefaultPrivate = nil
+ if *s.ShareDefaultPrivate != "" {
+ v := *s.ShareDefaultPrivate == "true"
+ u.Settings.ShareDefaultPrivate = &v
+ }
+ saveSetting = true
+ }
+
+ if s.PreferredViewers != nil {
+ if err := validatePreferredViewers(*s.PreferredViewers); err != nil {
+ return err
+ }
+ u.Settings.PreferredViewers = *s.PreferredViewers
+ saveSetting = true
+ }
+
+ if s.TrashRetention != nil {
+ u.Settings.TrashRetention = *s.TrashRetention
saveSetting = true
}
diff --git a/service/user/setting_test.go b/service/user/setting_test.go
new file mode 100644
index 00000000..c9e724e4
--- /dev/null
+++ b/service/user/setting_test.go
@@ -0,0 +1,146 @@
+package user
+
+import (
+ "context"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/application/dependency"
+ "github.com/cloudreve/Cloudreve/v4/ent/enttest"
+ "github.com/cloudreve/Cloudreve/v4/inventory"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
+ "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
+ "github.com/cloudreve/Cloudreve/v4/pkg/util"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPatchUserSettingShareDefaults verifies the tri-state private-share
+// override and profile-visibility validation (upstream #3390).
+func TestPatchUserSettingShareDefaults(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ u := client.User.Create().
+ SetEmail("patch@example.com").
+ SetNick("patch").
+ SetStatus("active").
+ SetGroup(group).
+ SetSettings(&types.UserSetting{}).
+ SaveX(ctx)
+
+ newCtx := func() *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("PATCH", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency(
+ dependency.WithUserClient(inventory.NewUserClient(client)),
+ ))
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+ }
+
+ strPtr := func(s string) *string { return &s }
+
+ patch := func(s *PatchUserSetting) error {
+ return s.Patch(newCtx())
+ }
+
+ // Set override on.
+ require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("true")}))
+ require.NotNil(t, u.Settings.ShareDefaultPrivate)
+ require.True(t, *u.Settings.ShareDefaultPrivate)
+
+ // Set override off.
+ require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("false")}))
+ require.NotNil(t, u.Settings.ShareDefaultPrivate)
+ require.False(t, *u.Settings.ShareDefaultPrivate)
+
+ // Persisted to DB.
+ persisted := client.User.GetX(ctx, u.ID)
+ require.NotNil(t, persisted.Settings.ShareDefaultPrivate)
+ require.False(t, *persisted.Settings.ShareDefaultPrivate)
+
+ // Empty clears the override back to inheritance.
+ require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("")}))
+ require.Nil(t, u.Settings.ShareDefaultPrivate)
+
+ // Malformed value rejected.
+ require.Error(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("yes")}))
+
+ // Valid profile visibility values accepted.
+ for _, v := range []string{"", "public_share", "all_share", "hide_share"} {
+ require.NoError(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr(v)}))
+ require.Equal(t, types.ShareLinksInProfileLevel(v), u.Settings.ShareLinksInProfile)
+ }
+
+ // Invalid profile visibility rejected.
+ require.Error(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr("friends_only")}))
+}
+
+// TestPatchUserSettingPreferredViewers verifies the extension → viewer-id map
+// is persisted and validated (upstream: persist "always open with" in DB).
+func TestPatchUserSettingPreferredViewers(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ ctx := context.Background()
+
+ group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
+ u := client.User.Create().
+ SetEmail("viewers@example.com").
+ SetNick("viewers").
+ SetStatus("active").
+ SetGroup(group).
+ SetSettings(&types.UserSetting{}).
+ SaveX(ctx)
+
+ newCtx := func() *gin.Context {
+ engine := gin.New()
+ engine.ContextWithFallback = true
+ c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
+ c.Request = httptest.NewRequest("PATCH", "/", nil)
+ util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency(
+ dependency.WithUserClient(inventory.NewUserClient(client)),
+ ))
+ util.WithValue(c, inventory.UserCtx{}, u)
+ return c
+ }
+
+ patch := func(s *PatchUserSetting) error {
+ return s.Patch(newCtx())
+ }
+
+ // Valid map persists.
+ valid := map[string]string{"md": "codemirror", "epub": "epub"}
+ require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &valid}))
+ require.Equal(t, "codemirror", u.Settings.PreferredViewers["md"])
+
+ persisted := client.User.GetX(ctx, u.ID)
+ require.Equal(t, "epub", persisted.Settings.PreferredViewers["epub"])
+
+ // Empty map clears all preferences.
+ empty := map[string]string{}
+ require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &empty}))
+ require.Empty(t, u.Settings.PreferredViewers)
+
+ // Invalid extension key rejected.
+ badExt := map[string]string{"../evil": "x"}
+ require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badExt}))
+
+ // Invalid viewer id rejected.
+ badID := map[string]string{"md": "../../etc"}
+ require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badID}))
+
+ // Oversized map rejected.
+ big := map[string]string{}
+ for i := 0; i < preferredViewersMaxEntries+1; i++ {
+ big[string(rune('a'+i%26))+strconv.Itoa(i)] = "v"
+ }
+ require.Error(t, patch(&PatchUserSetting{PreferredViewers: &big}))
+}
diff --git a/service/user/sso.go b/service/user/sso.go
index 6784bd2b..4eebf411 100644
--- a/service/user/sso.go
+++ b/service/user/sso.go
@@ -1,6 +1,7 @@
package user
import (
+ "context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
@@ -170,9 +171,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) {
return
}
- email := strings.ToLower(strings.TrimSpace(claims.Email))
+ // Profile claims resolve across standard and AD FS-style alternates;
+ // AD FS userinfo returns only sub, so the ID token is the primary
+ // source for name/email/upn (#3572).
+ email := firstEmailClaim(claims.Email, claims.UPN, claims.UniqueName)
name := claims.Name
+ if name == "" {
+ name = strings.TrimSpace(claims.GivenName + " " + claims.FamilyName)
+ }
preferred := claims.PreferredUsername
+ if preferred == "" {
+ preferred = claims.UniqueName
+ }
// Some providers omit email from the ID token; fall back to userinfo.
if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" {
@@ -180,12 +190,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) {
if err != nil {
dep.Logger().Warning("SSO userinfo request failed: %s", err)
} else {
- email = strings.ToLower(strings.TrimSpace(info.Email))
+ email = firstEmailClaim(info.Email, info.UPN, info.UniqueName)
if name == "" {
name = info.Name
+ if name == "" {
+ name = strings.TrimSpace(info.GivenName + " " + info.FamilyName)
+ }
}
if preferred == "" {
preferred = info.PreferredUsername
+ if preferred == "" {
+ preferred = info.UniqueName
+ }
}
}
}
@@ -239,11 +255,15 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid SSO ticket", nil)
}
- u, err := dep.UserClient().GetByID(c, uid)
+ ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
+ u, err := dep.UserClient().GetByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
- if err := checkUserStatus(u); err != nil {
+ if u, err = dep.UserClient().LiftExpiredBan(c, u); err != nil {
+ return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err)
+ }
+ if err := checkUserStatus(c, u); err != nil {
return nil, err
}
@@ -255,9 +275,12 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) {
func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, name, preferred string) (*ent.User, error) {
userClient := dep.UserClient()
- u, err := userClient.GetByEmail(c, email)
+ u, err := userClient.GetByEmail(context.WithValue(c, inventory.LoadUserGroup{}, true), email)
if err == nil {
- if err := checkUserStatus(u); err != nil {
+ if u, err = userClient.LiftExpiredBan(c, u); err != nil {
+ return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err)
+ }
+ if err := checkUserStatus(c, u); err != nil {
return nil, err
}
return u, nil
@@ -291,14 +314,25 @@ func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email,
return newUser, nil
}
-func checkUserStatus(u *ent.User) error {
+// banError renders the ban error. When a ban reason is configured it is
+// sent as the message so the frontend can render it next to the
+// localized "blocked" text (#2478).
+func banError(u *ent.User, fallback string) error {
+ msg := fallback
+ if u.BanReason != "" {
+ msg = u.BanReason
+ }
+ return serializer.NewError(serializer.CodeUserBaned, msg, nil)
+}
+
+func checkUserStatus(c *gin.Context, u *ent.User) error {
switch u.Status {
case user.StatusSysBanned, user.StatusManualBanned:
- return serializer.NewError(serializer.CodeUserBaned, "User is banned", nil)
+ return banError(u, "User is banned")
case user.StatusInactive:
return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil)
}
- return nil
+ return checkLoginIPWhitelist(c.ClientIP(), u.Edges.Group)
}
// CheckEmailAllowed enforces the sign-up email filter. Shared by the classic
@@ -309,8 +343,14 @@ func CheckEmailAllowed(filter *setting.EmailFilter, email string) error {
return serializer.NewError(serializer.CodeParamErr, "Invalid email", nil)
}
- if filter.DisableSubAddress && strings.Contains(local, "+") {
- return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil)
+ if filter.DisableSubAddress {
+ chars := filter.SubAddressChars
+ if chars == "" {
+ chars = "+"
+ }
+ if strings.ContainsAny(local, chars) {
+ return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil)
+ }
}
domain = strings.ToLower(domain)
@@ -582,3 +622,16 @@ func fetchOIDCUserInfo(c *gin.Context, client request.Client, endpoint, accessTo
return &info, nil
}
+
+// firstEmailClaim returns the first candidate usable as an email address.
+// AD FS upn/unique_name are accepted only when email-shaped, since
+// DOMAIN\user and non-routable identifiers cannot serve as addresses.
+func firstEmailClaim(candidates ...string) string {
+ for _, c := range candidates {
+ c = strings.ToLower(strings.TrimSpace(c))
+ if strings.Contains(c, "@") {
+ return c
+ }
+ }
+ return ""
+}
diff --git a/service/user/sso_test.go b/service/user/sso_test.go
index 2b0fbcdf..7450294b 100644
--- a/service/user/sso_test.go
+++ b/service/user/sso_test.go
@@ -3,6 +3,8 @@ package user
import (
"testing"
+ "github.com/cloudreve/Cloudreve/v4/ent"
+ "github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require"
@@ -103,6 +105,35 @@ func TestCheckEmailAllowed(t *testing.T) {
},
email: "a+tag@example.com",
},
+ {
+ name: "custom sub-address chars rejected",
+ filter: &setting.EmailFilter{
+ Mode: setting.EmailFilterDisabled,
+ DisableSubAddress: true,
+ SubAddressChars: "+-.",
+ },
+ email: "a-tag@example.com",
+ wantErr: true,
+ code: serializer.CodeParamErr,
+ },
+ {
+ name: "custom sub-address chars allow plain local part",
+ filter: &setting.EmailFilter{
+ Mode: setting.EmailFilterDisabled,
+ DisableSubAddress: true,
+ SubAddressChars: "+-.",
+ },
+ email: "user@example.com",
+ },
+ {
+ name: "dot in domain not treated as sub-address",
+ filter: &setting.EmailFilter{
+ Mode: setting.EmailFilterDisabled,
+ DisableSubAddress: true,
+ SubAddressChars: "+-.",
+ },
+ email: "user@mail.example.com",
+ },
{
name: "invalid email rejected",
filter: &setting.EmailFilter{Mode: setting.EmailFilterDisabled},
@@ -135,3 +166,33 @@ func TestSanitizeSSORedirect(t *testing.T) {
require.Equal(t, "/home", sanitizeSSORedirect("/home"))
require.Equal(t, "/share/s/abc", sanitizeSSORedirect("/share/s/abc"))
}
+
+func TestFirstEmailClaim(t *testing.T) {
+ require.Equal(t, "user@corp.com", firstEmailClaim("User@Corp.com"))
+ require.Equal(t, "user@corp.com", firstEmailClaim(" User@Corp.COM "))
+ require.Equal(t, "user@corp.com", firstEmailClaim("", `CORP\user`, "user@corp.com"))
+ require.Equal(t, "", firstEmailClaim("", `CORP\user`, "not-an-email"))
+ require.Equal(t, "", firstEmailClaim())
+}
+
+func TestCheckLoginIPWhitelist(t *testing.T) {
+ group := func(list ...string) *ent.Group {
+ return &ent.Group{Settings: &types.GroupSetting{LoginIPWhitelist: list}}
+ }
+
+ // Empty whitelist allows everything.
+ require.NoError(t, checkLoginIPWhitelist("1.2.3.4", nil))
+ require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group()))
+
+ // Exact IP match.
+ require.NoError(t, checkLoginIPWhitelist("10.0.0.5", group("10.0.0.5", "192.168.0.0/16")))
+ require.Error(t, checkLoginIPWhitelist("10.0.0.6", group("10.0.0.5")))
+
+ // CIDR ranges, v4 and v6.
+ require.NoError(t, checkLoginIPWhitelist("192.168.1.9", group("192.168.0.0/16")))
+ require.NoError(t, checkLoginIPWhitelist("fd00::42", group("fd00::/8")))
+ require.Error(t, checkLoginIPWhitelist("8.8.8.8", group("192.168.0.0/16", "fd00::/8")))
+
+ // Malformed entries are skipped rather than locking everyone out.
+ require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group("not-an-ip", "1.2.3.4")))
+}