new version with admin
This commit is contained in:
1 parent
0f983eb34d
commit
d08a02b6e7
10 files changed
+2357
-186
No files matched your search
@@ -2,4 +2,5 @@ export const Routes = {
|
||||
Root: "/uploadContent",
|
||||
ViewContent: "/viewContent",
|
||||
SentryCheck: "/sentryCheck",
|
||||
Admin: "/admin",
|
||||
};
|
||||
@@ -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: <RootPage /> },
|
||||
{ path: Routes.ViewContent, element: <ViewContentPage /> },
|
||||
{
|
||||
path: Routes.SentryCheck,
|
||||
{
|
||||
path: Routes.SentryCheck,
|
||||
element: (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<button
|
||||
@@ -29,6 +30,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: Routes.Admin, element: <AdminPage /> },
|
||||
]);
|
||||
|
||||
export const AppRouter = () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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<string, any>;
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -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<string, unknown> & {
|
||||
identity?: Record<string, unknown> | { error: string };
|
||||
bitswap?: Record<string, unknown> | { error: string };
|
||||
repo?: Record<string, unknown> | { 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<string, number>;
|
||||
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<string, string | null | undefined>;
|
||||
service_config: Array<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
raw: string | null;
|
||||
}>;
|
||||
services: AdminServiceState[];
|
||||
blockchain_tasks: Record<string, number>;
|
||||
latest_index_items: Array<{
|
||||
encrypted_cid: string | null;
|
||||
updated_at: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AdminBlockchainResponse = {
|
||||
counts: Record<string, number>;
|
||||
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<string, unknown>;
|
||||
repo: Record<string, unknown>;
|
||||
};
|
||||
pin_counts: Record<string, number>;
|
||||
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<TData, TQueryKey extends readonly unknown[]> = UseQueryOptions<
|
||||
TData,
|
||||
AxiosError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>;
|
||||
|
||||
type MutationOptions<TData, TVariables> = UseMutationOptions<
|
||||
TData,
|
||||
AxiosError,
|
||||
TVariables
|
||||
>;
|
||||
|
||||
export const useAdminOverview = (
|
||||
options?: QueryOptions<AdminOverviewResponse, ['admin', 'overview']>,
|
||||
) => {
|
||||
return useQuery<AdminOverviewResponse, AxiosError, AdminOverviewResponse, ['admin', 'overview']>(
|
||||
['admin', 'overview'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminOverviewResponse>('/admin.overview');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminStorage = (
|
||||
options?: QueryOptions<AdminStorageResponse, ['admin', 'storage']>,
|
||||
) => {
|
||||
return useQuery<AdminStorageResponse, AxiosError, AdminStorageResponse, ['admin', 'storage']>(
|
||||
['admin', 'storage'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminStorageResponse>('/admin.storage');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminUploads = (
|
||||
options?: QueryOptions<AdminUploadsResponse, ['admin', 'uploads']>,
|
||||
) => {
|
||||
return useQuery<AdminUploadsResponse, AxiosError, AdminUploadsResponse, ['admin', 'uploads']>(
|
||||
['admin', 'uploads'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminUploadsResponse>('/admin.uploads');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminSystem = (
|
||||
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
||||
) => {
|
||||
return useQuery<AdminSystemResponse, AxiosError, AdminSystemResponse, ['admin', 'system']>(
|
||||
['admin', 'system'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminSystemResponse>('/admin.system');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminBlockchain = (
|
||||
options?: QueryOptions<AdminBlockchainResponse, ['admin', 'blockchain']>,
|
||||
) => {
|
||||
return useQuery<AdminBlockchainResponse, AxiosError, AdminBlockchainResponse, ['admin', 'blockchain']>(
|
||||
['admin', 'blockchain'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminBlockchainResponse>('/admin.blockchain');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminNodes = (
|
||||
options?: QueryOptions<AdminNodesResponse, ['admin', 'nodes']>,
|
||||
) => {
|
||||
return useQuery<AdminNodesResponse, AxiosError, AdminNodesResponse, ['admin', 'nodes']>(
|
||||
['admin', 'nodes'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminNodesResponse>('/admin.nodes');
|
||||
return data;
|
||||
},
|
||||
{
|
||||
...defaultQueryOptions,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdminStatus = (
|
||||
options?: QueryOptions<AdminStatusResponse, ['admin', 'status']>,
|
||||
) => {
|
||||
return useQuery<AdminStatusResponse, AxiosError, AdminStatusResponse, ['admin', 'status']>(
|
||||
['admin', 'status'],
|
||||
async () => {
|
||||
const { data } = await request.get<AdminStatusResponse>('/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<AdminLoginResponse, { secret: string }>,
|
||||
) => {
|
||||
return useMutation<AdminLoginResponse, AxiosError, { secret: string }>(
|
||||
async ({ secret }) => {
|
||||
const { data } = await request.post<AdminLoginResponse>('/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;
|
||||
};
|
||||
+415
-148
@@ -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<void>((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<string, string | number | boolean | undefined>,
|
||||
) => {
|
||||
if (!metadata) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
|
||||
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<string, string>);
|
||||
};
|
||||
|
||||
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<UploadStatusResponse> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("Tus status polling aborted", "AbortError");
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await request.get<UploadStatusResponse>(
|
||||
`/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<string, string | number | boolean | undefined>;
|
||||
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<Error | null>(null);
|
||||
const activeUploadRef = useRef<Upload | null>(null);
|
||||
|
||||
const mutation = useMutation<TusUploadResult, Error, TusUploadArgs>(
|
||||
["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<TusUploadResult>((resolve, reject) => {
|
||||
const token = localStorage.getItem("auth_v1_token") ?? "";
|
||||
const normalizedMeta = normalizeMetadata(metadata);
|
||||
const headers: Record<string, string> = { "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<Error | null>(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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export { useTusUpload as useUploadFile };
|
||||
export type { TusUploadArgs, TusUploadResult, UploadSessionState };
|
||||
Reference in new issue
Block a user