diff --git a/package-lock.json b/package-lock.json
index 12fba59..17317ad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
"axios": "^1.6.7",
"buffer": "^6.0.3",
"clsx": "^2.1.0",
- "jssha": "^3.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.0",
@@ -25,6 +24,7 @@
"react-router-dom": "^6.22.2",
"react-tag-input": "^6.10.3",
"tailwind-merge": "^2.2.1",
+ "tus-js-client": "^4.3.1",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
@@ -2298,6 +2298,12 @@
"ieee754": "^1.2.1"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -2434,6 +2440,15 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
+ "node_modules/combine-errors": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz",
+ "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==",
+ "dependencies": {
+ "custom-error-instance": "2.1.1",
+ "lodash.uniqby": "4.5.0"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2496,6 +2511,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
+ "node_modules/custom-error-instance": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz",
+ "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==",
+ "license": "ISC"
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -3683,6 +3704,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -4062,6 +4089,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-string": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
@@ -4158,6 +4197,12 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-base64": {
+ "version": "3.7.8",
+ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
+ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
@@ -4222,15 +4267,6 @@
"node": ">=6"
}
},
- "node_modules/jssha": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz",
- "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": "*"
- }
- },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4294,12 +4330,74 @@
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
+ "node_modules/lodash._baseiteratee": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
+ "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash._stringtopath": "~4.8.0"
+ }
+ },
+ "node_modules/lodash._basetostring": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz",
+ "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash._baseuniq": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz",
+ "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash._createset": "~4.0.0",
+ "lodash._root": "~3.0.0"
+ }
+ },
+ "node_modules/lodash._createset": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz",
+ "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash._root": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz",
+ "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash._stringtopath": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz",
+ "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash._basetostring": "~4.12.0"
+ }
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
+ "node_modules/lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.uniqby": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz",
+ "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash._baseiteratee": "~4.7.0",
+ "lodash._baseuniq": "~4.6.0"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5053,6 +5151,23 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/proper-lockfile": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+ "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "retry": "^0.12.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "node_modules/proper-lockfile/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -5067,6 +5182,12 @@
"node": ">=6"
}
},
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5341,6 +5462,12 @@
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
},
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -5367,6 +5494,15 @@
"node": ">=4"
}
},
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -6040,6 +6176,24 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"dev": true
},
+ "node_modules/tus-js-client": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz",
+ "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.1.2",
+ "combine-errors": "^3.0.3",
+ "is-stream": "^2.0.0",
+ "js-base64": "^3.7.2",
+ "lodash.throttle": "^4.1.1",
+ "proper-lockfile": "^4.1.2",
+ "url-parse": "^1.5.7"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@ -6257,6 +6411,16 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
diff --git a/package.json b/package.json
index 8559d60..d4f55d7 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"react-router-dom": "^6.22.2",
"react-tag-input": "^6.10.3",
"tailwind-merge": "^2.2.1",
+ "tus-js-client": "^4.3.1",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
diff --git a/src/app/router/constants/index.ts b/src/app/router/constants/index.ts
index 09322d5..0c22278 100644
--- a/src/app/router/constants/index.ts
+++ b/src/app/router/constants/index.ts
@@ -2,4 +2,5 @@ export const Routes = {
Root: "/uploadContent",
ViewContent: "/viewContent",
SentryCheck: "/sentryCheck",
+ Admin: "/admin",
};
diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx
index b70c13e..f0cbc5c 100644
--- a/src/app/router/index.tsx
+++ b/src/app/router/index.tsx
@@ -3,6 +3,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { Routes } from "~/app/router/constants";
import { RootPage } from "~/pages/root";
import { ViewContentPage } from "~/pages/view-content";
+import { AdminPage } from "~/pages/admin";
import { ProtectedLayout } from "./protected-layout";
const router = createBrowserRouter([
@@ -11,8 +12,8 @@ const router = createBrowserRouter([
children: [
{ path: Routes.Root, element: },
{ path: Routes.ViewContent, element: },
- {
- path: Routes.SentryCheck,
+ {
+ path: Routes.SentryCheck,
element: (
},
]);
export const AppRouter = () => {
diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx
new file mode 100644
index 0000000..c05abda
--- /dev/null
+++ b/src/pages/admin/index.tsx
@@ -0,0 +1,1085 @@
+import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
+import { useForm } from "react-hook-form";
+import { useQueryClient } from "react-query";
+import clsx from "clsx";
+
+import {
+ isUnauthorizedError,
+ useAdminBlockchain,
+ useAdminCacheCleanup,
+ useAdminCacheSetLimits,
+ useAdminLogin,
+ useAdminLogout,
+ useAdminNodeSetRole,
+ useAdminNodes,
+ useAdminOverview,
+ useAdminStatus,
+ useAdminStorage,
+ useAdminSyncSetLimits,
+ useAdminSystem,
+ useAdminUploads,
+} from "~/shared/services/admin";
+import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
+
+const numberFormatter = new Intl.NumberFormat("ru-RU");
+const dateTimeFormatter = new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "short",
+ timeStyle: "medium",
+});
+
+const formatBytes = (input?: number | null) => {
+ if (!input) {
+ return "0 B";
+ }
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"] as const;
+ let value = input;
+ let unitIndex = 0;
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+ return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`;
+};
+
+const formatDate = (iso?: string | null) => {
+ if (!iso) {
+ return "—";
+ }
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) {
+ return iso;
+ }
+ return dateTimeFormatter.format(date);
+};
+
+const Section = ({
+ id,
+ title,
+ description,
+ children,
+ actions,
+}: {
+ id: string;
+ title: string;
+ description?: string;
+ children: ReactNode;
+ actions?: ReactNode;
+}) => {
+ return (
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {actions ?
{actions}
: null}
+
+ {children}
+
+ );
+};
+
+type BadgeTone = "neutral" | "success" | "danger" | "warn";
+
+const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => {
+ const toneMap: Record
= {
+ neutral: "bg-slate-800 text-slate-200",
+ success: "bg-emerald-900/70 text-emerald-200",
+ danger: "bg-rose-900/60 text-rose-100",
+ warn: "bg-amber-900/60 text-amber-100",
+ };
+ return (
+
+ {children}
+
+ );
+};
+
+type FlashMessage = {
+ type: "success" | "error" | "info";
+ message: string;
+};
+
+type AuthState = "checking" | "authorized" | "unauthorized";
+
+const ADMIN_SECTIONS = [
+ { id: "overview", label: "Обзор" },
+ { id: "storage", label: "Хранилище" },
+ { id: "uploads", label: "Загрузки" },
+ { id: "system", label: "Система" },
+ { id: "blockchain", label: "Блокчейн" },
+ { id: "nodes", label: "Ноды" },
+ { id: "status", label: "Статус & лимиты" },
+] as const;
+
+type CacheLimitsFormValues = {
+ max_gb: number;
+ ttl_days: number;
+};
+
+type CacheFitFormValues = {
+ max_gb: number;
+};
+
+type SyncLimitsFormValues = {
+ max_concurrent_pins: number;
+ disk_low_watermark_pct: number;
+};
+
+const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
+
+export const AdminPage = () => {
+ const queryClient = useQueryClient();
+ const [authState, setAuthState] = useState(initialAuthState);
+ const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview");
+ const [token, setToken] = useState("");
+ const [flash, setFlash] = useState(null);
+
+ useEffect(() => {
+ if (!flash) {
+ return;
+ }
+ const timeout = setTimeout(() => setFlash(null), 6000);
+ return () => clearTimeout(timeout);
+ }, [flash]);
+
+ useEffect(() => {
+ if (authState === "unauthorized") {
+ queryClient.removeQueries({
+ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
+ });
+ }
+ }, [authState, queryClient]);
+
+ const overviewQuery = useAdminOverview({
+ enabled: authState !== "unauthorized",
+ retry: false,
+ onSuccess: () => {
+ setAuthState((prev) => (prev === "authorized" ? prev : "authorized"));
+ },
+ onError: (error) => {
+ if (isUnauthorizedError(error)) {
+ clearAdminAuth();
+ setAuthState("unauthorized");
+ setFlash({ type: "info", message: "Введите админ-токен, чтобы продолжить" });
+ return;
+ }
+ setFlash({ type: "error", message: `Не удалось загрузить обзор: ${error.message}` });
+ },
+ });
+
+ const isDataEnabled = authState === "authorized";
+
+ const storageQuery = useAdminStorage({
+ enabled: isDataEnabled,
+ refetchInterval: 60_000,
+ });
+ const uploadsQuery = useAdminUploads({
+ enabled: isDataEnabled,
+ refetchInterval: 30_000,
+ });
+ const systemQuery = useAdminSystem({
+ enabled: isDataEnabled,
+ refetchInterval: 60_000,
+ });
+ const blockchainQuery = useAdminBlockchain({
+ enabled: isDataEnabled,
+ refetchInterval: 30_000,
+ });
+ const nodesQuery = useAdminNodes({
+ enabled: isDataEnabled,
+ refetchInterval: 60_000,
+ });
+ const statusQuery = useAdminStatus({
+ enabled: isDataEnabled,
+ refetchInterval: 30_000,
+ });
+
+ const loginMutation = useAdminLogin({
+ onSuccess: async () => {
+ setAuthState("authorized");
+ setFlash({ type: "success", message: "Админ-сессия активирована" });
+ setToken("");
+ await queryClient.invalidateQueries({
+ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
+ });
+ },
+ onError: (error) => {
+ if (isUnauthorizedError(error)) {
+ clearAdminAuth();
+ setFlash({ type: "error", message: "Неверный токен" });
+ return;
+ }
+ setFlash({ type: "error", message: `Ошибка входа: ${error.message}` });
+ },
+ });
+
+ const logoutMutation = useAdminLogout({
+ onSuccess: async () => {
+ setAuthState("unauthorized");
+ setFlash({ type: "info", message: "Сессия завершена" });
+ await queryClient.invalidateQueries({
+ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
+ });
+ },
+ onError: (error) => {
+ setFlash({ type: "error", message: `Ошибка выхода: ${error.message}` });
+ },
+ });
+
+ const cacheLimitsMutation = useAdminCacheSetLimits({
+ onSuccess: async () => {
+ setFlash({ type: "success", message: "Лимиты кэша обновлены" });
+ await statusQuery.refetch();
+ },
+ onError: (error) => {
+ setFlash({ type: "error", message: `Не удалось сохранить лимиты кэша: ${error.message}` });
+ },
+ });
+
+ const cacheCleanupMutation = useAdminCacheCleanup({
+ onSuccess: async (data) => {
+ const removed = typeof data.removed === "number" ? `Удалено файлов: ${data.removed}` : "";
+ setFlash({ type: "success", message: `Очистка кэша выполнена. ${removed}`.trim() });
+ await statusQuery.refetch();
+ },
+ onError: (error) => {
+ setFlash({ type: "error", message: `Ошибка очистки кэша: ${error.message}` });
+ },
+ });
+
+ const syncLimitsMutation = useAdminSyncSetLimits({
+ onSuccess: async () => {
+ setFlash({ type: "success", message: "Параметры синхронизации обновлены" });
+ await statusQuery.refetch();
+ },
+ onError: (error) => {
+ setFlash({ type: "error", message: `Не удалось обновить лимиты синхронизации: ${error.message}` });
+ },
+ });
+
+ const nodeRoleMutation = useAdminNodeSetRole({
+ onSuccess: async ({ node }) => {
+ setFlash({
+ type: "success",
+ message: `Роль узла ${node.public_key ?? node.ip ?? ""} обновлена до ${node.role}`,
+ });
+ await nodesQuery.refetch();
+ },
+ onError: (error) => {
+ setFlash({ type: "error", message: `Не удалось обновить роль узла: ${error.message}` });
+ },
+ });
+
+ const cacheLimitsForm = useForm({
+ values: {
+ max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
+ ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0,
+ },
+ });
+
+ const cacheFitForm = useForm({
+ defaultValues: {
+ max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
+ },
+ });
+
+ const syncLimitsForm = useForm({
+ values: {
+ max_concurrent_pins: statusQuery.data?.limits.SYNC_MAX_CONCURRENT_PINS ?? 4,
+ disk_low_watermark_pct: statusQuery.data?.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90,
+ },
+ });
+
+ useEffect(() => {
+ cacheLimitsForm.reset({
+ max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
+ ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0,
+ });
+ cacheFitForm.reset({
+ max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
+ });
+ syncLimitsForm.reset({
+ max_concurrent_pins: statusQuery.data?.limits.SYNC_MAX_CONCURRENT_PINS ?? 4,
+ disk_low_watermark_pct: statusQuery.data?.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90,
+ });
+ }, [statusQuery.data, cacheLimitsForm, cacheFitForm, syncLimitsForm]);
+
+ const overviewCards = useMemo(() => {
+ const data = overviewQuery.data;
+ if (!data) {
+ return [];
+ }
+ return [
+ {
+ label: "Хост",
+ value: data.project.host || "локальный" ,
+ helper: data.project.name,
+ },
+ {
+ label: "TON Master",
+ value: data.node.ton_master,
+ helper: "Платформа",
+ },
+ {
+ label: "Service Wallet",
+ value: data.node.service_wallet,
+ helper: data.node.id,
+ },
+ {
+ label: "Контент",
+ value: `${numberFormatter.format(data.content.encrypted_total)} зашифр.`,
+ helper: `${numberFormatter.format(data.content.derivatives_ready)} деривативов`,
+ },
+ {
+ label: "IPFS Repo",
+ value: formatBytes(
+ Number((data.ipfs.repo as Record)?.RepoSize ?? 0),
+ ),
+ helper: "Размер репозитория",
+ },
+ {
+ label: "Bitswap",
+ value: numberFormatter.format(
+ Number((data.ipfs.bitswap as Record)?.Peers ?? 0),
+ ),
+ helper: "Пиры",
+ },
+ {
+ label: "Билд",
+ value: data.codebase.commit ?? "n/a",
+ helper: data.codebase.branch ?? "",
+ },
+ {
+ label: "Python",
+ value: data.runtime.python,
+ helper: data.runtime.implementation,
+ },
+ ];
+ }, [overviewQuery.data]);
+
+ const handleRefreshAll = async () => {
+ await queryClient.invalidateQueries({
+ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
+ });
+ };
+
+ const renderFlash = () => {
+ if (!flash) {
+ return null;
+ }
+ const tone =
+ flash.type === "success"
+ ? "border-emerald-500/60 bg-emerald-500/10 text-emerald-100"
+ : flash.type === "error"
+ ? "border-rose-500/60 bg-rose-500/10 text-rose-100"
+ : "border-sky-500/60 bg-sky-500/10 text-sky-100";
+ return (
+
+ {flash.message}
+
+ );
+ };
+
+ const renderLoginPanel = () => {
+ return (
+
+
Админ-панель узла
+
+ Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению.
+
+
+
+ );
+ };
+
+ const renderOverview = () => {
+ if (!overviewQuery.data) {
+ return overviewQuery.isLoading ? Загрузка обзора…
: Нет данных.
;
+ }
+ const { project, services, ton, runtime, ipfs } = overviewQuery.data;
+ return (
+ overviewQuery.refetch()}
+ >
+ Обновить
+
+ }
+ >
+
+ {overviewCards.map((card) => (
+
+
{card.label}
+
{card.value}
+ {card.helper ?
{card.helper}
: null}
+
+ ))}
+
+
+
+
+
Проект
+
+ {project.host || "—"}
+ {project.name}
+ {project.privacy}
+ {ton.testnet ? "Да" : "Нет"}
+ {ton.api_key_configured ? "Настроен" : "Нет"}
+ {ton.host || "—"}
+
+
+
+
Среда выполнения
+
+ {runtime.python}
+ {runtime.implementation}
+ {runtime.platform}
+ {formatDate(runtime.utc_now)}
+
+
+
+
+
+
+
IPFS
+
+ {(ipfs.identity as Record)?.ID ?? "—"}
+ {(ipfs.identity as Record)?.AgentVersion ?? "—"}
+ {numberFormatter.format(Number((ipfs.bitswap as Record)?.Peers ?? 0))}
+ {formatBytes(Number((ipfs.repo as Record)?.RepoSize ?? 0))}
+ {formatBytes(Number((ipfs.repo as Record)?.StorageMax ?? 0))}
+
+
+
+
+
+ );
+ };
+
+ const renderStorage = () => {
+ if (storageQuery.isLoading) {
+ return ;
+ }
+ if (!storageQuery.data) {
+ return null;
+ }
+ const { directories, disk, derivatives } = storageQuery.data;
+ return (
+
+
+
+
+
+ | Путь |
+ Файлов |
+ Размер |
+ Состояние |
+
+
+
+ {directories.map((dir) => (
+
+ | {dir.path} |
+ {numberFormatter.format(dir.file_count)} |
+ {formatBytes(dir.size_bytes)} |
+
+ {dir.exists ? "OK" : "Нет"}
+ |
+
+ ))}
+
+
+
+ {disk ? (
+
+
Снимок диска
+
+ {disk.path}
+ {formatBytes(disk.free_bytes)}
+ {formatBytes(disk.used_bytes)}
+ {formatBytes(disk.total_bytes)}
+ {disk.percent_used != null ? `${disk.percent_used}%` : "—"}
+
+
+ ) : null}
+
+
Деривативы
+
+ {numberFormatter.format(derivatives.ready)}
+ {numberFormatter.format(derivatives.processing)}
+ {numberFormatter.format(derivatives.pending)}
+ {numberFormatter.format(derivatives.failed)}
+ {formatBytes(derivatives.total_bytes)}
+
+
+
+ );
+ };
+
+ const renderUploads = () => {
+ if (!uploadsQuery.data) {
+ return uploadsQuery.isLoading ? (
+
+ ) : null;
+ }
+ const { states, total, recent } = uploadsQuery.data;
+ return (
+ uploadsQuery.refetch()}
+ >
+ Обновить
+
+ }
+ >
+
+
+ {Object.entries(states).map(([state, count]) => (
+
+ ))}
+
+
+
+
+
+ | ID |
+ Файл |
+ Размер |
+ Статус |
+ Обновлено |
+
+
+
+ {recent.map((item) => (
+
+ | {item.id} |
+ {item.filename || "—"} |
+ {item.size_bytes ? formatBytes(item.size_bytes) : "—"} |
+ {item.state} |
+ {formatDate(item.updated_at)} |
+
+ ))}
+
+
+
+
+ );
+ };
+
+ const renderSystem = () => {
+ if (!systemQuery.data) {
+ return systemQuery.isLoading ? (
+
+ ) : null;
+ }
+ const { env, service_config, services, blockchain_tasks, latest_index_items } = systemQuery.data;
+ return (
+
+
+
+
Окружение
+
+ {Object.entries(env).map(([key, value]) => (
+ {value ?? "—"}
+ ))}
+
+
+
+
Задачи блокчейна
+
+ {Object.entries(blockchain_tasks).map(([key, value]) => (
+ {numberFormatter.format(value)}
+ ))}
+
+
Последние индексы
+
+
+
+
+
ServiceConfig
+
+
+
+
+ | Ключ |
+ Значение |
+ RAW |
+
+
+
+ {service_config.map((item) => (
+
+ | {item.key} |
+ {item.value ?? "—"} |
+ {item.raw ?? "—"} |
+
+ ))}
+
+
+
+
+
+
Сервисы
+
+ {services.map((service) => (
+ -
+ {service.name}
+ {service.status ?? "—"}
+
+ ))}
+
+
+
+ );
+ };
+
+ const renderBlockchain = () => {
+ if (!blockchainQuery.data) {
+ return blockchainQuery.isLoading ? (
+
+ ) : null;
+ }
+ const { counts, recent } = blockchainQuery.data;
+ return (
+ blockchainQuery.refetch()}
+ >
+ Обновить
+
+ }
+ >
+
+ {Object.entries(counts).map(([key, value]) => (
+
+ ))}
+
+
+
+
+
+ | ID |
+ Назначение |
+ Сумма |
+ Статус |
+ Epoch · Seqno |
+ Hash |
+ Обновлено |
+
+
+
+ {recent.map((task) => (
+
+ | {task.id} |
+ {task.destination || "—"} |
+ {task.amount || "—"} |
+ {task.status} |
+ {task.epoch ?? "—"} · {task.seqno ?? "—"} |
+ {task.transaction_hash || "—"} |
+ {formatDate(task.updated)} |
+
+ ))}
+
+
+
+
+ );
+ };
+
+ const renderNodes = () => {
+ if (!nodesQuery.data) {
+ return nodesQuery.isLoading ? (
+
+ ) : null;
+ }
+ const { items } = nodesQuery.data;
+ const handleRoleChange = (nodePublicKey: string | null, nodeIp: string | null) => {
+ return (event: ChangeEvent) => {
+ const role = event.target.value as "trusted" | "read-only" | "deny";
+ nodeRoleMutation.mutate({
+ role,
+ public_key: nodePublicKey ?? undefined,
+ host: nodeIp ?? undefined,
+ });
+ };
+ };
+ return (
+
+
+
+
+
+ | IP |
+ Порт |
+ Публичный ключ |
+ Роль |
+ Версия |
+ Последний онлайн |
+ Заметки |
+
+
+
+ {items.map((node, index) => (
+
+ | {node.ip || "—"} |
+ {node.port ?? "—"} |
+ {node.public_key || "—"} |
+
+
+ |
+ {node.version || "—"} |
+ {formatDate(node.last_seen)} |
+ {node.notes || "—"} |
+
+ ))}
+
+
+
+
+ );
+ };
+
+ const renderStatus = () => {
+ if (!statusQuery.data) {
+ return statusQuery.isLoading ? (
+
+ ) : null;
+ }
+ const { ipfs, pin_counts, derivatives, convert_backlog, limits } = statusQuery.data;
+ return (
+
+
+
+
+
IPFS
+
+ {formatBytes(Number((ipfs.repo as Record)?.RepoSize ?? 0))}
+ {formatBytes(Number((ipfs.repo as Record)?.StorageMax ?? 0))}
+ {numberFormatter.format(Number((ipfs.bitswap as Record)?.Peers ?? 0))}
+ {numberFormatter.format(Number((ipfs.bitswap as Record)?.BlocksReceived ?? 0))}
+
+
+
+
Pin-статистика
+
+ {Object.entries(pin_counts).map(([key, value]) => (
+ {numberFormatter.format(value)}
+ ))}
+ {numberFormatter.format(convert_backlog)}
+ {formatBytes(derivatives.total_bytes)}
+
+
+
+
+
+
+ );
+ };
+
+ const renderDashboard = () => {
+ if (authState === "checking" && overviewQuery.isLoading) {
+ return (
+ Проверяем сессию…
+ );
+ }
+ return (
+
+
+
+
Админ-панель
+
+ Управление узлом, мониторинг IPFS/TON и контроль кэша
+
+
+
+
+
+
+
+
+
+
+ {renderOverview()}
+ {renderStorage()}
+ {renderUploads()}
+ {renderSystem()}
+ {renderBlockchain()}
+ {renderNodes()}
+ {renderStatus()}
+
+ );
+ };
+
+ return (
+
+
+ {renderFlash()}
+ {authState === "authorized" || authState === "checking" ? renderDashboard() : renderLoginPanel()}
+
+
+ );
+};
+
+const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => {
+ return (
+
+
{label}
+ {children}
+
+ );
+};
+
+const MetricCard = ({ label, value }: { label: string; value: string }) => {
+ return (
+
+ );
+};
diff --git a/src/pages/root/steps/presubmit-step/index.tsx b/src/pages/root/steps/presubmit-step/index.tsx
index 5e66324..9e91ef6 100644
--- a/src/pages/root/steps/presubmit-step/index.tsx
+++ b/src/pages/root/steps/presubmit-step/index.tsx
@@ -8,7 +8,7 @@ import ReactPlayer from "react-player/lazy";
import { Button } from "~/shared/ui/button";
import { useRootStore } from "~/shared/stores/root";
import { FormLabel } from "~/shared/ui/form-label";
-import { useUploadFile } from "~/shared/services/file";
+import { useLegacyUploadFile, useTusUpload } from "~/shared/services/file";
import { Progress } from "~/shared/ui/progress";
import { useCreateNewContent } from "~/shared/services/content";
import { BackButton } from "~/shared/ui/back-button";
@@ -31,8 +31,8 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
const [isCoverExpanded, setCoverExpanded] = useState(false);
- const uploadCover = useUploadFile();
- const uploadFile = useUploadFile();
+ const uploadCover = useLegacyUploadFile();
+ const uploadFile = useTusUpload();
const createContent = useCreateNewContent();
@@ -54,9 +54,21 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
try {
let coverUploadResult = { content_id_v1: "" };
- const fileUploadResult = await uploadFile.mutateAsync(
- rootStore.file as File,
- );
+ const fileUploadResult = await uploadFile.mutateAsync({
+ file: rootStore.file as File,
+ metadata: {
+ title: rootStore.name,
+ description: "",
+ content_type: rootStore.file?.type || "application/octet-stream",
+ preview_start_ms: 0,
+ preview_duration_ms: 30000,
+ downloadable: rootStore.allowDwnld ? "1" : "0",
+ },
+ });
+
+ if (!fileUploadResult.encryptedCid) {
+ throw new Error("Tus upload did not return encrypted cid");
+ }
if (rootStore.allowCover && rootStore.cover) {
coverUploadResult = await uploadCover.mutateAsync(
@@ -76,7 +88,7 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
// следует отметить что вы должны еще откомментровать AuthorsStep в RootPage
// authors: rootStore.authors,
downloadable: rootStore.allowDwnld,
- content: fileUploadResult.content_id_v1,
+ content: fileUploadResult.encryptedCid,
image: coverUploadResult.content_id_v1,
price: String(rootStore.price * 10 ** 9),
hashtags: rootStore.hashtags,
diff --git a/src/shared/libs/request/index.ts b/src/shared/libs/request/index.ts
index 9eae009..25df920 100644
--- a/src/shared/libs/request/index.ts
+++ b/src/shared/libs/request/index.ts
@@ -1,17 +1,102 @@
-import axios from 'axios';
+import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
-export const APP_API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
+import { getAdminAuthSnapshot } from '~/shared/libs/admin-auth';
-export const request = axios.create({
- baseURL: APP_API_BASE_URL,
+const API_BASE_PATH = '/api/v1';
+const API_ENDPOINTS = [
+ 'https://my-public-node-8.projscale.dev',
+ 'http://localhost:3000',
+] as const;
+
+const API_BASE_URLS = API_ENDPOINTS.map((endpoint) => {
+ return `${endpoint.replace(/\/$/, '')}${API_BASE_PATH}`;
});
-request.interceptors.request.use((config) => {
- const auth_v1_token = localStorage.getItem('auth_v1_token');
+const ENDPOINT_INDEX_KEY = '__apiEndpointIndex__';
- if (auth_v1_token) {
- config.headers.Authorization = auth_v1_token;
+const isBrowser = typeof window !== 'undefined';
+
+const readStorage = (key: string) => {
+ if (!isBrowser) {
+ return null;
}
+ try {
+ return window.localStorage.getItem(key);
+ } catch (error) {
+ console.error('Failed to access localStorage', error);
+ return null;
+ }
+};
+
+type RequestConfig = InternalAxiosRequestConfig & {
+ [ENDPOINT_INDEX_KEY]?: number;
+};
+
+export const request = axios.create({ withCredentials: true });
+
+request.interceptors.request.use((config: RequestConfig) => {
+ config.headers = config.headers ?? {};
+ const headers = config.headers as Record;
+ const authToken = readStorage('auth_v1_token');
+
+ if (authToken && !headers.Authorization) {
+ headers.Authorization = authToken;
+ }
+
+ const urlPath = config.url ?? '';
+ if (urlPath.startsWith('/admin')) {
+ const { token, headerName } = getAdminAuthSnapshot();
+ if (token) {
+ const headerKey = headerName || 'X-Admin-Token';
+ if (!headers[headerKey]) {
+ headers[headerKey] = token;
+ }
+ }
+ }
+
+ const hasCustomBaseUrl = Boolean(config.baseURL && !API_BASE_URLS.includes(config.baseURL));
+ if (hasCustomBaseUrl) {
+ return config;
+ }
+
+ const attempt = typeof config[ENDPOINT_INDEX_KEY] === 'number' ? (config[ENDPOINT_INDEX_KEY] as number) : 0;
+ config[ENDPOINT_INDEX_KEY] = attempt;
+ config.baseURL = API_BASE_URLS[attempt] ?? API_BASE_URLS[0];
return config;
});
+
+request.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const config = error.config as RequestConfig | undefined;
+
+ if (!config) {
+ return Promise.reject(error);
+ }
+
+ const hasCustomBaseUrl = Boolean(config.baseURL && !API_BASE_URLS.includes(config.baseURL));
+ if (hasCustomBaseUrl) {
+ return Promise.reject(error);
+ }
+
+ const isAbortError = error.code === 'ERR_CANCELED' || error.message === 'canceled';
+ const isNetworkError = !error.response && !isAbortError;
+
+ if (!isNetworkError) {
+ return Promise.reject(error);
+ }
+
+ const currentIndex = typeof config[ENDPOINT_INDEX_KEY] === 'number' ? (config[ENDPOINT_INDEX_KEY] as number) : 0;
+ const nextIndex = currentIndex + 1;
+
+ if (nextIndex >= API_BASE_URLS.length) {
+ return Promise.reject(error);
+ }
+
+ config[ENDPOINT_INDEX_KEY] = nextIndex;
+ config.baseURL = API_BASE_URLS[nextIndex];
+
+ return request(config);
+ },
+);
diff --git a/src/shared/services/admin/index.ts b/src/shared/services/admin/index.ts
new file mode 100644
index 0000000..40080e5
--- /dev/null
+++ b/src/shared/services/admin/index.ts
@@ -0,0 +1,421 @@
+import axios, { AxiosError } from 'axios';
+import {
+ useMutation,
+ useQuery,
+ UseMutationOptions,
+ UseQueryOptions,
+} from 'react-query';
+
+import { request } from "~/shared/libs";
+import { clearAdminAuth, persistAdminAuth } from "~/shared/libs/admin-auth";
+
+export type AdminServiceState = {
+ name: string;
+ status?: string;
+ last_reported_seconds: number | null;
+};
+
+export type AdminOverviewResponse = {
+ project: {
+ host: string | null;
+ name: string;
+ privacy: string;
+ };
+ codebase: {
+ branch: string | null;
+ commit: string | null;
+ };
+ node: {
+ id: string;
+ service_wallet: string;
+ ton_master: string;
+ };
+ runtime: {
+ python: string;
+ implementation: string;
+ platform: string;
+ utc_now: string;
+ };
+ ipfs: Record & {
+ identity?: Record | { error: string };
+ bitswap?: Record | { error: string };
+ repo?: Record | { error: string };
+ };
+ content: {
+ encrypted_total: number;
+ upload_sessions_total: number;
+ derivatives_ready: number;
+ };
+ ton: {
+ host: string | null;
+ api_key_configured: boolean;
+ testnet: boolean;
+ };
+ services: AdminServiceState[];
+};
+
+export type AdminStorageResponse = {
+ directories: Array<{
+ label: string;
+ path: string;
+ exists: boolean;
+ file_count: number;
+ size_bytes: number;
+ }>;
+ disk: null | {
+ path: string;
+ total_bytes: number;
+ used_bytes: number;
+ free_bytes: number;
+ percent_used: number | null;
+ };
+ derivatives: {
+ ready: number;
+ processing: number;
+ pending: number;
+ failed: number;
+ total_bytes: number;
+ };
+};
+
+export type AdminUploadsResponse = {
+ total: number;
+ states: Record;
+ recent: Array<{
+ id: string;
+ filename: string | null;
+ size_bytes: number | null;
+ state: string;
+ encrypted_cid: string | null;
+ error: string | null;
+ updated_at: string;
+ created_at: string;
+ }>;
+};
+
+export type AdminSystemResponse = {
+ env: Record;
+ service_config: Array<{
+ key: string;
+ value: string | null;
+ raw: string | null;
+ }>;
+ services: AdminServiceState[];
+ blockchain_tasks: Record;
+ latest_index_items: Array<{
+ encrypted_cid: string | null;
+ updated_at: string;
+ }>;
+};
+
+export type AdminBlockchainResponse = {
+ counts: Record;
+ recent: Array<{
+ id: string;
+ destination: string | null;
+ amount: string | null;
+ status: string;
+ epoch: number | null;
+ seqno: number | null;
+ transaction_hash: string | null;
+ updated: string;
+ }>;
+};
+
+export type AdminNodesResponse = {
+ items: Array<{
+ ip: string | null;
+ port: number | null;
+ public_key: string | null;
+ role: 'trusted' | 'read-only' | 'deny';
+ version?: string | null;
+ last_seen?: string | null;
+ notes?: string | null;
+ }>;
+};
+
+export type AdminStatusResponse = {
+ ipfs: {
+ bitswap: Record;
+ repo: Record;
+ };
+ pin_counts: Record;
+ derivatives: {
+ ready: number;
+ processing: number;
+ pending: number;
+ failed: number;
+ total_bytes: number;
+ };
+ convert_backlog: number;
+ limits: {
+ DERIVATIVE_CACHE_MAX_GB: number;
+ DERIVATIVE_CACHE_TTL_DAYS: number;
+ SYNC_MAX_CONCURRENT_PINS: number;
+ SYNC_DISK_LOW_WATERMARK_PCT: number;
+ };
+};
+
+export type AdminCacheSetLimitsPayload = {
+ max_gb: number;
+ ttl_days: number;
+};
+
+export type AdminCacheCleanupPayload =
+ | {
+ mode: 'ttl';
+ }
+ | {
+ mode?: 'fit';
+ max_gb: number;
+ };
+
+export type AdminSyncSetLimitsPayload = {
+ max_concurrent_pins: number;
+ disk_low_watermark_pct: number;
+};
+
+export type AdminNodeSetRolePayload = {
+ public_key?: string;
+ host?: string;
+ role: 'trusted' | 'read-only' | 'deny';
+};
+
+const defaultQueryOptions = {
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+} as const;
+
+type QueryOptions = UseQueryOptions<
+ TData,
+ AxiosError,
+ TData,
+ TQueryKey
+>;
+
+type MutationOptions = UseMutationOptions<
+ TData,
+ AxiosError,
+ TVariables
+>;
+
+export const useAdminOverview = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'overview'],
+ async () => {
+ const { data } = await request.get('/admin.overview');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminStorage = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'storage'],
+ async () => {
+ const { data } = await request.get('/admin.storage');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminUploads = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'uploads'],
+ async () => {
+ const { data } = await request.get('/admin.uploads');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminSystem = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'system'],
+ async () => {
+ const { data } = await request.get('/admin.system');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminBlockchain = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'blockchain'],
+ async () => {
+ const { data } = await request.get('/admin.blockchain');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminNodes = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'nodes'],
+ async () => {
+ const { data } = await request.get('/admin.nodes');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export const useAdminStatus = (
+ options?: QueryOptions,
+) => {
+ return useQuery(
+ ['admin', 'status'],
+ async () => {
+ const { data } = await request.get('/admin.status');
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
+export type AdminLoginResponse = {
+ ok: true;
+ cookie_name?: string;
+ header_name?: string;
+ max_age?: number;
+};
+
+export const useAdminLogin = (
+ options?: MutationOptions,
+) => {
+ return useMutation(
+ async ({ secret }) => {
+ const { data } = await request.post('/admin.login', { secret });
+ persistAdminAuth({
+ token: secret,
+ headerName: data.header_name,
+ cookieName: data.cookie_name,
+ maxAge: data.max_age,
+ });
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminLogout = (
+ options?: MutationOptions<{ ok: true }, void>,
+) => {
+ return useMutation<{ ok: true }, AxiosError, void>(
+ async () => {
+ const { data } = await request.post<{ ok: true }>('/admin.logout');
+ clearAdminAuth();
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminCacheSetLimits = (
+ options?: MutationOptions<{ ok: true }, AdminCacheSetLimitsPayload>,
+) => {
+ return useMutation<{ ok: true }, AxiosError, AdminCacheSetLimitsPayload>(
+ async (payload) => {
+ const { data } = await request.post<{ ok: true }>('/admin.cache.setLimits', payload);
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminCacheCleanup = (
+ options?: MutationOptions<{ ok: true; removed?: number }, AdminCacheCleanupPayload>,
+) => {
+ return useMutation<{ ok: true; removed?: number }, AxiosError, AdminCacheCleanupPayload>(
+ async (payload) => {
+ const { data } = await request.post<{ ok: true; removed?: number }>('/admin.cache.cleanup', payload);
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminSyncSetLimits = (
+ options?: MutationOptions<{ ok: true }, AdminSyncSetLimitsPayload>,
+) => {
+ return useMutation<{ ok: true }, AxiosError, AdminSyncSetLimitsPayload>(
+ async (payload) => {
+ const { data } = await request.post<{ ok: true }>('/admin.sync.setLimits', payload);
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminNodeSetRole = (
+ options?: MutationOptions<{ ok: true; node: { ip: string | null; public_key: string | null; role: string } }, AdminNodeSetRolePayload>,
+) => {
+ return useMutation<
+ { ok: true; node: { ip: string | null; public_key: string | null; role: string } },
+ AxiosError,
+ AdminNodeSetRolePayload
+ >(
+ async (payload) => {
+ const { data } = await request.post<{ ok: true; node: { ip: string | null; public_key: string | null; role: string } }>(
+ '/admin.node.setRole',
+ payload,
+ );
+ return data;
+ },
+ options,
+ );
+};
+
+export const useAdminSyncLimits = () => {
+ const cacheSetLimits = useAdminCacheSetLimits();
+ const syncSetLimits = useAdminSyncSetLimits();
+ return { cacheSetLimits, syncSetLimits };
+};
+
+export const isUnauthorizedError = (error: unknown) => {
+ if (!error) {
+ return false;
+ }
+ if (axios.isAxiosError(error)) {
+ return error.response?.status === 401;
+ }
+ return false;
+};
diff --git a/src/shared/services/file/index.ts b/src/shared/services/file/index.ts
index 60bd57e..9110ba2 100644
--- a/src/shared/services/file/index.ts
+++ b/src/shared/services/file/index.ts
@@ -1,201 +1,468 @@
import { useMutation } from "react-query";
-import { useState } from "react";
+import { useRef, useState } from "react";
+import { Upload } from "tus-js-client";
+
import { request } from "~/shared/libs";
const STORAGE_API_URL = import.meta.env.VITE_API_BASE_STORAGE_URL;
const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB
+const TUS_ENDPOINT = import.meta.env.VITE_TUS_ENDPOINT?.trim();
-export const useUploadFile = () => {
+const TUS_STATUS_POLL_INTERVAL_MS = 2000;
+const TUS_STATUS_POLL_TIMEOUT_MS = 5 * 60 * 1000;
+
+const sleep = (ms: number) => new Promise((resolve) => {
+ setTimeout(resolve, ms);
+});
+
+const normalizeError = (error: unknown, fallbackMessage: string) => {
+ if (error instanceof Error) {
+ return error;
+ }
+
+ if (typeof error === "string") {
+ return new Error(error);
+ }
+
+ if (error && typeof error === "object" && "message" in error) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === "string") {
+ return new Error(message);
+ }
+ }
+
+ return new Error(fallbackMessage);
+};
+
+const sanitizeMetadataKey = (key: string) => {
+ return key.replace(/[^A-Za-z0-9_.-]/g, "_");
+};
+
+const normalizeMetadata = (
+ metadata?: Record,
+) => {
+ if (!metadata) {
+ return {} as Record;
+ }
+
+ return Object.entries(metadata).reduce((acc, [rawKey, rawValue]) => {
+ if (rawValue === undefined || rawValue === null) {
+ return acc;
+ }
+
+ const key = sanitizeMetadataKey(rawKey);
+ if (!key) {
+ return acc;
+ }
+
+ acc[key] = String(rawValue);
+ return acc;
+ }, {} as Record);
+};
+
+const extractUploadId = (uploadUrl: string) => {
+ try {
+ const url = new URL(uploadUrl);
+ const parts = url.pathname.split("/").filter(Boolean);
+ const uploadId = parts[parts.length - 1];
+ if (!uploadId) {
+ throw new Error("Empty upload id");
+ }
+ return uploadId;
+ } catch (error) {
+ const normalized = uploadUrl.split("/").filter(Boolean);
+ const uploadId = normalized[normalized.length - 1];
+ if (!uploadId) {
+ throw new Error("Unable to extract upload id from tus Location");
+ }
+ return uploadId;
+ }
+};
+
+type UploadSessionState = "uploading" | "processing" | "pinned" | "failed";
+
+type UploadStatusResponse = {
+ id: string;
+ state: UploadSessionState;
+ encrypted_cid?: string | null;
+ size_bytes?: number | null;
+ error?: string | null;
+};
+
+const pollUploadStatus = async (
+ uploadId: string,
+ signal?: AbortSignal,
+): Promise => {
+ const startedAt = Date.now();
+
+ while (true) {
+ if (signal?.aborted) {
+ throw new DOMException("Tus status polling aborted", "AbortError");
+ }
+
+ try {
+ const { data } = await request.get(
+ `/upload.status/${uploadId}`,
+ {
+ headers: { "Cache-Control": "no-store" },
+ },
+ );
+
+ if (data.state === "failed") {
+ throw new Error(data.error || "Tus upload failed on server");
+ }
+
+ if (data.state === "pinned" && data.encrypted_cid) {
+ return data;
+ }
+ } catch (error) {
+ const maybeAxios = error as { response?: { status?: number } };
+ if (maybeAxios?.response?.status === 404) {
+ // Hook has not recorded the upload session yet; continue polling.
+ } else if (error instanceof DOMException && error.name === "AbortError") {
+ throw error;
+ } else {
+ throw normalizeError(error, "Tus status polling failed");
+ }
+ }
+
+ if (Date.now() - startedAt > TUS_STATUS_POLL_TIMEOUT_MS) {
+ throw new Error("Timed out waiting for tus upload finalization");
+ }
+
+ await sleep(TUS_STATUS_POLL_INTERVAL_MS);
+ }
+};
+
+type TusUploadArgs = {
+ file: File;
+ metadata?: Record;
+ signal?: AbortSignal;
+};
+
+type TusUploadResult = {
+ kind: "tus";
+ uploadId: string;
+ encryptedCid: string;
+ sizeBytes?: number;
+ state: UploadSessionState;
+};
+
+export const useTusUpload = () => {
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
+ const activeUploadRef = useRef(null);
+
+ const mutation = useMutation(
+ ["upload-file", "tus"],
+ async ({ file, metadata, signal }) => {
+ if (!file) {
+ throw new Error("File is required for tus upload");
+ }
+
+ if (!TUS_ENDPOINT) {
+ throw new Error("Tus endpoint is not configured");
+ }
+
+ setIsUploading(true);
+ setUploadProgress(0);
+ setUploadError(null);
+
+ try {
+ const result = await new Promise((resolve, reject) => {
+ const token = localStorage.getItem("auth_v1_token") ?? "";
+ const normalizedMeta = normalizeMetadata(metadata);
+ const headers: Record = { "Cache-Control": "no-store" };
+ if (token) {
+ headers.Authorization = token;
+ }
+
+ const upload = new Upload(file, {
+ endpoint: TUS_ENDPOINT,
+ metadata: {
+ filename: file.name,
+ content_type: file.type || "application/octet-stream",
+ ...normalizedMeta,
+ },
+ headers,
+ retryDelays: [0, 1000, 3000, 5000],
+ removeFingerprintOnSuccess: true,
+ uploadDataDuringCreation: true,
+ onError: (err) => {
+ const normalized = normalizeError(err, "Tus upload failed");
+ setUploadError(normalized);
+ setIsUploading(false);
+ setUploadProgress(0);
+ activeUploadRef.current = null;
+ reject(normalized);
+ },
+ onProgress: (bytesUploaded, bytesTotal) => {
+ const percent = bytesTotal
+ ? Math.floor((bytesUploaded / bytesTotal) * 100)
+ : 0;
+ setUploadProgress(Math.min(99, Math.max(0, percent)));
+ },
+ onSuccess: () => {
+ (async () => {
+ try {
+ setUploadProgress(99);
+ const uploadUrl = upload.url;
+ if (!uploadUrl) {
+ throw new Error("Tus upload finished without Location header");
+ }
+
+ const uploadId = extractUploadId(uploadUrl);
+ const status = await pollUploadStatus(uploadId, signal);
+ const encryptedCid = status.encrypted_cid;
+ if (!encryptedCid) {
+ throw new Error("Tus upload finalized without encrypted CID");
+ }
+
+ setUploadProgress(100);
+ setIsUploading(false);
+ activeUploadRef.current = null;
+
+ resolve({
+ kind: "tus",
+ uploadId,
+ encryptedCid,
+ sizeBytes: status.size_bytes ?? undefined,
+ state: status.state,
+ });
+ } catch (statusError) {
+ const normalized = normalizeError(
+ statusError,
+ "Failed to finalize tus upload",
+ );
+ setUploadError(normalized);
+ setIsUploading(false);
+ activeUploadRef.current = null;
+ reject(normalized);
+ }
+ })();
+ },
+ });
+
+ activeUploadRef.current = upload;
+
+ if (signal) {
+ signal.addEventListener(
+ "abort",
+ () => {
+ upload.abort();
+ const abortError = new DOMException(
+ "Tus upload aborted",
+ "AbortError",
+ );
+ setUploadError(abortError);
+ setIsUploading(false);
+ activeUploadRef.current = null;
+ reject(abortError);
+ },
+ { once: true },
+ );
+ }
+
+ upload
+ .findPreviousUploads()
+ .then((previousUploads) => {
+ if (previousUploads.length > 0) {
+ upload.resumeFromPreviousUpload(previousUploads[0]);
+ }
+ upload.start();
+ })
+ .catch((resumeError) => {
+ const normalized = normalizeError(
+ resumeError,
+ "Failed to resume tus upload",
+ );
+ setUploadError(normalized);
+ setIsUploading(false);
+ activeUploadRef.current = null;
+ reject(normalized);
+ });
+ });
+
+ return result;
+ } finally {
+ activeUploadRef.current = null;
+ setIsUploading(false);
+ }
+ },
+ );
+
+ const resetUploadError = () => setUploadError(null);
+
+ const abortUpload = () => {
+ if (activeUploadRef.current) {
+ activeUploadRef.current.abort();
+ activeUploadRef.current = null;
+ setIsUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ return {
+ ...mutation,
+ uploadProgress,
+ isUploading,
+ uploadError,
+ resetUploadError,
+ abortUpload,
+ };
+};
+
+export const useLegacyUploadFile = () => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
- const mutation = useMutation(["upload-file"], async (file: File) => {
- console.log(`Начинаем загрузку файла: ${file.name} (${file.size} байт)`);
+ const mutation = useMutation(["upload-file", "legacy"], async (file: File) => {
setIsUploading(true);
setUploadProgress(0);
- setUploadError(null); // Сбрасываем ошибку перед началом новой загрузки
+ setUploadError(null);
try {
- // Для маленьких файлов используем обычную загрузку, но с теми же заголовками
if (file.size <= MAX_CHUNK_SIZE) {
- console.log("Используем обычную загрузку (файл <= MAX_CHUNK_SIZE)");
-
- // Подготавливаем заголовки - такие же, как для чанковой загрузки
const headers: Record = {
- "X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
- "X-Chunk-Start": "0", // Начинаем с позиции 0
+ "X-File-Name": btoa(unescape(encodeURIComponent(file.name))),
+ "X-Chunk-Start": "0",
"Content-Type": file.type || "application/octet-stream",
- "X-Last-Chunk": "1" // Это единственный и последний чанк
+ "X-Last-Chunk": "1",
};
-
- // Добавляем заголовок авторизации
- const authToken = localStorage.getItem('auth_v1_token');
+
+ const authToken = localStorage.getItem("auth_v1_token");
if (authToken) {
- headers["Authorization"] = authToken;
- }
-
- console.log("Заголовки запроса:", headers);
-
- try {
- const response = await request.post<{
- upload_id?: string;
- content_sha256?: string;
- content_id?: string;
- content_id_v1?: string;
- content_url?: string;
- }>("", file, { // Отправляем файл напрямую вместо FormData
- baseURL: STORAGE_API_URL,
- headers,
- onUploadProgress: (progressEvent) => {
- const percentCompleted = Math.round(
- (progressEvent.loaded * 100) / (progressEvent?.total as number || file.size)
- );
- setUploadProgress(Math.min(99, percentCompleted));
- },
- });
-
- console.log("Ответ на обычную загрузку:", response.data);
- setUploadProgress(100);
-
- return {
- content_sha256: response.data.content_sha256 || "",
- content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
- content_url: response.data.content_url || ""
- };
- } catch (error) {
- console.error("Ошибка при обычной загрузке:", error);
- setUploadError(error instanceof Error ? error : new Error('Ошибка при загрузке файла'));
- throw error;
+ headers.Authorization = authToken;
}
+
+ const response = await request.post<{
+ upload_id?: string;
+ content_sha256?: string;
+ content_id?: string;
+ content_id_v1?: string;
+ content_url?: string;
+ }>("", file, {
+ baseURL: STORAGE_API_URL,
+ headers,
+ onUploadProgress: (progressEvent) => {
+ const total = progressEvent?.total ?? file.size;
+ const percentCompleted = Math.round(
+ (progressEvent.loaded * 100) / total,
+ );
+ setUploadProgress(Math.min(99, percentCompleted));
+ },
+ });
+
+ setUploadProgress(100);
+
+ return {
+ content_sha256: response.data.content_sha256 || "",
+ content_id_v1:
+ response.data.content_id_v1 || response.data.content_id || "",
+ content_url: response.data.content_url || "",
+ };
}
-
- // Для больших файлов используем чанковую загрузку
- console.log("Используем чанкированную загрузку (файл > MAX_CHUNK_SIZE)");
-
+
let offset = 0;
let uploadId: string | null = null;
- let chunkNumber = 0;
-
- // Загружаем файл по чанкам
+
while (offset < file.size) {
- chunkNumber++;
const chunkEnd = Math.min(offset + MAX_CHUNK_SIZE, file.size);
const chunk = file.slice(offset, chunkEnd);
- console.log(`Загрузка чанка #${chunkNumber} начиная с байта ${offset}`);
-
- // Определяем, является ли текущий чанк последним
const isLastChunk = chunkEnd === file.size;
-
- // Подготавливаем заголовки
+
const headers: Record = {
- "X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
+ "X-File-Name": btoa(unescape(encodeURIComponent(file.name))),
"X-Chunk-Start": offset.toString(),
- "Content-Type": file.type || "application/octet-stream"
+ "Content-Type": file.type || "application/octet-stream",
};
-
- // Добавляем маркер последнего чанка, если это последний чанк
+
if (isLastChunk) {
headers["X-Last-Chunk"] = "1";
}
-
- // Добавляем заголовок авторизации
- const authToken = localStorage.getItem('auth_v1_token');
+
+ const authToken = localStorage.getItem("auth_v1_token");
if (authToken) {
- headers["Authorization"] = authToken;
+ headers.Authorization = authToken;
}
-
- // Если есть uploadId, добавляем его в заголовки
+
if (uploadId) {
headers["X-Upload-ID"] = uploadId;
}
-
- console.log("Заголовки запроса:", headers);
-
- try {
- const response = await request.post<{
- upload_id?: string;
- current_size?: number;
- content_id?: string;
- content_sha256?: string;
- content_id_v1?: string;
- content_url?: string;
- }>("", chunk, {
- baseURL: STORAGE_API_URL,
- headers,
- onUploadProgress: (progressEvent) => {
- // Прогресс загрузки текущего чанка
- const overallProgress = offset + progressEvent.loaded;
- const percentCompleted = Math.round((overallProgress / file.size) * 100);
- setUploadProgress(Math.min(99, percentCompleted));
- },
- });
-
- console.log(`Ответ на чанк #${chunkNumber}:`, response.data);
-
- // Сохраняем uploadId из первого ответа, если не установлен
- if (!uploadId && response.data.upload_id) {
- uploadId = response.data.upload_id;
- console.log("Получен upload_id:", uploadId);
- }
-
- // Проверка на наличие content_id
- if (response.data.content_id) {
- console.log("Загрузка завершена. ID файла:", response.data.content_id);
- setUploadProgress(100);
-
- return {
- content_sha256: response.data.content_sha256 || "",
- content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
- content_url: response.data.content_url || ""
- };
- }
-
- // Обновляем смещение на основе ответа сервера
- if (response.data.current_size !== undefined) {
- offset = response.data.current_size;
- console.log(`Сервер сообщает current_size: ${offset}`);
- } else {
- console.warn("Неожиданный ответ от сервера, отсутствует current_size");
- const error = new Error("Missing current_size in response");
- setUploadError(error);
- throw error;
- }
- } catch (error: any) {
- console.error(`Ошибка при загрузке чанка #${chunkNumber}:`, error);
- if (error.response) {
- console.error("Ответ сервера:", error.response.status, error.response.data);
- }
- setUploadError(error instanceof Error ? error : new Error(`Ошибка при загрузке чанка #${chunkNumber}`));
+
+ const response = await request.post<{
+ upload_id?: string;
+ current_size?: number;
+ content_id?: string;
+ content_sha256?: string;
+ content_id_v1?: string;
+ content_url?: string;
+ }>("", chunk, {
+ baseURL: STORAGE_API_URL,
+ headers,
+ onUploadProgress: (progressEvent) => {
+ const overallProgress = offset + progressEvent.loaded;
+ const percentCompleted = Math.round(
+ (overallProgress / file.size) * 100,
+ );
+ setUploadProgress(Math.min(99, percentCompleted));
+ },
+ });
+
+ if (!uploadId && response.data.upload_id) {
+ uploadId = response.data.upload_id;
+ }
+
+ if (response.data.content_id) {
+ setUploadProgress(100);
+
+ return {
+ content_sha256: response.data.content_sha256 || "",
+ content_id_v1:
+ response.data.content_id_v1 || response.data.content_id || "",
+ content_url: response.data.content_url || "",
+ };
+ }
+
+ if (response.data.current_size !== undefined) {
+ offset = response.data.current_size;
+ } else {
+ const error = new Error("Missing current_size in response");
+ setUploadError(error);
throw error;
}
}
-
- const error = new Error("Ошибка загрузки файла: все чанки загружены, но content_id не получен");
- console.error(error.message);
+
+ const error = new Error(
+ "All chunks uploaded but server did not return content_id",
+ );
setUploadError(error);
throw error;
- } catch (error: any) {
- console.error("Ошибка при загрузке:", error);
- if (error.response) {
- console.error("Ответ сервера:", error.response.status, error.response.data);
- }
- setUploadError(error instanceof Error ? error : new Error("Неизвестная ошибка при загрузке"));
+ } catch (error) {
+ const normalized = normalizeError(
+ error,
+ "Unknown error during legacy upload",
+ );
+ setUploadError(normalized);
setIsUploading(false);
- throw error;
+ throw normalized;
} finally {
setIsUploading(false);
}
});
- // Сбросить ошибку
const resetUploadError = () => setUploadError(null);
- return {
- ...mutation,
- uploadProgress,
- isUploading,
+ return {
+ ...mutation,
+ uploadProgress,
+ isUploading,
uploadError,
- resetUploadError
+ resetUploadError,
};
-};
\ No newline at end of file
+};
+
+export { useTusUpload as useUploadFile };
+export type { TusUploadArgs, TusUploadResult, UploadSessionState };
diff --git a/yarn.lock b/yarn.lock
index ea8fb39..b6c3bf4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -225,10 +225,10 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
-"@esbuild/win32-x64@0.19.12":
+"@esbuild/darwin-arm64@0.19.12":
version "0.19.12"
- resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz"
- integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
+ resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz"
+ integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
version "4.4.0"
@@ -381,10 +381,10 @@
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz"
integrity sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==
-"@rollup/rollup-win32-x64-msvc@4.12.0":
+"@rollup/rollup-darwin-arm64@4.12.0":
version "4.12.0"
- resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz"
- integrity sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==
+ resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz"
+ integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==
"@sentry-internal/browser-utils@9.1.0":
version "9.1.0"
@@ -976,6 +976,11 @@ browserslist@^4.22.2, browserslist@^4.23.0, "browserslist@>= 4.21.0":
node-releases "^2.0.14"
update-browserslist-db "^1.0.13"
+buffer-from@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
+ integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz"
@@ -1081,6 +1086,14 @@ color-name@1.1.3:
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
+combine-errors@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz"
+ integrity sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==
+ dependencies:
+ custom-error-instance "2.1.1"
+ lodash.uniqby "4.5.0"
+
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
@@ -1122,6 +1135,11 @@ csstype@^3.0.2, csstype@^3.1.1:
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+custom-error-instance@2.1.1:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz"
+ integrity sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==
+
debug@^3.2.7:
version "3.2.7"
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
@@ -1637,6 +1655,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+fsevents@~2.3.2, fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -1769,6 +1792,11 @@ gopd@^1.0.1:
dependencies:
get-intrinsic "^1.1.3"
+graceful-fs@^4.2.4:
+ version "4.2.11"
+ resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
+ integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
graphemer@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
@@ -1975,6 +2003,11 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
dependencies:
call-bind "^1.0.7"
+is-stream@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz"
+ integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
+
is-string@^1.0.5, is-string@^1.0.7:
version "1.0.7"
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz"
@@ -2027,6 +2060,11 @@ jiti@^1.19.1:
resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz"
integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==
+js-base64@^3.7.2:
+ version "3.7.8"
+ resolved "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz"
+ integrity sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==
+
js-sha3@0.8.0:
version "0.8.0"
resolved "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz"
@@ -2076,11 +2114,6 @@ json5@^2.2.3:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
-jssha@^3.3.1:
- version "3.3.1"
- resolved "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz"
- integrity sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==
-
jssha@3.2.0:
version "3.2.0"
resolved "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz"
@@ -2133,11 +2166,61 @@ lodash-es@^4.17.21:
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
+lodash._baseiteratee@~4.7.0:
+ version "4.7.0"
+ resolved "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz"
+ integrity sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==
+ dependencies:
+ lodash._stringtopath "~4.8.0"
+
+lodash._basetostring@~4.12.0:
+ version "4.12.0"
+ resolved "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz"
+ integrity sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==
+
+lodash._baseuniq@~4.6.0:
+ version "4.6.0"
+ resolved "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz"
+ integrity sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==
+ dependencies:
+ lodash._createset "~4.0.0"
+ lodash._root "~3.0.0"
+
+lodash._createset@~4.0.0:
+ version "4.0.3"
+ resolved "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz"
+ integrity sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==
+
+lodash._root@~3.0.0:
+ version "3.0.1"
+ resolved "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz"
+ integrity sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==
+
+lodash._stringtopath@~4.8.0:
+ version "4.8.0"
+ resolved "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz"
+ integrity sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==
+ dependencies:
+ lodash._basetostring "~4.12.0"
+
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+lodash.throttle@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz"
+ integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
+
+lodash.uniqby@4.5.0:
+ version "4.5.0"
+ resolved "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz"
+ integrity sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==
+ dependencies:
+ lodash._baseiteratee "~4.7.0"
+ lodash._baseuniq "~4.6.0"
+
loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
@@ -2544,6 +2627,15 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.13.1"
+proper-lockfile@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz"
+ integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==
+ dependencies:
+ graceful-fs "^4.2.4"
+ retry "^0.12.0"
+ signal-exit "^3.0.2"
+
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
@@ -2554,6 +2646,11 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+querystringify@^2.1.1:
+ version "2.2.0"
+ resolved "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz"
+ integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
+
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
@@ -2697,6 +2794,11 @@ remove-accents@0.5.0:
resolved "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz"
integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==
+requires-port@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
+ integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
+
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@@ -2711,6 +2813,11 @@ resolve@^1.1.7, resolve@^1.22.2, resolve@^1.22.4:
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
+retry@^0.12.0:
+ version "0.12.0"
+ resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz"
+ integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
+
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
@@ -2834,6 +2941,11 @@ side-channel@^1.0.4:
get-intrinsic "^1.2.4"
object-inspect "^1.13.1"
+signal-exit@^3.0.2:
+ version "3.0.7"
+ resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
+ integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
signal-exit@^4.0.1:
version "4.1.0"
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
@@ -3080,6 +3192,19 @@ tslib@^2.6.2:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tus-js-client@^4.3.1:
+ version "4.3.1"
+ resolved "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz"
+ integrity sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==
+ dependencies:
+ buffer-from "^1.1.2"
+ combine-errors "^3.0.3"
+ is-stream "^2.0.0"
+ js-base64 "^3.7.2"
+ lodash.throttle "^4.1.1"
+ proper-lockfile "^4.1.2"
+ url-parse "^1.5.7"
+
tweetnacl-util@^0.15.1:
version "0.15.1"
resolved "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz"
@@ -3194,6 +3319,14 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
+url-parse@^1.5.7:
+ version "1.5.10"
+ resolved "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz"
+ integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
+ dependencies:
+ querystringify "^2.1.1"
+ requires-port "^1.0.0"
+
use-sync-external-store@1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"