chore: remove chunked uploads for now
This commit is contained in:
parent
bc2a78e5af
commit
1a1d13b571
@ -96,11 +96,6 @@ export const Endpoints = {
|
||||
CHANNEL_TYPING: (channelId: string) => `/channels/${channelId}/typing`,
|
||||
CHANNEL_WEBHOOKS: (channelId: string) => `/channels/${channelId}/webhooks`,
|
||||
CHANNEL_RTC_REGIONS: (channelId: string) => `/channels/${channelId}/rtc-regions`,
|
||||
CHANNEL_CHUNKED_UPLOADS: (channelId: string) => `/channels/${channelId}/chunked-uploads`,
|
||||
CHANNEL_CHUNKED_UPLOAD_CHUNK: (channelId: string, uploadId: string, chunkIndex: number) =>
|
||||
`/channels/${channelId}/chunked-uploads/${uploadId}/chunks/${chunkIndex}`,
|
||||
CHANNEL_CHUNKED_UPLOAD_COMPLETE: (channelId: string, uploadId: string) =>
|
||||
`/channels/${channelId}/chunked-uploads/${uploadId}/complete`,
|
||||
CHANNEL_CALL: (channelId: string) => `/channels/${channelId}/call`,
|
||||
CHANNEL_CALL_RING: (channelId: string) => `/channels/${channelId}/call/ring`,
|
||||
CHANNEL_CALL_STOP_RINGING: (channelId: string) => `/channels/${channelId}/call/stop-ringing`,
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {CHUNKED_UPLOAD_CHUNK_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
const logger = new Logger('ChunkedUploadService');
|
||||
|
||||
const MAX_CONCURRENT_CHUNKS = 4;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
const RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
interface ChunkedUploadResult {
|
||||
upload_filename: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
interface InitiateUploadResponse {
|
||||
upload_id: string;
|
||||
upload_filename: string;
|
||||
chunk_size: number;
|
||||
chunk_count: number;
|
||||
}
|
||||
|
||||
interface UploadChunkResponse {
|
||||
etag: string;
|
||||
}
|
||||
|
||||
interface CompleteUploadResponse {
|
||||
upload_filename: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export async function uploadFileChunked(
|
||||
channelId: string,
|
||||
file: File,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChunkedUploadResult> {
|
||||
const initiateResponse = await http.post<InitiateUploadResponse>({
|
||||
url: Endpoints.CHANNEL_CHUNKED_UPLOADS(channelId),
|
||||
body: {
|
||||
filename: file.name,
|
||||
file_size: file.size,
|
||||
},
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
|
||||
const {upload_id, chunk_size, chunk_count} = initiateResponse.body;
|
||||
|
||||
logger.debug(`Initiated chunked upload: ${upload_id}, ${chunk_count} chunks of ${chunk_size} bytes`);
|
||||
|
||||
const chunkProgress = new Array<number>(chunk_count).fill(0);
|
||||
const etags = new Array<{chunk_index: number; etag: string}>(chunk_count);
|
||||
|
||||
function reportProgress() {
|
||||
if (!onProgress) return;
|
||||
const loaded = chunkProgress.reduce((sum, bytes) => sum + bytes, 0);
|
||||
onProgress(loaded, file.size);
|
||||
}
|
||||
|
||||
const chunkIndices = Array.from({length: chunk_count}, (_, i) => i);
|
||||
let cursor = 0;
|
||||
const activeTasks: Array<Promise<void>> = [];
|
||||
|
||||
async function uploadOneChunk(chunkIndex: number): Promise<void> {
|
||||
const start = chunkIndex * chunk_size;
|
||||
const end = Math.min(start + chunk_size, file.size);
|
||||
const chunkBlob = file.slice(start, end);
|
||||
const chunkData = new Uint8Array(await chunkBlob.arrayBuffer());
|
||||
const chunkLength = chunkData.byteLength;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_CHUNK_RETRIES; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await http.put<UploadChunkResponse>({
|
||||
url: Endpoints.CHANNEL_CHUNKED_UPLOAD_CHUNK(channelId, upload_id, chunkIndex),
|
||||
body: chunkData,
|
||||
headers: {'Content-Type': 'application/octet-stream'},
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
|
||||
etags[chunkIndex] = {chunk_index: chunkIndex, etag: response.body.etag};
|
||||
chunkProgress[chunkIndex] = chunkLength;
|
||||
reportProgress();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isRetryable =
|
||||
error instanceof Error &&
|
||||
'status' in error &&
|
||||
((error as {status: number}).status >= 500 || (error as {status: number}).status === 429);
|
||||
|
||||
if (!isRetryable || attempt === MAX_CHUNK_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
|
||||
logger.debug(
|
||||
`Chunk ${chunkIndex} failed (attempt ${attempt + 1}/${MAX_CHUNK_RETRIES + 1}), retrying in ${delay}ms`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
function settle(error?: unknown) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
while (activeTasks.length < MAX_CONCURRENT_CHUNKS && cursor < chunkIndices.length) {
|
||||
const chunkIndex = chunkIndices[cursor++];
|
||||
const task = uploadOneChunk(chunkIndex).then(
|
||||
() => {
|
||||
const idx = activeTasks.indexOf(task);
|
||||
if (idx !== -1) activeTasks.splice(idx, 1);
|
||||
if (cursor >= chunkIndices.length && activeTasks.length === 0) {
|
||||
settle();
|
||||
} else {
|
||||
scheduleNext();
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
settle(error);
|
||||
},
|
||||
);
|
||||
activeTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNext();
|
||||
});
|
||||
|
||||
logger.debug(`All ${chunk_count} chunks uploaded, completing upload`);
|
||||
|
||||
const completeResponse = await http.post<CompleteUploadResponse>({
|
||||
url: Endpoints.CHANNEL_CHUNKED_UPLOAD_COMPLETE(channelId, upload_id),
|
||||
body: {etags},
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
|
||||
return {
|
||||
upload_filename: completeResponse.body.upload_filename,
|
||||
file_size: completeResponse.body.file_size,
|
||||
content_type: completeResponse.body.content_type,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseChunkedUpload(file: File): boolean {
|
||||
return file.size > CHUNKED_UPLOAD_CHUNK_SIZE;
|
||||
}
|
||||
@ -48,7 +48,6 @@ export interface CloudAttachment {
|
||||
duration?: number | null;
|
||||
waveform?: string | null;
|
||||
isVoiceMessage?: boolean;
|
||||
uploadedFilename?: string;
|
||||
}
|
||||
|
||||
export interface MessageUpload {
|
||||
|
||||
@ -32,7 +32,6 @@ import {NSFWContentRejectedModal} from '@app/components/alerts/NSFWContentReject
|
||||
import {SlowmodeRateLimitedModal} from '@app/components/alerts/SlowmodeRateLimitedModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import i18n from '@app/I18n';
|
||||
import {shouldUseChunkedUpload, uploadFileChunked} from '@app/lib/ChunkedUploadService';
|
||||
import {CloudUpload} from '@app/lib/CloudUpload';
|
||||
import http, {type HttpResponse} from '@app/lib/HttpClient';
|
||||
import type {HttpError} from '@app/lib/HttpError';
|
||||
@ -225,32 +224,6 @@ class MessageQueue extends Queue<MessageQueuePayload, HttpResponse<Message> | un
|
||||
files = result.files;
|
||||
}
|
||||
|
||||
if (hasAttachments && files?.length && attachments?.length) {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(nonce, abortController);
|
||||
|
||||
try {
|
||||
const chunkedResult = await this.performChunkedUploads(
|
||||
channelId,
|
||||
nonce,
|
||||
files,
|
||||
attachments,
|
||||
abortController.signal,
|
||||
);
|
||||
files = chunkedResult.files;
|
||||
attachments = chunkedResult.attachments;
|
||||
} catch (error) {
|
||||
this.abortControllers.delete(nonce);
|
||||
const httpError = error as HttpError;
|
||||
logger.error(`Chunked upload failed for channel ${channelId}:`, error);
|
||||
this.handleSendError(channelId, nonce, httpError, i18n, payload.hasAttachments);
|
||||
completed(null, undefined, error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.abortControllers.delete(nonce);
|
||||
}
|
||||
|
||||
const requestBody = buildMessageCreateRequest({
|
||||
content: payload.content,
|
||||
nonce,
|
||||
@ -321,77 +294,6 @@ class MessageQueue extends Queue<MessageQueuePayload, HttpResponse<Message> | un
|
||||
}
|
||||
}
|
||||
|
||||
private async performChunkedUploads(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
files: Array<File>,
|
||||
attachments: Array<ApiAttachmentMetadata>,
|
||||
signal: AbortSignal,
|
||||
): Promise<{files: Array<File>; attachments: Array<ApiAttachmentMetadata>}> {
|
||||
const largeFileIndices = new Set<number>();
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (shouldUseChunkedUpload(files[i])) {
|
||||
largeFileIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (largeFileIndices.size === 0) {
|
||||
return {files, attachments};
|
||||
}
|
||||
|
||||
const totalChunkedSize = Array.from(largeFileIndices).reduce((sum, i) => sum + files[i].size, 0);
|
||||
const totalOverallSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
const chunkedRatio = totalOverallSize > 0 ? totalChunkedSize / totalOverallSize : 0;
|
||||
const chunkedProgressWeight = chunkedRatio * 90;
|
||||
|
||||
const perFileProgress = new Map<number, number>();
|
||||
for (const i of largeFileIndices) {
|
||||
perFileProgress.set(i, 0);
|
||||
}
|
||||
|
||||
const updatedAttachments = [...attachments];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(largeFileIndices).map(async (fileIndex) => {
|
||||
const file = files[fileIndex];
|
||||
const result = await uploadFileChunked(
|
||||
channelId,
|
||||
file,
|
||||
(loaded, _total) => {
|
||||
perFileProgress.set(fileIndex, loaded);
|
||||
const totalLoaded = Array.from(perFileProgress.values()).reduce((s, v) => s + v, 0);
|
||||
const ratio = totalChunkedSize > 0 ? totalLoaded / totalChunkedSize : 0;
|
||||
const overallProgress = ratio * chunkedProgressWeight;
|
||||
CloudUpload.updateSendingProgress(nonce, overallProgress);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
if (updatedAttachments[fileIndex]) {
|
||||
updatedAttachments[fileIndex] = {
|
||||
...updatedAttachments[fileIndex],
|
||||
uploaded_filename: result.upload_filename,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const inlineFiles: Array<File> = [];
|
||||
let inlineIndex = 0;
|
||||
const remappedAttachments = updatedAttachments.map((att, originalIndex) => {
|
||||
if (largeFileIndices.has(originalIndex)) {
|
||||
return att;
|
||||
}
|
||||
const newId = String(inlineIndex);
|
||||
inlineFiles.push(files[originalIndex]);
|
||||
inlineIndex++;
|
||||
return {...att, id: newId};
|
||||
});
|
||||
|
||||
return {files: inlineFiles, attachments: remappedAttachments};
|
||||
}
|
||||
|
||||
private async sendMultipartMessage(
|
||||
channelId: string,
|
||||
requestBody: MessageCreateRequest,
|
||||
|
||||
@ -39,7 +39,7 @@ export async function prepareAttachmentsForNonce(
|
||||
throw new Error('No message upload found');
|
||||
}
|
||||
|
||||
const inlineAttachments = messageUpload.attachments.filter((att) => !att.uploadedFilename);
|
||||
const inlineAttachments = messageUpload.attachments;
|
||||
const files = inlineAttachments.map((att) => att.file);
|
||||
const attachments = favoriteMemeId ? undefined : mapMessageUploadAttachments(messageUpload.attachments);
|
||||
|
||||
@ -55,6 +55,5 @@ export function mapMessageUploadAttachments(attachments: Array<CloudAttachment>)
|
||||
flags: att.flags,
|
||||
duration: att.duration != null ? Math.ceil(att.duration) : undefined,
|
||||
waveform: att.waveform ?? undefined,
|
||||
uploaded_filename: att.uploadedFilename,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -34,7 +34,6 @@ export interface ApiAttachmentMetadata {
|
||||
flags?: number;
|
||||
duration?: number;
|
||||
waveform?: string;
|
||||
uploaded_filename?: string;
|
||||
}
|
||||
|
||||
export interface MessageCreateRequest {
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
ChunkedUploadChunkParam,
|
||||
ChunkedUploadParam,
|
||||
CompleteChunkedUploadRequest,
|
||||
CompleteChunkedUploadResponse,
|
||||
CreateChunkedUploadRequest,
|
||||
CreateChunkedUploadResponse,
|
||||
UploadChunkResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChunkedUploadSchemas';
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ChunkedUploadController(app: HonoApp) {
|
||||
app.post(
|
||||
'/channels/:channel_id/chunked-uploads',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CHUNKED_UPLOAD_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('json', CreateChunkedUploadRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_chunked_upload',
|
||||
summary: 'Initiate a chunked upload session',
|
||||
description:
|
||||
'Creates a new chunked upload session for uploading large files. Returns the upload ID, expected chunk size, and total chunk count. The client should then upload each chunk individually and complete the upload when all chunks are uploaded.',
|
||||
responseSchema: CreateChunkedUploadResponse,
|
||||
statusCode: 201,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Attachments'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const body = ctx.req.valid('json');
|
||||
const chunkedUploadService = ctx.get('chunkedUploadService');
|
||||
const result = await chunkedUploadService.initiateUpload(user.id, channelId, body);
|
||||
return ctx.json(result, 201);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/channels/:channel_id/chunked-uploads/:upload_id/chunks/:chunk_index',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CHUNKED_UPLOAD_CHUNK),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChunkedUploadChunkParam),
|
||||
OpenAPI({
|
||||
operationId: 'upload_chunk',
|
||||
summary: 'Upload a file chunk',
|
||||
description:
|
||||
'Uploads a single chunk of a file as part of a chunked upload session. The chunk index is zero-based. Returns an ETag that must be provided when completing the upload.',
|
||||
responseSchema: UploadChunkResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Attachments'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {upload_id, chunk_index} = ctx.req.valid('param');
|
||||
const arrayBuffer = await ctx.req.arrayBuffer();
|
||||
const body = new Uint8Array(arrayBuffer);
|
||||
const chunkedUploadService = ctx.get('chunkedUploadService');
|
||||
const result = await chunkedUploadService.uploadChunk(user.id, upload_id, chunk_index, body);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/chunked-uploads/:upload_id/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_CHUNKED_UPLOAD_COMPLETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChunkedUploadParam),
|
||||
Validator('json', CompleteChunkedUploadRequest),
|
||||
OpenAPI({
|
||||
operationId: 'complete_chunked_upload',
|
||||
summary: 'Complete a chunked upload',
|
||||
description:
|
||||
'Completes a chunked upload session by assembling all uploaded chunks. Requires ETags for all chunks. Returns the upload filename that can be referenced when sending a message with the uploaded file.',
|
||||
responseSchema: CompleteChunkedUploadResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Attachments'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {upload_id} = ctx.req.valid('param');
|
||||
const body = ctx.req.valid('json');
|
||||
const chunkedUploadService = ctx.get('chunkedUploadService');
|
||||
const result = await chunkedUploadService.completeUpload(user.id, upload_id, body);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -19,7 +19,6 @@
|
||||
|
||||
import {CallController} from '@fluxer/api/src/channel/controllers/CallController';
|
||||
import {ChannelController} from '@fluxer/api/src/channel/controllers/ChannelController';
|
||||
import {ChunkedUploadController} from '@fluxer/api/src/channel/controllers/ChunkedUploadController';
|
||||
import {MessageController} from '@fluxer/api/src/channel/controllers/MessageController';
|
||||
import {MessageInteractionController} from '@fluxer/api/src/channel/controllers/MessageInteractionController';
|
||||
import {ScheduledMessageController} from '@fluxer/api/src/channel/controllers/ScheduledMessageController';
|
||||
@ -31,7 +30,6 @@ export function registerChannelControllers(app: HonoApp) {
|
||||
MessageInteractionController(app);
|
||||
MessageController(app);
|
||||
ScheduledMessageController(app);
|
||||
ChunkedUploadController(app);
|
||||
CallController(app);
|
||||
StreamController(app);
|
||||
}
|
||||
|
||||
@ -1,227 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {AuthenticatedChannel} from '@fluxer/api/src/channel/services/AuthenticatedChannel';
|
||||
import {getContentType} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
ATTACHMENT_MAX_SIZE_NON_PREMIUM,
|
||||
CHUNKED_UPLOAD_CHUNK_SIZE,
|
||||
CHUNKED_UPLOAD_MAX_CHUNKS,
|
||||
CHUNKED_UPLOAD_SESSION_TTL_SECONDS,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ChunkedUploadChunkIndexOutOfRangeError} from '@fluxer/errors/src/domains/channel/ChunkedUploadChunkIndexOutOfRangeError';
|
||||
import {ChunkedUploadIncompleteError} from '@fluxer/errors/src/domains/channel/ChunkedUploadIncompleteError';
|
||||
import {ChunkedUploadNotFoundError} from '@fluxer/errors/src/domains/channel/ChunkedUploadNotFoundError';
|
||||
import {ChunkedUploadNotOwnedError} from '@fluxer/errors/src/domains/channel/ChunkedUploadNotOwnedError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import type {
|
||||
CompleteChunkedUploadRequest,
|
||||
CompleteChunkedUploadResponse,
|
||||
CreateChunkedUploadRequest,
|
||||
CreateChunkedUploadResponse,
|
||||
UploadChunkResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChunkedUploadSchemas';
|
||||
|
||||
interface ChunkedUploadSession {
|
||||
userId: string;
|
||||
channelId: string;
|
||||
s3UploadId: string;
|
||||
uploadFilename: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
chunkSize: number;
|
||||
chunkCount: number;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
function sessionKey(uploadId: string): string {
|
||||
return `chunked_upload:${uploadId}`;
|
||||
}
|
||||
|
||||
export class ChunkedUploadService {
|
||||
constructor(
|
||||
private storageService: IStorageService,
|
||||
private kvProvider: IKVProvider,
|
||||
private userRepository: IUserRepository,
|
||||
private limitConfigService: LimitConfigService,
|
||||
private getChannelAuthenticated: (params: {userId: UserID; channelId: ChannelID}) => Promise<AuthenticatedChannel>,
|
||||
private ensureTextChannel: (channel: Channel) => void,
|
||||
) {}
|
||||
|
||||
async initiateUpload(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
request: CreateChunkedUploadRequest,
|
||||
): Promise<CreateChunkedUploadResponse> {
|
||||
const {channel, guild, checkPermission} = await this.getChannelAuthenticated({userId, channelId});
|
||||
this.ensureTextChannel(channel);
|
||||
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const fallbackMaxSize = ATTACHMENT_MAX_SIZE_NON_PREMIUM;
|
||||
const ctx = createLimitMatchContext({user, guildFeatures: guild?.features ?? null});
|
||||
const maxFileSize = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_attachment_file_size',
|
||||
fallbackMaxSize,
|
||||
);
|
||||
|
||||
if (request.file_size > maxFileSize) {
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
|
||||
const chunkCount = Math.ceil(request.file_size / CHUNKED_UPLOAD_CHUNK_SIZE);
|
||||
if (chunkCount > CHUNKED_UPLOAD_MAX_CHUNKS) {
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
|
||||
const uploadFilename = crypto.randomUUID();
|
||||
const contentType = getContentType(request.filename);
|
||||
|
||||
const {uploadId: s3UploadId} = await this.storageService.createMultipartUpload({
|
||||
bucket: Config.s3.buckets.uploads,
|
||||
key: uploadFilename,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
const session: ChunkedUploadSession = {
|
||||
userId: userId.toString(),
|
||||
channelId: channelId.toString(),
|
||||
s3UploadId,
|
||||
uploadFilename,
|
||||
filename: request.filename,
|
||||
fileSize: request.file_size,
|
||||
chunkSize: CHUNKED_UPLOAD_CHUNK_SIZE,
|
||||
chunkCount,
|
||||
contentType,
|
||||
};
|
||||
|
||||
await this.kvProvider.setex(sessionKey(uploadId), CHUNKED_UPLOAD_SESSION_TTL_SECONDS, JSON.stringify(session));
|
||||
|
||||
return {
|
||||
upload_id: uploadId,
|
||||
upload_filename: uploadFilename,
|
||||
chunk_size: CHUNKED_UPLOAD_CHUNK_SIZE,
|
||||
chunk_count: chunkCount,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadChunk(
|
||||
userId: UserID,
|
||||
uploadId: string,
|
||||
chunkIndex: number,
|
||||
body: Uint8Array,
|
||||
): Promise<UploadChunkResponse> {
|
||||
const session = await this.getSession(uploadId);
|
||||
this.verifyOwnership(session, userId);
|
||||
|
||||
if (chunkIndex < 0 || chunkIndex >= session.chunkCount) {
|
||||
throw new ChunkedUploadChunkIndexOutOfRangeError();
|
||||
}
|
||||
|
||||
const {etag} = await this.storageService.uploadPart({
|
||||
bucket: Config.s3.buckets.uploads,
|
||||
key: session.uploadFilename,
|
||||
uploadId: session.s3UploadId,
|
||||
partNumber: chunkIndex + 1,
|
||||
body,
|
||||
});
|
||||
|
||||
return {etag};
|
||||
}
|
||||
|
||||
async completeUpload(
|
||||
userId: UserID,
|
||||
uploadId: string,
|
||||
request: CompleteChunkedUploadRequest,
|
||||
): Promise<CompleteChunkedUploadResponse> {
|
||||
const session = await this.getSession(uploadId);
|
||||
this.verifyOwnership(session, userId);
|
||||
|
||||
if (request.etags.length !== session.chunkCount) {
|
||||
throw new ChunkedUploadIncompleteError();
|
||||
}
|
||||
|
||||
const seenIndices = new Set<number>();
|
||||
for (const entry of request.etags) {
|
||||
if (entry.chunk_index < 0 || entry.chunk_index >= session.chunkCount) {
|
||||
throw new ChunkedUploadChunkIndexOutOfRangeError();
|
||||
}
|
||||
if (seenIndices.has(entry.chunk_index)) {
|
||||
throw new ChunkedUploadIncompleteError();
|
||||
}
|
||||
seenIndices.add(entry.chunk_index);
|
||||
}
|
||||
|
||||
const parts = request.etags.map((entry) => ({
|
||||
partNumber: entry.chunk_index + 1,
|
||||
etag: entry.etag,
|
||||
}));
|
||||
|
||||
await this.storageService.completeMultipartUpload({
|
||||
bucket: Config.s3.buckets.uploads,
|
||||
key: session.uploadFilename,
|
||||
uploadId: session.s3UploadId,
|
||||
parts,
|
||||
});
|
||||
|
||||
await this.kvProvider.del(sessionKey(uploadId));
|
||||
|
||||
return {
|
||||
upload_filename: session.uploadFilename,
|
||||
file_size: session.fileSize,
|
||||
content_type: session.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSession(uploadId: string): Promise<ChunkedUploadSession> {
|
||||
const raw = await this.kvProvider.get(sessionKey(uploadId));
|
||||
if (!raw) {
|
||||
throw new ChunkedUploadNotFoundError();
|
||||
}
|
||||
return JSON.parse(raw) as ChunkedUploadSession;
|
||||
}
|
||||
|
||||
private verifyOwnership(session: ChunkedUploadSession, userId: UserID): void {
|
||||
if (session.userId !== userId.toString()) {
|
||||
throw new ChunkedUploadNotOwnedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import type {ChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {
|
||||
type AttachmentRequestData,
|
||||
mergeUploadWithClientData,
|
||||
@ -26,7 +25,6 @@ import {
|
||||
} from '@fluxer/api/src/channel/AttachmentDTOs';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@fluxer/api/src/channel/MessageTypes';
|
||||
import {getContentType} from '@fluxer/api/src/channel/services/message/MessageHelpers';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
@ -172,14 +170,11 @@ export async function parseMultipartMessageData(
|
||||
const fileIds = new Set(filesWithIndices.map((f) => f.index));
|
||||
|
||||
const inlineNewAttachments: Array<ClientAttachmentRequest> = [];
|
||||
const preUploadedNewAttachments: Array<ClientAttachmentRequest> = [];
|
||||
|
||||
for (const att of newAttachments) {
|
||||
const id = typeof att.id === 'string' ? parseInt(att.id, 10) : att.id;
|
||||
if (fileIds.has(id)) {
|
||||
inlineNewAttachments.push(att);
|
||||
} else if (att.uploaded_filename) {
|
||||
preUploadedNewAttachments.push(att);
|
||||
} else {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.NO_FILE_FOR_ATTACHMENT_METADATA, {
|
||||
attachmentId: att.id,
|
||||
@ -232,32 +227,7 @@ export async function parseMultipartMessageData(
|
||||
});
|
||||
}
|
||||
|
||||
let processedPreUploadedAttachments: Array<AttachmentRequestData> = [];
|
||||
if (preUploadedNewAttachments.length > 0) {
|
||||
const storageService = ctx.get('storageService');
|
||||
|
||||
processedPreUploadedAttachments = await Promise.all(
|
||||
preUploadedNewAttachments.map(async (clientData) => {
|
||||
const uploadFilename = clientData.uploaded_filename!;
|
||||
const metadata = await storageService.getObjectMetadata(Config.s3.buckets.uploads, uploadFilename);
|
||||
if (!metadata) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.NO_FILE_FOR_ATTACHMENT_METADATA, {
|
||||
attachmentId: clientData.id,
|
||||
});
|
||||
}
|
||||
const uploaded: UploadedAttachment = {
|
||||
id: typeof clientData.id === 'string' ? parseInt(clientData.id, 10) : clientData.id,
|
||||
upload_filename: uploadFilename,
|
||||
filename: clientData.filename,
|
||||
file_size: metadata.contentLength,
|
||||
content_type: getContentType(clientData.filename),
|
||||
};
|
||||
return mergeUploadWithClientData(uploaded, clientData);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
data.attachments = [...existingAttachments, ...processedInlineAttachments, ...processedPreUploadedAttachments];
|
||||
data.attachments = [...existingAttachments, ...processedInlineAttachments];
|
||||
}
|
||||
|
||||
return data as MessageRequest | MessageUpdateRequest;
|
||||
|
||||
@ -1,396 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createMultipartFormData, setupTestGuildAndChannel} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {CHUNKED_UPLOAD_CHUNK_SIZE} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {
|
||||
CompleteChunkedUploadResponse,
|
||||
CreateChunkedUploadResponse,
|
||||
UploadChunkResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChunkedUploadSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function initiateChunkedUpload(
|
||||
token: string,
|
||||
channelId: string,
|
||||
filename: string,
|
||||
fileSize: number,
|
||||
): Promise<CreateChunkedUploadResponse> {
|
||||
return createBuilder<CreateChunkedUploadResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/chunked-uploads`)
|
||||
.body({filename, file_size: fileSize})
|
||||
.expect(201)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function uploadChunk(
|
||||
token: string,
|
||||
channelId: string,
|
||||
uploadId: string,
|
||||
chunkIndex: number,
|
||||
data: Buffer,
|
||||
): Promise<UploadChunkResponse> {
|
||||
const response = await harness.app.request(
|
||||
`/channels/${channelId}/chunked-uploads/${uploadId}/chunks/${chunkIndex}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: new Headers({
|
||||
Authorization: token,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
}),
|
||||
body: data,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as UploadChunkResponse;
|
||||
}
|
||||
|
||||
async function completeChunkedUpload(
|
||||
token: string,
|
||||
channelId: string,
|
||||
uploadId: string,
|
||||
etags: Array<{chunk_index: number; etag: string}>,
|
||||
): Promise<CompleteChunkedUploadResponse> {
|
||||
return createBuilder<CompleteChunkedUploadResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/chunked-uploads/${uploadId}/complete`)
|
||||
.body({etags})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Chunked Uploads', () => {
|
||||
let account: TestAccount;
|
||||
let channel: ChannelResponse;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupTestGuildAndChannel(harness);
|
||||
account = setup.account;
|
||||
channel = setup.channel;
|
||||
});
|
||||
|
||||
describe('POST /channels/:channel_id/chunked-uploads', () => {
|
||||
it('should initiate a chunked upload session', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE * 2 + 100;
|
||||
const result = await initiateChunkedUpload(account.token, channel.id, 'large-file.bin', fileSize);
|
||||
|
||||
expect(result.upload_id).toBeDefined();
|
||||
expect(result.upload_filename).toBeDefined();
|
||||
expect(result.chunk_size).toBe(CHUNKED_UPLOAD_CHUNK_SIZE);
|
||||
expect(result.chunk_count).toBe(3);
|
||||
});
|
||||
|
||||
it('should reject when file size exceeds the limit', async () => {
|
||||
const hugeSize = 1024 * 1024 * 1024 * 10;
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/chunked-uploads`)
|
||||
.body({filename: 'huge.bin', file_size: hugeSize})
|
||||
.expect(400, APIErrorCodes.FILE_SIZE_TOO_LARGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject without authentication', async () => {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/channels/${channel.id}/chunked-uploads`)
|
||||
.body({filename: 'file.bin', file_size: 1024})
|
||||
.expect(401)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /channels/:channel_id/chunked-uploads/:upload_id/chunks/:chunk_index', () => {
|
||||
it('should upload a chunk and return an etag', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE + 100;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'test.bin', fileSize);
|
||||
|
||||
const chunkData = Buffer.alloc(CHUNKED_UPLOAD_CHUNK_SIZE, 0xab);
|
||||
const result = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunkData);
|
||||
|
||||
expect(result.etag).toBeDefined();
|
||||
expect(typeof result.etag).toBe('string');
|
||||
});
|
||||
|
||||
it('should reject chunk index out of range', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE + 100;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'test.bin', fileSize);
|
||||
|
||||
const chunkData = Buffer.alloc(100, 0xab);
|
||||
const response = await harness.app.request(
|
||||
`/channels/${channel.id}/chunked-uploads/${initResult.upload_id}/chunks/99`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: new Headers({
|
||||
Authorization: account.token,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
}),
|
||||
body: chunkData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {code: string};
|
||||
expect(body.code).toBe(APIErrorCodes.CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE);
|
||||
});
|
||||
|
||||
it('should reject for non-existent upload session', async () => {
|
||||
const chunkData = Buffer.alloc(100, 0xab);
|
||||
const response = await harness.app.request(`/channels/${channel.id}/chunked-uploads/non-existent-id/chunks/0`, {
|
||||
method: 'PUT',
|
||||
headers: new Headers({
|
||||
Authorization: account.token,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
}),
|
||||
body: chunkData,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const body = (await response.json()) as {code: string};
|
||||
expect(body.code).toBe(APIErrorCodes.CHUNKED_UPLOAD_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /channels/:channel_id/chunked-uploads/:upload_id/complete', () => {
|
||||
it('should complete a chunked upload', async () => {
|
||||
const chunkSize = CHUNKED_UPLOAD_CHUNK_SIZE;
|
||||
const fileSize = chunkSize * 2;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'two-chunks.bin', fileSize);
|
||||
|
||||
const chunk0 = Buffer.alloc(chunkSize, 0xaa);
|
||||
const chunk1 = Buffer.alloc(chunkSize, 0xbb);
|
||||
|
||||
const etag0 = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunk0);
|
||||
const etag1 = await uploadChunk(account.token, channel.id, initResult.upload_id, 1, chunk1);
|
||||
|
||||
const result = await completeChunkedUpload(account.token, channel.id, initResult.upload_id, [
|
||||
{chunk_index: 0, etag: etag0.etag},
|
||||
{chunk_index: 1, etag: etag1.etag},
|
||||
]);
|
||||
|
||||
expect(result.upload_filename).toBe(initResult.upload_filename);
|
||||
expect(result.file_size).toBe(fileSize);
|
||||
expect(result.content_type).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject when not all chunks have been provided', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE * 2;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'test.bin', fileSize);
|
||||
|
||||
const chunk0 = Buffer.alloc(CHUNKED_UPLOAD_CHUNK_SIZE, 0xaa);
|
||||
const etag0 = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunk0);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/chunked-uploads/${initResult.upload_id}/complete`)
|
||||
.body({etags: [{chunk_index: 0, etag: etag0.etag}]})
|
||||
.expect(400, APIErrorCodes.CHUNKED_UPLOAD_INCOMPLETE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject duplicate chunk indices', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE * 2;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'test.bin', fileSize);
|
||||
|
||||
const chunk0 = Buffer.alloc(CHUNKED_UPLOAD_CHUNK_SIZE, 0xaa);
|
||||
const etag0 = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunk0);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/chunked-uploads/${initResult.upload_id}/complete`)
|
||||
.body({
|
||||
etags: [
|
||||
{chunk_index: 0, etag: etag0.etag},
|
||||
{chunk_index: 0, etag: etag0.etag},
|
||||
],
|
||||
})
|
||||
.expect(400, APIErrorCodes.CHUNKED_UPLOAD_INCOMPLETE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upload ownership', () => {
|
||||
it('should reject chunk upload from a different user', async () => {
|
||||
const fileSize = CHUNKED_UPLOAD_CHUNK_SIZE + 100;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'test.bin', fileSize);
|
||||
|
||||
const otherSetup = await setupTestGuildAndChannel(harness);
|
||||
const otherAccount = otherSetup.account;
|
||||
|
||||
const chunkData = Buffer.alloc(100, 0xab);
|
||||
const response = await harness.app.request(
|
||||
`/channels/${channel.id}/chunked-uploads/${initResult.upload_id}/chunks/0`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: new Headers({
|
||||
Authorization: otherAccount.token,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
}),
|
||||
body: chunkData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = (await response.json()) as {code: string};
|
||||
expect(body.code).toBe(APIErrorCodes.CHUNKED_UPLOAD_NOT_OWNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-end: chunked upload + message send', () => {
|
||||
it('should send a message with a pre-uploaded file', async () => {
|
||||
const chunkSize = CHUNKED_UPLOAD_CHUNK_SIZE;
|
||||
const fileSize = chunkSize + 500;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'uploaded-file.txt', fileSize);
|
||||
|
||||
const chunk0 = Buffer.alloc(chunkSize, 0x41);
|
||||
const chunk1 = Buffer.alloc(500, 0x42);
|
||||
|
||||
const etag0 = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunk0);
|
||||
const etag1 = await uploadChunk(account.token, channel.id, initResult.upload_id, 1, chunk1);
|
||||
|
||||
await completeChunkedUpload(account.token, channel.id, initResult.upload_id, [
|
||||
{chunk_index: 0, etag: etag0.etag},
|
||||
{chunk_index: 1, etag: etag1.etag},
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
content: 'Message with chunked upload',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'uploaded-file.txt',
|
||||
uploaded_filename: initResult.upload_filename,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {body, contentType} = createMultipartFormData(payload, []);
|
||||
|
||||
const mergedHeaders = new Headers();
|
||||
mergedHeaders.set('Content-Type', contentType);
|
||||
mergedHeaders.set('Authorization', account.token);
|
||||
mergedHeaders.set('x-forwarded-for', '127.0.0.1');
|
||||
|
||||
const response = await harness.app.request(`/channels/${channel.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: mergedHeaders,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const message = (await response.json()) as MessageResponse;
|
||||
expect(message.content).toBe('Message with chunked upload');
|
||||
expect(message.attachments).toBeDefined();
|
||||
expect(message.attachments!.length).toBe(1);
|
||||
expect(message.attachments![0].filename).toBe('uploaded-file.txt');
|
||||
});
|
||||
|
||||
it('should send a message with both inline and pre-uploaded files', async () => {
|
||||
const chunkSize = CHUNKED_UPLOAD_CHUNK_SIZE;
|
||||
const fileSize = chunkSize + 100;
|
||||
const initResult = await initiateChunkedUpload(account.token, channel.id, 'large.bin', fileSize);
|
||||
|
||||
const chunk0 = Buffer.alloc(chunkSize, 0xcc);
|
||||
const chunk1 = Buffer.alloc(100, 0xdd);
|
||||
|
||||
const etag0 = await uploadChunk(account.token, channel.id, initResult.upload_id, 0, chunk0);
|
||||
const etag1 = await uploadChunk(account.token, channel.id, initResult.upload_id, 1, chunk1);
|
||||
|
||||
await completeChunkedUpload(account.token, channel.id, initResult.upload_id, [
|
||||
{chunk_index: 0, etag: etag0.etag},
|
||||
{chunk_index: 1, etag: etag1.etag},
|
||||
]);
|
||||
|
||||
const smallFileData = Buffer.from('small inline file content');
|
||||
const payload = {
|
||||
content: 'Mixed upload message',
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'small.txt',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
filename: 'large.bin',
|
||||
uploaded_filename: initResult.upload_filename,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const {response, json} = await sendMixedMessage(account.token, channel.id, payload, [
|
||||
{index: 0, filename: 'small.txt', data: smallFileData},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.content).toBe('Mixed upload message');
|
||||
expect(json.attachments).toBeDefined();
|
||||
expect(json.attachments!.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function sendMixedMessage(
|
||||
token: string,
|
||||
channelId: string,
|
||||
payload: Record<string, unknown>,
|
||||
files: Array<{index: number; filename: string; data: Buffer}>,
|
||||
): Promise<{response: Response; json: MessageResponse}> {
|
||||
const {body, contentType} = createMultipartFormData(payload, files);
|
||||
|
||||
const mergedHeaders = new Headers();
|
||||
mergedHeaders.set('Content-Type', contentType);
|
||||
mergedHeaders.set('Authorization', token);
|
||||
mergedHeaders.set('x-forwarded-for', '127.0.0.1');
|
||||
|
||||
const response = await harness.app.request(`/channels/${channelId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: mergedHeaders,
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json: MessageResponse = undefined as unknown as MessageResponse;
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
json = JSON.parse(text) as MessageResponse;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {response, json};
|
||||
}
|
||||
@ -35,7 +35,6 @@ import {Config} from '@fluxer/api/src/Config';
|
||||
import {ChannelRepository} from '@fluxer/api/src/channel/ChannelRepository';
|
||||
import {ChannelRequestService} from '@fluxer/api/src/channel/services/ChannelRequestService';
|
||||
import {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import {ChunkedUploadService} from '@fluxer/api/src/channel/services/ChunkedUploadService';
|
||||
import {MessageRequestService} from '@fluxer/api/src/channel/services/message/MessageRequestService';
|
||||
import {ScheduledMessageService} from '@fluxer/api/src/channel/services/ScheduledMessageService';
|
||||
import {StreamPreviewService} from '@fluxer/api/src/channel/services/StreamPreviewService';
|
||||
@ -160,13 +159,11 @@ import {WebhookRequestService} from '@fluxer/api/src/webhook/WebhookRequestServi
|
||||
import {WebhookService} from '@fluxer/api/src/webhook/WebhookService';
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {KVCacheProvider} from '@fluxer/cache/src/providers/KVCacheProvider';
|
||||
import {TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {EmailI18nService} from '@fluxer/email/src/EmailI18nService';
|
||||
import type {EmailConfig, UserBouncedEmailChecker} from '@fluxer/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@fluxer/email/src/EmailService';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {TestEmailService} from '@fluxer/email/src/TestEmailService';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import {createMockLogger} from '@fluxer/logger/src/mock';
|
||||
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
|
||||
import type {ISmsProvider} from '@fluxer/sms/src/providers/ISmsProvider';
|
||||
@ -474,19 +471,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
mediaService,
|
||||
);
|
||||
|
||||
const chunkedUploadService = new ChunkedUploadService(
|
||||
storageService,
|
||||
kvClient,
|
||||
userRepository,
|
||||
limitConfigService,
|
||||
channelService.getChannelAuthenticated.bind(channelService),
|
||||
(channel) => {
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const scheduledMessageRepository = new ScheduledMessageRepository();
|
||||
const scheduledMessageService = new ScheduledMessageService(
|
||||
channelService,
|
||||
@ -886,7 +870,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
ctx.set('cacheService', cacheService);
|
||||
ctx.set('channelService', channelService);
|
||||
ctx.set('channelRequestService', channelRequestService);
|
||||
ctx.set('chunkedUploadService', chunkedUploadService);
|
||||
ctx.set('messageRequestService', messageRequestService);
|
||||
ctx.set('channelRepository', channelRepository);
|
||||
ctx.set('connectionService', connectionService);
|
||||
|
||||
@ -140,19 +140,4 @@ export const ChannelRateLimitConfigs = {
|
||||
bucket: 'channel:stream:preview:post::stream_key',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
|
||||
CHANNEL_CHUNKED_UPLOAD_CREATE: {
|
||||
bucket: 'channel:chunked_upload:create::channel_id',
|
||||
config: {limit: 5, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
|
||||
CHANNEL_CHUNKED_UPLOAD_CHUNK: {
|
||||
bucket: 'channel:chunked_upload:chunk::channel_id',
|
||||
config: {limit: 50, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
|
||||
CHANNEL_CHUNKED_UPLOAD_COMPLETE: {
|
||||
bucket: 'channel:chunked_upload:complete::channel_id',
|
||||
config: {limit: 5, windowMs: ms('10 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
} as const;
|
||||
|
||||
@ -31,7 +31,6 @@ import type {IBlueskyOAuthService} from '@fluxer/api/src/bluesky/IBlueskyOAuthSe
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelRequestService} from '@fluxer/api/src/channel/services/ChannelRequestService';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {ChunkedUploadService} from '@fluxer/api/src/channel/services/ChunkedUploadService';
|
||||
import type {MessageRequestService} from '@fluxer/api/src/channel/services/message/MessageRequestService';
|
||||
import type {ScheduledMessageService} from '@fluxer/api/src/channel/services/ScheduledMessageService';
|
||||
import type {StreamPreviewService} from '@fluxer/api/src/channel/services/StreamPreviewService';
|
||||
@ -134,7 +133,6 @@ export interface HonoEnv {
|
||||
cacheService: ICacheService;
|
||||
channelService: ChannelService;
|
||||
channelRequestService: ChannelRequestService;
|
||||
chunkedUploadService: ChunkedUploadService;
|
||||
messageRequestService: MessageRequestService;
|
||||
channelRepository: IChannelRepository;
|
||||
connectionService: ConnectionService;
|
||||
|
||||
@ -66,10 +66,6 @@ export const APIErrorCodes = {
|
||||
CANNOT_SHRINK_RESERVED_SLOTS: 'CANNOT_SHRINK_RESERVED_SLOTS',
|
||||
CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED',
|
||||
CHANNEL_INDEXING: 'CHANNEL_INDEXING',
|
||||
CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE: 'CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE',
|
||||
CHUNKED_UPLOAD_INCOMPLETE: 'CHUNKED_UPLOAD_INCOMPLETE',
|
||||
CHUNKED_UPLOAD_NOT_FOUND: 'CHUNKED_UPLOAD_NOT_FOUND',
|
||||
CHUNKED_UPLOAD_NOT_OWNED: 'CHUNKED_UPLOAD_NOT_OWNED',
|
||||
COMMUNICATION_DISABLED: 'COMMUNICATION_DISABLED',
|
||||
CONNECTION_ALREADY_EXISTS: 'CONNECTION_ALREADY_EXISTS',
|
||||
CONNECTION_INITIATION_TOKEN_INVALID: 'CONNECTION_INITIATION_TOKEN_INVALID',
|
||||
|
||||
@ -66,10 +66,6 @@ export const APIErrorCodesDescriptions: Record<keyof typeof APIErrorCodes, strin
|
||||
CANNOT_SHRINK_RESERVED_SLOTS: 'Cannot shrink reserved slots',
|
||||
CAPTCHA_REQUIRED: 'Captcha verification is required',
|
||||
CHANNEL_INDEXING: 'Channel is currently being indexed',
|
||||
CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE: 'Chunk index is out of range for this upload',
|
||||
CHUNKED_UPLOAD_INCOMPLETE: 'Not all chunks have been uploaded',
|
||||
CHUNKED_UPLOAD_NOT_FOUND: 'Chunked upload session was not found',
|
||||
CHUNKED_UPLOAD_NOT_OWNED: 'You do not own this chunked upload session',
|
||||
COMMUNICATION_DISABLED: 'You are timed out in this guild',
|
||||
CONNECTION_ALREADY_EXISTS: 'A connection of this type with this identifier already exists',
|
||||
CONNECTION_INITIATION_TOKEN_INVALID: 'The connection initiation token is invalid or has expired',
|
||||
|
||||
@ -97,10 +97,6 @@ export const MAX_MESSAGE_CACHE_SIZE = MAX_MESSAGES_PER_CHANNEL * 5;
|
||||
|
||||
export const NEW_MESSAGES_BAR_BUFFER = 32;
|
||||
|
||||
export const CHUNKED_UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024;
|
||||
export const CHUNKED_UPLOAD_MAX_CHUNKS = 128;
|
||||
export const CHUNKED_UPLOAD_SESSION_TTL_SECONDS = 3600;
|
||||
|
||||
export const VALID_TEMP_BAN_DURATIONS: ReadonlySet<number> = new Set([
|
||||
1 * 3600,
|
||||
12 * 3600,
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class ChunkedUploadChunkIndexOutOfRangeError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE});
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class ChunkedUploadIncompleteError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CHUNKED_UPLOAD_INCOMPLETE});
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
|
||||
export class ChunkedUploadNotFoundError extends NotFoundError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CHUNKED_UPLOAD_NOT_FOUND});
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
||||
|
||||
export class ChunkedUploadNotOwnedError extends ForbiddenError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.CHUNKED_UPLOAD_NOT_OWNED});
|
||||
}
|
||||
}
|
||||
@ -78,11 +78,6 @@ export const ErrorCodeToI18nKey = {
|
||||
[APIErrorCodes.CANNOT_SHRINK_RESERVED_SLOTS]: 'limits.cannot_shrink_reserved_slots',
|
||||
[APIErrorCodes.CAPTCHA_REQUIRED]: 'captcha.required',
|
||||
[APIErrorCodes.CHANNEL_INDEXING]: 'admin_and_system.channel_indexing',
|
||||
[APIErrorCodes.CHUNKED_UPLOAD_CHUNK_INDEX_OUT_OF_RANGE]:
|
||||
'attachments_and_uploads.chunked_upload_chunk_index_out_of_range',
|
||||
[APIErrorCodes.CHUNKED_UPLOAD_INCOMPLETE]: 'attachments_and_uploads.chunked_upload_incomplete',
|
||||
[APIErrorCodes.CHUNKED_UPLOAD_NOT_FOUND]: 'attachments_and_uploads.chunked_upload_not_found',
|
||||
[APIErrorCodes.CHUNKED_UPLOAD_NOT_OWNED]: 'attachments_and_uploads.chunked_upload_not_owned',
|
||||
[APIErrorCodes.COMMUNICATION_DISABLED]: 'account.communication_disabled',
|
||||
[APIErrorCodes.CONNECTION_ALREADY_EXISTS]: 'connections.already_exists',
|
||||
[APIErrorCodes.CONNECTION_INITIATION_TOKEN_INVALID]: 'connections.initiation_token_invalid',
|
||||
|
||||
@ -79,10 +79,6 @@ export type ErrorI18nKey =
|
||||
| 'attachments_and_uploads.attachments_not_allowed_for_message'
|
||||
| 'attachments_and_uploads.cannot_edit_attachment_metadata'
|
||||
| 'attachments_and_uploads.cannot_reference_attachments_without_attachments'
|
||||
| 'attachments_and_uploads.chunked_upload_chunk_index_out_of_range'
|
||||
| 'attachments_and_uploads.chunked_upload_incomplete'
|
||||
| 'attachments_and_uploads.chunked_upload_not_found'
|
||||
| 'attachments_and_uploads.chunked_upload_not_owned'
|
||||
| 'attachments_and_uploads.duplicate_attachment_ids_not_allowed'
|
||||
| 'attachments_and_uploads.duplicate_file_index'
|
||||
| 'attachments_and_uploads.failed_to_parse_multipart_form_data'
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: بيئة الاختبار محظورة.
|
||||
admin_and_system.unknown_suspicious_flag: suspicious flag غير معروف.
|
||||
admin_and_system.update_failed: ما قدرنا نحدّث المورد. جرّب مرة ثانية.
|
||||
admin_and_system.user_must_be_bot_for_system_user: المستخدم لازم يكون بوت عشان يتم تعيينه كمستخدم نظام.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: فهرس الجزء خارج النطاق المسموح لهذا الرفع.
|
||||
attachments_and_uploads.chunked_upload_incomplete: لم يتم رفع جميع الأجزاء بعد.
|
||||
attachments_and_uploads.chunked_upload_not_found: جلسة الرفع غير موجودة أو انتهت صلاحيتها.
|
||||
attachments_and_uploads.chunked_upload_not_owned: أنت لا تملك جلسة الرفع هذه.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id` و `channel_id` و `message_id` و `expires_at` مطلوبين.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id` و `channel_id` و `message_id` لازم تكون أرقام صحيحة.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: المستخدمون الذين لديهم صلاحية MANAGE_MESSAGES يمكنهم تعديل أوصاف المرفقات فقط، وليس البيانات الوصفية الأخرى.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness е забранен.
|
||||
admin_and_system.unknown_suspicious_flag: Непознат suspicious flag.
|
||||
admin_and_system.update_failed: Не успяхме да обновим ресурса. Моля, опитай пак.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Потребителят трябва да е бот, за да бъде маркиран като системен потребител.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Индексът на частта е извън обхвата за това качване.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Не всички части са качени.
|
||||
attachments_and_uploads.chunked_upload_not_found: Сесията за качване не беше намерена или е изтекла.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Тази сесия за качване не е твоя.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` и `expires_at` са задължителни.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` и `message_id` трябва да са валидни цели числа.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Потребителите с MANAGE_MESSAGES могат да редактират само описанията на прикачените файлове, не и други metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness je zakázaný.
|
||||
admin_and_system.unknown_suspicious_flag: Neznámý suspicious flag.
|
||||
admin_and_system.update_failed: Zdroj se nepodařilo aktualizovat. Zkus to znovu.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Uživatel musí být bot, aby mohl být označený jako systémový uživatel.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Index části je mimo rozsah pro toto nahrávání.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nebyly nahrány všechny části.
|
||||
attachments_and_uploads.chunked_upload_not_found: Relace nahrávání nebyla nalezena nebo vypršela.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Tato relace nahrávání ti nepatří.
|
||||
attachments_and_uploads.attachment_fields_required: Pole `attachment_id`, `channel_id`, `message_id` a `expires_at` jsou povinná.
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: Pole `attachment_id`, `channel_id` a `message_id` musí být platná celá čísla.
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Uživatelé s oprávněním MANAGE_MESSAGES můžou upravovat jen popisy příloh, ne další metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness er ikke tilladt.
|
||||
admin_and_system.unknown_suspicious_flag: Ukendt mistænkeligt flag.
|
||||
admin_and_system.update_failed: Opdatering mislykkedes.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Brugeren skal være en bot for at blive markeret som systembruger.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chunk-indekset er uden for intervallet for denne upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Ikke alle chunks er blevet uploadet.
|
||||
attachments_and_uploads.chunked_upload_not_found: Uploadsessionen blev ikke fundet eller er udløbet.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Du ejer ikke denne uploadsession.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` og `expires_at` er påkrævet.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` og `message_id` skal være gyldige heltal.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Brugere med MANAGE_MESSAGES kan kun redigere vedhæftningsbeskrivelser, ikke andre metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test-Harness ist nicht erlaubt.
|
||||
admin_and_system.unknown_suspicious_flag: Unbekanntes verdächtiges Flag.
|
||||
admin_and_system.update_failed: Wir konnten die Ressource nicht aktualisieren. Bitte versuche es nochmal.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Nutzer muss ein Bot sein, um als Systemnutzer markiert zu werden.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Der Chunk-Index liegt außerhalb des gültigen Bereichs für diesen Upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Es wurden nicht alle Chunks hochgeladen.
|
||||
attachments_and_uploads.chunked_upload_not_found: Die Upload-Sitzung wurde nicht gefunden oder ist abgelaufen.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Diese Upload-Sitzung gehört nicht dir.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` und `expires_at` sind erforderlich.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` und `message_id` müssen gültige Integer sein.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Nutzer mit MANAGE_MESSAGES können nur Attachment-Beschreibungen bearbeiten, keine anderen Metadaten.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Το test harness δεν επιτρέπ
|
||||
admin_and_system.unknown_suspicious_flag: Άγνωστο suspicious flag.
|
||||
admin_and_system.update_failed: Δεν μπορέσαμε να ενημερώσουμε τον πόρο. Παρακαλώ προσπάθησε ξανά.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Ο χρήστης πρέπει να είναι bot για να σημειωθεί ως system user.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Ο δείκτης του τμήματος είναι εκτός εύρους για αυτό το ανέβασμα.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Δεν έχουν ανέβει όλα τα τμήματα.
|
||||
attachments_and_uploads.chunked_upload_not_found: Η συνεδρία ανεβάσματος δεν βρέθηκε ή έχει λήξει.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Δεν σου ανήκει αυτή η συνεδρία ανεβάσματος.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` και `expires_at` είναι υποχρεωτικά.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` και `message_id` πρέπει να είναι έγκυροι ακέραιοι αριθμοί.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Οι χρήστες με MANAGE_MESSAGES μπορούν να επεξεργαστούν μόνο τις περιγραφές συνημμένων, όχι άλλα metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness is forbidden.
|
||||
admin_and_system.unknown_suspicious_flag: Unknown suspicious flag.
|
||||
admin_and_system.update_failed: We couldn't update the resource. Please try again.
|
||||
admin_and_system.user_must_be_bot_for_system_user: User must be a bot to be marked as a system user.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chunk index is out of range for this upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Not all chunks have been uploaded.
|
||||
attachments_and_uploads.chunked_upload_not_found: Upload session wasn't found or has expired.
|
||||
attachments_and_uploads.chunked_upload_not_owned: You don't own this upload session.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, and `expires_at` are required.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, and `message_id` must be valid integers.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Users with MANAGE_MESSAGES can only edit attachment descriptions, not other metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: El arnés de prueba está prohibido.
|
||||
admin_and_system.unknown_suspicious_flag: Bandera sospechosa desconocida.
|
||||
admin_and_system.update_failed: No pudimos actualizar el recurso. Intenta de nuevo.
|
||||
admin_and_system.user_must_be_bot_for_system_user: El usuario debe ser un bot para marcarse como usuario del sistema.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: El índice del fragmento está fuera de rango para esta subida.
|
||||
attachments_and_uploads.chunked_upload_incomplete: No se subieron todos los fragmentos.
|
||||
attachments_and_uploads.chunked_upload_not_found: La sesión de subida no fue encontrada o expiró.
|
||||
attachments_and_uploads.chunked_upload_not_owned: No sos dueño de esta sesión de subida.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` y `expires_at` son obligatorios.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` y `message_id` deben ser enteros válidos.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Los usuarios con MANAGE_MESSAGES solo pueden editar descripciones de adjuntos, no otros metadatos.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: El soporte de pruebas no está permitid
|
||||
admin_and_system.unknown_suspicious_flag: El indicador sospechoso no es conocido.
|
||||
admin_and_system.update_failed: No hemos podido actualizar el recurso. Por favor, inténtalo de nuevo.
|
||||
admin_and_system.user_must_be_bot_for_system_user: El usuario debe ser un bot para marcarse como usuario del sistema.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: El índice del fragmento está fuera de rango para esta subida.
|
||||
attachments_and_uploads.chunked_upload_incomplete: No se han subido todos los fragmentos.
|
||||
attachments_and_uploads.chunked_upload_not_found: La sesión de subida no se encontró o ha expirado.
|
||||
attachments_and_uploads.chunked_upload_not_owned: No eres el propietario de esta sesión de subida.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` y `expires_at` son obligatorios.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` y `message_id` deben ser números enteros válidos.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Los usuarios con MANAGE_MESSAGES solo pueden editar descripciones de adjuntos, no otros metadatos.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Testikehykseen ei ole oikeutta.
|
||||
admin_and_system.unknown_suspicious_flag: Tuntematon epäilyttävä lippu.
|
||||
admin_and_system.update_failed: Resurssia ei voitu päivittää. Yritä uudelleen.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Käyttäjän pitää olla botti, jotta sen voi merkitä järjestelmäkäyttäjäksi.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Osan indeksi on alueen ulkopuolella tälle lataukselle.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Kaikkia osia ei ole ladattu.
|
||||
attachments_and_uploads.chunked_upload_not_found: Latausistuntoa ei löytynyt tai se on vanhentunut.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Et omista tätä latausistuntoa.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` ja `expires_at` vaaditaan.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` ja `message_id` pitää olla kelvollisia kokonaislukuja.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Käyttäjät, joilla on MANAGE_MESSAGES, voivat muokata vain liitteiden kuvauksia, eivät muita metatietoja.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Le bâti de test est interdit.
|
||||
admin_and_system.unknown_suspicious_flag: Flag suspect inconnu.
|
||||
admin_and_system.update_failed: Nous n'avons pas pu mettre à jour la ressource. Veuillez réessayer.
|
||||
admin_and_system.user_must_be_bot_for_system_user: L'utilisateur doit être un bot pour être marqué en tant qu'utilisateur système.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: L'index du fragment est hors limites pour cet envoi.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Tous les fragments n'ont pas été envoyés.
|
||||
attachments_and_uploads.chunked_upload_not_found: La session d'envoi est introuvable ou a expiré.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Cette session d'envoi ne t'appartient pas.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` et `expires_at` sont obligatoires.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` et `message_id` doivent être des entiers valides.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Les utilisateurs avec MANAGE_MESSAGES peuvent uniquement modifier les descriptions de pièces jointes, pas les autres métadonnées.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: ניסיון בדיקה אסור.
|
||||
admin_and_system.unknown_suspicious_flag: דגל חשוד לא מוכר.
|
||||
admin_and_system.update_failed: לא הצלחנו לעדכן את המשאב. נא לנסות שוב.
|
||||
admin_and_system.user_must_be_bot_for_system_user: משתמש חייב להיות בוט כדי לסמן אותו כמשתמש מערכת.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: אינדקס החלק מחוץ לטווח להעלאה זו.
|
||||
attachments_and_uploads.chunked_upload_incomplete: לא כל החלקים הועלו.
|
||||
attachments_and_uploads.chunked_upload_not_found: סשן ההעלאה לא נמצא או שפג תוקפו.
|
||||
attachments_and_uploads.chunked_upload_not_owned: סשן ההעלאה הזה לא שייך לך.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` ו-`expires_at` הם שדות חובה.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, and `message_id` must be valid integers.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: משתמשים עם MANAGE_MESSAGES יכולים לערוך רק תיאורי קבצים מצורפים, לא מטא-דאטה אחרת.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: परीक्षण हार्न
|
||||
admin_and_system.unknown_suspicious_flag: अज्ञात संदिग्ध ध्वज।
|
||||
admin_and_system.update_failed: हम संसाधन को अद्यतन करने में विफल रहे। कृपया दोबारा प्रयास करें।
|
||||
admin_and_system.user_must_be_bot_for_system_user: उपयोगकर्ता को सिस्टम उपयोगकर्ता के रूप में चिह्नित किए जाने के लिए बॉट होना चाहिए।
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: इस upload के लिए chunk index सीमा से बाहर है।
|
||||
attachments_and_uploads.chunked_upload_incomplete: सभी chunks upload नहीं हुए हैं।
|
||||
attachments_and_uploads.chunked_upload_not_found: Upload session नहीं मिला या expire हो गया है।
|
||||
attachments_and_uploads.chunked_upload_not_owned: यह upload session तुम्हारा नहीं है।
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, और `expires_at` जरूरी हैं।'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, and `message_id` must be valid integers.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: MANAGE_MESSAGES permission वाले users सिर्फ attachment descriptions edit कर सकते हैं, दूसरा metadata नहीं।
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness nije dopušten.
|
||||
admin_and_system.unknown_suspicious_flag: Nepoznat suspicious flag.
|
||||
admin_and_system.update_failed: Ažuriranje nije uspjelo.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Korisnik mora biti bot da bi bio označen kao sistemski korisnik.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Indeks dijela je izvan raspona za ovaj upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nisu svi dijelovi uploadani.
|
||||
attachments_and_uploads.chunked_upload_not_found: Sesija uploada nije pronađena ili je istekla.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Ne posjeduješ ovu sesiju uploada.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` i `expires_at` su obavezni.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` i `message_id` moraju biti valjani cijeli brojevi.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Korisnici s MANAGE_MESSAGES mogu uređivati samo opise privitaka, ne ostale metapodatke.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: A tesztkészlet nem engedélyezett.
|
||||
admin_and_system.unknown_suspicious_flag: Ismeretlen gyanús jelzőszám.
|
||||
admin_and_system.update_failed: Nem sikerült frissíteni az erőforrást. Próbáld újra.
|
||||
admin_and_system.user_must_be_bot_for_system_user: A felhasználónak botnak kell lennie ahhoz, hogy rendszerfelhasználóként legyen megjelölve.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: A darab indexe kívül esik az érvényes tartományon ehhez a feltöltéshez.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nem minden darab lett feltöltve.
|
||||
attachments_and_uploads.chunked_upload_not_found: A feltöltési munkamenet nem található vagy lejárt.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Ez a feltöltési munkamenet nem a tiéd.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` és `expires_at` kötelezőek.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` és `message_id` érvényes egész számoknak kell lenniük.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: A MANAGE_MESSAGES engedéllyel rendelkező felhasználók csak a melléklet leírását szerkeszthetik, más metaadatokat nem.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness dilarang.
|
||||
admin_and_system.unknown_suspicious_flag: Suspicious flag tidak dikenal.
|
||||
admin_and_system.update_failed: Kami tidak dapat memperbarui sumber daya tersebut. Harap coba lagi.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Pengguna harus berupa bot untuk ditandai sebagai pengguna sistem.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Indeks chunk di luar jangkauan untuk unggahan ini.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Belum semua chunk diunggah.
|
||||
attachments_and_uploads.chunked_upload_not_found: Sesi unggahan tidak ditemukan atau sudah kedaluwarsa.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Kamu bukan pemilik sesi unggahan ini.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, dan `expires_at` wajib disertakan.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, dan `message_id` harus berupa bilangan bulat yang valid.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Pengguna dengan izin MANAGE_MESSAGES hanya bisa mengedit deskripsi lampiran, bukan metadata lain.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness non consentito.
|
||||
admin_and_system.unknown_suspicious_flag: Suspicious flag sconosciuta.
|
||||
admin_and_system.update_failed: Aggiornamento non riuscito.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Per essere marcato come utente di sistema, deve essere un bot.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: L'indice del frammento è fuori dall'intervallo per questo caricamento.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Non tutti i frammenti sono stati caricati.
|
||||
attachments_and_uploads.chunked_upload_not_found: La sessione di caricamento non è stata trovata o è scaduta.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Questa sessione di caricamento non è tua.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` e `expires_at` sono obbligatori.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, and `message_id` must be valid integers.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Gli utenti con MANAGE_MESSAGES possono modificare solo le descrizioni degli allegati, non altri metadati.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: テストハーネスは許可されて
|
||||
admin_and_system.unknown_suspicious_flag: 不明な不審フラグです。
|
||||
admin_and_system.update_failed: リソースを更新できませんでした。もう一度お試しください。
|
||||
admin_and_system.user_must_be_bot_for_system_user: ユーザーをシステムユーザーとしてマークするには、ボットである必要があります。
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: チャンクのインデックスがこのアップロードの範囲外だよ。
|
||||
attachments_and_uploads.chunked_upload_incomplete: まだすべてのチャンクがアップロードされてないよ。
|
||||
attachments_and_uploads.chunked_upload_not_found: アップロードセッションが見つからないか、有効期限が切れてるよ。
|
||||
attachments_and_uploads.chunked_upload_not_owned: このアップロードセッションはあなたのものじゃないよ。
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`、`channel_id`、`message_id`、`expires_at`が全て必要だよ。'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`、`channel_id`、`message_id`は有効な整数じゃないとダメだよ。'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: MANAGE_MESSAGES権限を持つユーザーは添付ファイルの説明だけを編集できて、他のメタデータは編集できないよ。
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: 테스트 하네스 사용이 금지돼
|
||||
admin_and_system.unknown_suspicious_flag: 알 수 없는 의심 플래그예요.
|
||||
admin_and_system.update_failed: 리소스를 업데이트하지 못했어요. 잠시 후 다시 시도해 주세요.
|
||||
admin_and_system.user_must_be_bot_for_system_user: 시스템 유저로 표시하려면 사용자는 봇이어야 해요.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: 청크 인덱스가 이 업로드의 범위를 벗어났어요.
|
||||
attachments_and_uploads.chunked_upload_incomplete: 아직 모든 청크가 업로드되지 않았어요.
|
||||
attachments_and_uploads.chunked_upload_not_found: 업로드 세션을 찾을 수 없거나 만료됐어요.
|
||||
attachments_and_uploads.chunked_upload_not_owned: 이 업로드 세션의 소유자가 아니에요.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, `expires_at` 필드가 필요해요.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, `message_id`는 올바른 정수여야 해요.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: MANAGE_MESSAGES 권한을 가진 사용자는 첨부 파일 설명만 수정할 수 있고, 다른 메타데이터는 수정할 수 없어요.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Bandymo arklys uždrausta.
|
||||
admin_and_system.unknown_suspicious_flag: Nežinoma įtartina žyma.
|
||||
admin_and_system.update_failed: Nepavyko atnaujinti resurso. Prašau bandyti dar kartą.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Naudotojas turi būti botas, kad būtų pažymėtas kaip sistemos naudotojas.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Dalies indeksas yra už leistino diapazono šiam įkėlimui.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Ne visos dalys buvo įkeltos.
|
||||
attachments_and_uploads.chunked_upload_not_found: Įkėlimo sesija nerasta arba pasibaigė jos galiojimas.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Ši įkėlimo sesija tau nepriklauso.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` ir `expires_at` yra būtini.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` ir `message_id` turi būti galiojantys sveikieji skaičiai.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Naudotojai su MANAGE_MESSAGES gali redaguoti tik priedu aprašus, o ne kitus metaduomenis.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness is forbidden.
|
||||
admin_and_system.unknown_suspicious_flag: Unknown suspicious flag.
|
||||
admin_and_system.update_failed: We couldn't update the resource. Please try again.
|
||||
admin_and_system.user_must_be_bot_for_system_user: User must be a bot to be marked as a system user.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chunk index is out of range for this upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Not all chunks have been uploaded.
|
||||
attachments_and_uploads.chunked_upload_not_found: Upload session wasn't found or has expired.
|
||||
attachments_and_uploads.chunked_upload_not_owned: You don't own this upload session.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, and `expires_at` are required.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, and `message_id` must be valid integers.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Users with MANAGE_MESSAGES can only edit attachment descriptions, not other metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness is niet toegestaan.
|
||||
admin_and_system.unknown_suspicious_flag: Onbekende suspicious flag.
|
||||
admin_and_system.update_failed: We konden de resource niet bijwerken. Probeer het opnieuw.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Gebruiker moet een bot zijn om als systeemgebruiker gemarkeerd te worden.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: De chunk-index valt buiten het bereik voor deze upload.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Niet alle chunks zijn geüpload.
|
||||
attachments_and_uploads.chunked_upload_not_found: De uploadsessie is niet gevonden of is verlopen.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Je bent niet de eigenaar van deze uploadsessie.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` en `expires_at` zijn verplicht.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` en `message_id` moeten geldige integers zijn.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Gebruikers met MANAGE_MESSAGES kunnen alleen bijlagebeschrijvingen bewerken, niet andere metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness er ikke tillatt.
|
||||
admin_and_system.unknown_suspicious_flag: Ukjent mistenkelig flagg.
|
||||
admin_and_system.update_failed: Vi klarte ikke å oppdatere ressursen. Prøv igjen.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Brukeren må være en bot for å bli markert som systembruker.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chunk-indeksen er utenfor gyldig område for denne opplastinga.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Ikke alle chunks er lasta opp.
|
||||
attachments_and_uploads.chunked_upload_not_found: Opplastingsøkta ble ikke funnet eller har utløpt.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Du eier ikke denne opplastingsøkta.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id`, og `expires_at` er påkrevd.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` og `message_id` må være gyldige heltall.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Brukere med MANAGE_MESSAGES kan bare redigere vedleggsbeskrivelser, ikke andre metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness jest zabroniony.
|
||||
admin_and_system.unknown_suspicious_flag: Nieznana podejrzana flaga.
|
||||
admin_and_system.update_failed: Nie mogliśmy zaktualizować zasobu. Spróbuj ponownie.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Użytkownik musi być botem, aby został oznaczony jako użytkownik systemowy.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Indeks fragmentu jest poza zakresem dla tego przesyłania.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nie wszystkie fragmenty zostały przesłane.
|
||||
attachments_and_uploads.chunked_upload_not_found: Sesja przesyłania nie została znaleziona lub wygasła.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Ta sesja przesyłania nie należy do ciebie.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` i `expires_at` są wymagane.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` i `message_id` muszą być prawidłowymi liczbami całkowitymi.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Użytkownicy z uprawnieniem MANAGE_MESSAGES mogą edytować tylko opisy załączników, a nie inne metadane.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: O teste harness não é permitido.
|
||||
admin_and_system.unknown_suspicious_flag: Sinalizador suspeito desconhecido.
|
||||
admin_and_system.update_failed: Não conseguimos atualizar o recurso. Por favor, tente novamente.
|
||||
admin_and_system.user_must_be_bot_for_system_user: O usuário deve ser um bot para ser marcado como usuário do sistema.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: O índice do fragmento está fora do intervalo para este envio.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nem todos os fragmentos foram enviados.
|
||||
attachments_and_uploads.chunked_upload_not_found: A sessão de envio não foi encontrada ou expirou.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Você não é o dono desta sessão de envio.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` e `expires_at` são obrigatórios.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` e `message_id` devem ser inteiros válidos.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Usuários com MANAGE_MESSAGES só podem editar descrições de anexos, não outros metadados.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Harnesul de testare este interzis.
|
||||
admin_and_system.unknown_suspicious_flag: Steag suspect necunoscut.
|
||||
admin_and_system.update_failed: Nu am putut actualiza resursa. Te rugăm să încerci din nou.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Utilizatorul trebuie să fie bot ca să fie marcat ca utilizator de sistem.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Indexul fragmentului este în afara intervalului pentru această încărcare.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Nu toate fragmentele au fost încărcate.
|
||||
attachments_and_uploads.chunked_upload_not_found: Sesiunea de încărcare nu a fost găsită sau a expirat.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Nu ești proprietarul acestei sesiuni de încărcare.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` și `expires_at` sunt obligatorii.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` și `message_id` trebuie să fie numere întregi valide.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Utilizatorii cu MANAGE_MESSAGES pot edita doar descrierile atașamentelor, nu alte metadate.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Тестовая инфраструкт
|
||||
admin_and_system.unknown_suspicious_flag: Неизвестный флаг подозрительности.
|
||||
admin_and_system.update_failed: Не удалось обновить ресурс. Попробуй еще раз.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Пользователь должен быть ботом, чтобы его пометить как системного.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Индекс части выходит за допустимый диапазон для этой загрузки.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Не все части были загружены.
|
||||
attachments_and_uploads.chunked_upload_not_found: Сессия загрузки не найдена или истекла.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Эта сессия загрузки не принадлежит тебе.
|
||||
attachments_and_uploads.attachment_fields_required: Обязательны `attachment_id`, `channel_id`, `message_id` и `expires_at`.
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` и `message_id` должны быть валидными целыми числами.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Пользователи с правом MANAGE_MESSAGES могут редактировать только описания вложений, но не другие метаданные.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Testhärnessen är förbjuden.
|
||||
admin_and_system.unknown_suspicious_flag: Okänd flagga för misstänkt aktivitet.
|
||||
admin_and_system.update_failed: Vi kunde inte uppdatera resursen. Försök igen.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Användaren måste vara en bot för att kunna markeras som systemanvändare.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chunk-indexet är utanför det giltiga intervallet för den här uppladdningen.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Inte alla chunks har laddats upp.
|
||||
attachments_and_uploads.chunked_upload_not_found: Uppladdningssessionen hittades inte eller har gått ut.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Du äger inte den här uppladdningssessionen.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` och `expires_at` krävs.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` och `message_id` måste vara giltiga heltal.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Användare med MANAGE_MESSAGES kan bara redigera bilagebeskrivningar, inte annan metadata.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: ห้ามใช้ test harness
|
||||
admin_and_system.unknown_suspicious_flag: แฟล็กที่น่าสงสัยไม่รู้จัก
|
||||
admin_and_system.update_failed: ไม่สามารถอัปเดตทรัพยากรได้ โปรดลองใหม่
|
||||
admin_and_system.user_must_be_bot_for_system_user: ผู้ใช้ต้องเป็น bot เพื่อให้ทำเครื่องหมายว่าเป็นผู้ใช้ระบบ
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: ดัชนี chunk อยู่นอกช่วงที่อนุญาตสำหรับการอัปโหลดนี้
|
||||
attachments_and_uploads.chunked_upload_incomplete: ยังไม่ได้อัปโหลด chunk ทั้งหมด
|
||||
attachments_and_uploads.chunked_upload_not_found: ไม่พบเซสชันการอัปโหลดหรือหมดอายุแล้ว
|
||||
attachments_and_uploads.chunked_upload_not_owned: เซสชันการอัปโหลดนี้ไม่ใช่ของคุณ
|
||||
attachments_and_uploads.attachment_fields_required: ต้องระบุ `attachment_id`, `channel_id`, `message_id` และ `expires_at`
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` และ `message_id` ต้องเป็นจำนวนเต็มที่ถูกต้อง'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: ผู้ใช้ที่มีสิทธิ์ MANAGE_MESSAGES สามารถแก้ไขเฉพาะคำอธิบายไฟล์แนบเท่านั้น
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Test harness yasaklı.
|
||||
admin_and_system.unknown_suspicious_flag: Bilinmeyen şüpheli bayrak.
|
||||
admin_and_system.update_failed: Kaynağı güncellemedik. Lütfen tekrar dene.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Bir kullanıcının sistem kullanıcısı olarak işaretlenmesi için bot olması gerekir.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Parça indeksi bu yükleme için geçerli aralığın dışında.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Tüm parçalar henüz yüklenmedi.
|
||||
attachments_and_uploads.chunked_upload_not_found: Yükleme oturumu bulunamadı veya süresi dolmuş.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Bu yükleme oturumu sana ait değil.
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`, `channel_id`, `message_id` ve `expires_at` gerekli.'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` ve `message_id` geçerli integer olmalı.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: MANAGE_MESSAGES yetkisine sahip kullanıcılar yalnızca ek açıklamalarını düzenleyebilir, diğer metadataları düzenleyemez.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Тестовий стенд забор
|
||||
admin_and_system.unknown_suspicious_flag: Невідомий прапор підозрілої діяльності.
|
||||
admin_and_system.update_failed: Не вдалося оновити ресурс. Спробуй ще раз.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Користувач має бути ботом, щоб його позначити як системного.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Індекс частини виходить за межі допустимого діапазону для цього завантаження.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Не всі частини було завантажено.
|
||||
attachments_and_uploads.chunked_upload_not_found: Сеанс завантаження не знайдено або він закінчився.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Цей сеанс завантаження не належить тобі.
|
||||
attachments_and_uploads.attachment_fields_required: Обов'язкові `attachment_id`, `channel_id`, `message_id` і `expires_at`.
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id` і `message_id` мають бути валідними цілими числами.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Користувачі з правом MANAGE_MESSAGES можуть редагувати лише описи вкладень, а не інші метадані.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: Thiết bị kiểm thử bị cấm.
|
||||
admin_and_system.unknown_suspicious_flag: Cờ đáng ngờ không xác định.
|
||||
admin_and_system.update_failed: Không thể cập nhật tài nguyên. Vui lòng thử lại.
|
||||
admin_and_system.user_must_be_bot_for_system_user: Người dùng phải là bot để được đánh dấu là người dùng hệ thống.
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: Chỉ mục phần nằm ngoài phạm vi cho lần tải lên này.
|
||||
attachments_and_uploads.chunked_upload_incomplete: Chưa tải lên hết tất cả các phần.
|
||||
attachments_and_uploads.chunked_upload_not_found: Phiên tải lên không tìm thấy hoặc đã hết hạn.
|
||||
attachments_and_uploads.chunked_upload_not_owned: Bạn không sở hữu phiên tải lên này.
|
||||
attachments_and_uploads.attachment_fields_required: Cần có `attachment_id`, `channel_id`, `message_id`, và `expires_at`.
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`, `channel_id`, và `message_id` phải là số nguyên hợp lệ.'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: Người dùng có quyền MANAGE_MESSAGES chỉ có thể chỉnh sửa mô tả attachment, không thể sửa metadata khác.
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: 禁止使用测试工具。
|
||||
admin_and_system.unknown_suspicious_flag: 未知的可疑标记。
|
||||
admin_and_system.update_failed: 无法更新该资源,请重试。
|
||||
admin_and_system.user_must_be_bot_for_system_user: 要标记为系统用户,该用户必须是机器人。
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: 分片索引超出此上传的有效范围。
|
||||
attachments_and_uploads.chunked_upload_incomplete: 尚未上传所有分片。
|
||||
attachments_and_uploads.chunked_upload_not_found: 上传会话未找到或已过期。
|
||||
attachments_and_uploads.chunked_upload_not_owned: 你不是这个上传会话的所有者。
|
||||
attachments_and_uploads.attachment_fields_required: 必须提供 `attachment_id`、`channel_id`、`message_id` 和 `expires_at`。
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`、`channel_id` 和 `message_id` 必须为有效的整数。'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: 仅具有 MANAGE_MESSAGES 权限的用户可以编辑附件描述,不能编辑其他元数据。
|
||||
|
||||
@ -50,10 +50,6 @@ admin_and_system.test_harness_forbidden: 禁止使用測試工具。
|
||||
admin_and_system.unknown_suspicious_flag: 未知的可疑旗標。
|
||||
admin_and_system.update_failed: 我們沒能更新該資源。請再試一次。
|
||||
admin_and_system.user_must_be_bot_for_system_user: 使用者必須是機器人才能標記為系統使用者。
|
||||
attachments_and_uploads.chunked_upload_chunk_index_out_of_range: 分片索引超出此上傳的有效範圍。
|
||||
attachments_and_uploads.chunked_upload_incomplete: 尚未上傳所有分片。
|
||||
attachments_and_uploads.chunked_upload_not_found: 上傳工作階段未找到或已過期。
|
||||
attachments_and_uploads.chunked_upload_not_owned: 你不是這個上傳工作階段的擁有者。
|
||||
attachments_and_uploads.attachment_fields_required: '`attachment_id`、`channel_id`、`message_id` 和 `expires_at` 為必填。'
|
||||
attachments_and_uploads.attachment_ids_must_be_valid_integers: '`attachment_id`、`channel_id` 和 `message_id` 必須是有效的整數。'
|
||||
attachments_and_uploads.cannot_edit_attachment_metadata: 擁有 MANAGE_MESSAGES 權限的使用者只能編輯附件描述,無法編輯其他中繼資料。
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
|
||||
import {
|
||||
coerceNumberFromString,
|
||||
createStringType,
|
||||
Int32Type,
|
||||
SnowflakeType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const CreateChunkedUploadRequest = z.object({
|
||||
filename: FilenameType.describe('The name of the file being uploaded'),
|
||||
file_size: z.number().int().positive().describe('The total size of the file in bytes'),
|
||||
});
|
||||
export type CreateChunkedUploadRequest = z.infer<typeof CreateChunkedUploadRequest>;
|
||||
|
||||
export const CreateChunkedUploadResponse = z.object({
|
||||
upload_id: z.string().describe('The unique identifier for the upload session'),
|
||||
upload_filename: z.string().describe('The temporary filename used to reference this upload'),
|
||||
chunk_size: z.number().int().describe('The size of each chunk in bytes'),
|
||||
chunk_count: z.number().int().describe('The total number of chunks to upload'),
|
||||
});
|
||||
export type CreateChunkedUploadResponse = z.infer<typeof CreateChunkedUploadResponse>;
|
||||
|
||||
export const UploadChunkResponse = z.object({
|
||||
etag: z.string().describe('The ETag of the uploaded chunk'),
|
||||
});
|
||||
export type UploadChunkResponse = z.infer<typeof UploadChunkResponse>;
|
||||
|
||||
export const CompleteChunkedUploadRequest = z.object({
|
||||
etags: z
|
||||
.array(
|
||||
z.object({
|
||||
chunk_index: z.number().int().min(0).describe('The zero-based index of the chunk'),
|
||||
etag: z.string().describe('The ETag returned when the chunk was uploaded'),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe('Array of chunk ETags in order'),
|
||||
});
|
||||
export type CompleteChunkedUploadRequest = z.infer<typeof CompleteChunkedUploadRequest>;
|
||||
|
||||
export const CompleteChunkedUploadResponse = z.object({
|
||||
upload_filename: z.string().describe('The temporary filename used to reference this upload'),
|
||||
file_size: z.number().int().describe('The total size of the uploaded file in bytes'),
|
||||
content_type: z.string().describe('The MIME type of the uploaded file'),
|
||||
});
|
||||
export type CompleteChunkedUploadResponse = z.infer<typeof CompleteChunkedUploadResponse>;
|
||||
|
||||
export const ChunkedUploadParam = z.object({
|
||||
channel_id: SnowflakeType.describe('The ID of the channel'),
|
||||
upload_id: createStringType(1, 128).describe('The ID of the chunked upload session'),
|
||||
});
|
||||
export type ChunkedUploadParam = z.infer<typeof ChunkedUploadParam>;
|
||||
|
||||
export const ChunkedUploadChunkParam = z.object({
|
||||
channel_id: SnowflakeType.describe('The ID of the channel'),
|
||||
upload_id: createStringType(1, 128).describe('The ID of the chunked upload session'),
|
||||
chunk_index: coerceNumberFromString(Int32Type.min(0)).describe('The zero-based index of the chunk'),
|
||||
});
|
||||
export type ChunkedUploadChunkParam = z.infer<typeof ChunkedUploadChunkParam>;
|
||||
@ -48,7 +48,6 @@ const ClientAttachmentBase = z.object({
|
||||
export const ClientAttachmentRequest = ClientAttachmentBase.extend({
|
||||
id: coerceNumberFromString(Int32Type).describe('The client-side identifier for this attachment'),
|
||||
filename: FilenameType.describe('The name of the file being uploaded'),
|
||||
uploaded_filename: z.string().optional().describe('The temporary filename from a completed chunked upload'),
|
||||
});
|
||||
export type ClientAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user