Update index.js
Browse files
index.js
CHANGED
|
@@ -248,6 +248,23 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 248 |
let fileName = 'unknown';
|
| 249 |
let mimeType = 'application/octet-stream';
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
// ββ Per-TG-document state βββββββββββββββββββββββββββββββββββββββββββββ
|
| 252 |
const finalDocs = [];
|
| 253 |
let docIdx = 0;
|
|
@@ -255,12 +272,14 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 255 |
let tgPartIdx = 0; // part index within current TG doc
|
| 256 |
let tgFileId = makeTgFileId(0);
|
| 257 |
let totalBytes = 0;
|
|
|
|
| 258 |
|
| 259 |
-
// Accumulation ring β drained only in exact PART_SIZE slices
|
| 260 |
-
|
| 261 |
-
|
|
|
|
| 262 |
|
| 263 |
-
// In-flight SaveBigFilePart promises
|
| 264 |
const inFlight = [];
|
| 265 |
|
| 266 |
async function flushInFlight() {
|
|
@@ -268,26 +287,69 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 268 |
await Promise.all(inFlight.splice(0));
|
| 269 |
}
|
| 270 |
|
| 271 |
-
//
|
| 272 |
-
//
|
| 273 |
-
//
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
//
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
const docCeiling = (declaredSize > 0 && declaredSize <= PIECE_BYTES)
|
| 284 |
-
? declaredSize
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
const estTotal = Math.max(
|
| 289 |
-
tgPartIdx + 4, // always > current partNo
|
| 290 |
-
Math.ceil(docCeiling / PART_SIZE) + 4 // ceiling for remaining parts
|
| 291 |
);
|
| 292 |
|
| 293 |
const p = client.invoke(new Api.upload.SaveBigFilePart({
|
|
@@ -305,16 +367,15 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 305 |
}
|
| 306 |
}
|
| 307 |
|
| 308 |
-
// Drain
|
| 309 |
-
// If `final` is true, also flush
|
| 310 |
async function drainPartBuf(fileStream, final) {
|
| 311 |
while (partLen >= PART_SIZE) {
|
| 312 |
-
// Collect exactly PART_SIZE bytes
|
| 313 |
const slices = [];
|
| 314 |
let need = PART_SIZE;
|
| 315 |
while (need > 0) {
|
| 316 |
const top = partChunks[0];
|
| 317 |
-
if (!top) break;
|
| 318 |
if (top.length <= need) {
|
| 319 |
slices.push(top);
|
| 320 |
need -= top.length;
|
|
@@ -327,22 +388,17 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 327 |
}
|
| 328 |
partLen -= PART_SIZE;
|
| 329 |
if (partLen < 0) { console.warn('[BUG] partLen underflow, clamping'); partLen = 0; }
|
| 330 |
-
await
|
| 331 |
}
|
| 332 |
|
| 333 |
-
// Final call: flush remaining bytes (last part of the ENTIRE file β the
|
| 334 |
-
// only place a sub-512KB part is legal).
|
| 335 |
if (final && partLen > 0) {
|
| 336 |
const buf = Buffer.concat(partChunks.splice(0));
|
| 337 |
partLen = 0;
|
| 338 |
-
await
|
| 339 |
}
|
| 340 |
}
|
| 341 |
|
| 342 |
-
// Finalize
|
| 343 |
-
// `committedBytes` = exact bytes uploaded into THIS TG doc (supplied by
|
| 344 |
-
// caller so we never depend on the mutable docBytes variable during
|
| 345 |
-
// boundary splits where docBytes has already been updated).
|
| 346 |
async function finalizeDoc(committedBytes) {
|
| 347 |
await flushInFlight();
|
| 348 |
if (tgPartIdx === 0) return;
|
|
@@ -351,12 +407,12 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 351 |
const sent = await client.invoke(new Api.messages.SendMedia({
|
| 352 |
peer,
|
| 353 |
media: new Api.InputMediaUploadedDocument({
|
| 354 |
-
file: new Api.InputFileBig({
|
| 355 |
id: tgFileId,
|
| 356 |
-
parts: tgPartIdx,
|
| 357 |
name: tgName,
|
| 358 |
}),
|
| 359 |
-
mimeType: 'application/octet-stream',
|
| 360 |
forceFile: true,
|
| 361 |
attributes: [new Api.DocumentAttributeFilename({ fileName: tgName })],
|
| 362 |
}),
|
|
@@ -375,18 +431,13 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 375 |
partLabel: `.part${docIdx + 1}`,
|
| 376 |
});
|
| 377 |
|
| 378 |
-
console.log(`[Upload] part ${docIdx + 1} done -> msgId=${msgId} ${formatBytes(realSize || committedBytes)}`);
|
| 379 |
-
|
| 380 |
-
// Reset only TG-doc level counters.
|
| 381 |
-
// docBytes is intentionally NOT reset here; the data-handler loop
|
| 382 |
-
// manages it correctly (sets docBytes = partLen after a split, so the
|
| 383 |
-
// next document's byte accounting starts from the carry-over size).
|
| 384 |
docIdx++;
|
| 385 |
tgPartIdx = 0;
|
| 386 |
tgFileId = makeTgFileId(docIdx);
|
| 387 |
}
|
| 388 |
|
| 389 |
-
// ββ Stream: busboy ->
|
| 390 |
await new Promise((resolve, reject) => {
|
| 391 |
const bb = busboy({ headers: req.headers });
|
| 392 |
|
|
@@ -398,47 +449,30 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 398 |
fileStream.on('data', async (chunk) => {
|
| 399 |
fileStream.pause();
|
| 400 |
try {
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
//
|
| 409 |
-
const slice = (offset === 0 && take === chunk.length)
|
| 410 |
-
? chunk // no-copy fast path
|
| 411 |
-
: chunk.slice(offset, offset + take);
|
| 412 |
-
|
| 413 |
-
partChunks.push(slice);
|
| 414 |
-
partLen += take;
|
| 415 |
-
totalBytes += take;
|
| 416 |
-
docBytes += take;
|
| 417 |
-
offset += take;
|
| 418 |
-
|
| 419 |
-
// Drain complete 512 KB parts (never flushes sub-512KB here)
|
| 420 |
await drainPartBuf(fileStream, false);
|
| 421 |
|
| 422 |
-
// If we have hit the 1.9 GB boundary, finalize this TG document.
|
| 423 |
-
// CRITICAL: do NOT call drainPartBuf(final=true) here.
|
| 424 |
-
// Any bytes still in partChunks (< 512 KB leftover) stay in the
|
| 425 |
-
// buffer and will be prepended to the NEXT document's stream,
|
| 426 |
-
// satisfying Telegram's rule that only the very last part of
|
| 427 |
-
// the ENTIRE file may be < 512 KB.
|
| 428 |
if (docBytes >= PIECE_BYTES) {
|
| 429 |
-
//
|
| 430 |
-
// (handles the edge case where docBytes lands on a part boundary)
|
| 431 |
-
await drainPartBuf(fileStream, false);
|
| 432 |
-
// committedBytes = bytes that actually went into this TG doc.
|
| 433 |
-
// partLen is the carry-over that will belong to the NEXT doc.
|
| 434 |
const committedToThisDoc = PIECE_BYTES - partLen;
|
| 435 |
await finalizeDoc(committedToThisDoc);
|
| 436 |
-
|
| 437 |
-
// Reset docBytes to the carry-over size so the next doc's
|
| 438 |
-
// byte accounting is correct from the start.
|
| 439 |
-
docBytes = partLen;
|
| 440 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
|
|
|
| 442 |
|
| 443 |
fileStream.resume();
|
| 444 |
} catch (e) { reject(e); }
|
|
@@ -446,8 +480,21 @@ app.post('/upload', checkRAM, (req, res) => {
|
|
| 446 |
|
| 447 |
fileStream.on('end', async () => {
|
| 448 |
try {
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
resolve();
|
| 452 |
} catch (e) { reject(e); }
|
| 453 |
});
|
|
|
|
| 248 |
let fileName = 'unknown';
|
| 249 |
let mimeType = 'application/octet-stream';
|
| 250 |
|
| 251 |
+
// ββ Telegram upload API thresholds ββββββββββββββββββββββββββββββββββββ
|
| 252 |
+
// Files <= SMALL_FILE_THRESHOLD β SaveFilePart + InputFile (small-file API)
|
| 253 |
+
// Files > SMALL_FILE_THRESHOLD β SaveBigFilePart + InputFileBig (big-file API)
|
| 254 |
+
//
|
| 255 |
+
// Telegram enforces this split strictly:
|
| 256 |
+
// β’ SaveBigFilePart rejects parts < 512 KB unless it's the very last part
|
| 257 |
+
// of a >10 MB file.
|
| 258 |
+
// β’ SaveFilePart has no minimum part size but is limited to files β€ 10 MB.
|
| 259 |
+
//
|
| 260 |
+
// Since we don't always know the size upfront (streaming), we use
|
| 261 |
+
// declaredSize (x-file-size header) when available. If unknown we default
|
| 262 |
+
// to the big-file path (safe for all sizes) but only once we know the
|
| 263 |
+
// file is actually large. For truly unknown sizes we buffer up to
|
| 264 |
+
// SMALL_FILE_THRESHOLD + 1 byte: if the file fits, use small API; if not,
|
| 265 |
+
// switch to big-file streaming with the buffered bytes as the first parts.
|
| 266 |
+
const SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
| 267 |
+
|
| 268 |
// ββ Per-TG-document state βββββββββββββββββββββββββββββββββββββββββββββ
|
| 269 |
const finalDocs = [];
|
| 270 |
let docIdx = 0;
|
|
|
|
| 272 |
let tgPartIdx = 0; // part index within current TG doc
|
| 273 |
let tgFileId = makeTgFileId(0);
|
| 274 |
let totalBytes = 0;
|
| 275 |
+
let isBigFile = (declaredSize > SMALL_FILE_THRESHOLD); // resolved at stream-end if unknown
|
| 276 |
|
| 277 |
+
// Accumulation ring β drained only in exact PART_SIZE slices (big-file path)
|
| 278 |
+
// Also used as a full-file buffer on the small-file path.
|
| 279 |
+
const partChunks = [];
|
| 280 |
+
let partLen = 0;
|
| 281 |
|
| 282 |
+
// In-flight SaveBigFilePart promises (big-file path only)
|
| 283 |
const inFlight = [];
|
| 284 |
|
| 285 |
async function flushInFlight() {
|
|
|
|
| 287 |
await Promise.all(inFlight.splice(0));
|
| 288 |
}
|
| 289 |
|
| 290 |
+
// ββ Small-file path βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
// Called once, after the entire file has been buffered (β€ 10 MB).
|
| 292 |
+
// Uses SaveFilePart (no minimum part size) + InputFile.
|
| 293 |
+
async function uploadSmallFile(fullBuf, committedBytes) {
|
| 294 |
+
const SMALL_PART = 512 * 1024; // can use any size β€ 512 KB; 512 KB is fine
|
| 295 |
+
const numParts = Math.ceil(fullBuf.length / SMALL_PART);
|
| 296 |
+
const smallId = tgFileId; // already a BigInt
|
| 297 |
+
|
| 298 |
+
for (let i = 0; i < numParts; i++) {
|
| 299 |
+
const slice = fullBuf.slice(i * SMALL_PART, (i + 1) * SMALL_PART);
|
| 300 |
+
await client.invoke(new Api.upload.SaveFilePart({
|
| 301 |
+
fileId: smallId,
|
| 302 |
+
filePart: i,
|
| 303 |
+
bytes: Buffer.isBuffer(slice) ? slice : Buffer.from(slice),
|
| 304 |
+
}));
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
const tgName = `${fileName}.part${docIdx + 1}`;
|
| 308 |
+
const sent = await client.invoke(new Api.messages.SendMedia({
|
| 309 |
+
peer,
|
| 310 |
+
media: new Api.InputMediaUploadedDocument({
|
| 311 |
+
file: new Api.InputFile({ // β InputFile, NOT InputFileBig
|
| 312 |
+
id: smallId,
|
| 313 |
+
parts: numParts,
|
| 314 |
+
name: tgName,
|
| 315 |
+
md5Checksum: '',
|
| 316 |
+
}),
|
| 317 |
+
mimeType: mimeType || 'application/octet-stream',
|
| 318 |
+
forceFile: true,
|
| 319 |
+
attributes: [new Api.DocumentAttributeFilename({ fileName: tgName })],
|
| 320 |
+
}),
|
| 321 |
+
message: `\ud83d\udcc2 ${fileName} | Part ${docIdx + 1}`,
|
| 322 |
+
randomId: BigInt(Date.now()) + BigInt(Math.floor(Math.random() * 1e9)),
|
| 323 |
+
}));
|
| 324 |
+
|
| 325 |
+
const { msgId, realSize, dcId } = extractMsgInfo(sent, committedBytes);
|
| 326 |
+
if (!msgId) throw new Error(`SendMedia returned no messageId for part ${docIdx + 1}`);
|
| 327 |
+
|
| 328 |
+
finalDocs.push({
|
| 329 |
+
index: docIdx,
|
| 330 |
+
messageId: msgId,
|
| 331 |
+
size: realSize || committedBytes,
|
| 332 |
+
dcId,
|
| 333 |
+
partLabel: `.part${docIdx + 1}`,
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
console.log(`[Upload] small-file part ${docIdx + 1} done -> msgId=${msgId} ${formatBytes(realSize || committedBytes)}`);
|
| 337 |
+
docIdx++;
|
| 338 |
+
tgPartIdx = 0;
|
| 339 |
+
tgFileId = makeTgFileId(docIdx);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// ββ Big-file path: send one part (exactly PART_SIZE, or true final part) β
|
| 343 |
+
// fileTotalParts is a running upper bound. Telegram requires:
|
| 344 |
+
// (a) filePart < fileTotalParts on every SaveBigFilePart call
|
| 345 |
+
// (b) InputFileBig.parts == exact count at SendMedia
|
| 346 |
+
async function sendBigPart(buf, fileStream) {
|
| 347 |
+
const partNo = tgPartIdx++;
|
| 348 |
const docCeiling = (declaredSize > 0 && declaredSize <= PIECE_BYTES)
|
| 349 |
+
? declaredSize : PIECE_BYTES;
|
| 350 |
+
const estTotal = Math.max(
|
| 351 |
+
tgPartIdx + 4,
|
| 352 |
+
Math.ceil(docCeiling / PART_SIZE) + 4
|
|
|
|
|
|
|
|
|
|
| 353 |
);
|
| 354 |
|
| 355 |
const p = client.invoke(new Api.upload.SaveBigFilePart({
|
|
|
|
| 367 |
}
|
| 368 |
}
|
| 369 |
|
| 370 |
+
// Drain accumulation ring into exactly PART_SIZE big-file sends.
|
| 371 |
+
// If `final` is true, also flush the sub-512KB remainder (true last part only).
|
| 372 |
async function drainPartBuf(fileStream, final) {
|
| 373 |
while (partLen >= PART_SIZE) {
|
|
|
|
| 374 |
const slices = [];
|
| 375 |
let need = PART_SIZE;
|
| 376 |
while (need > 0) {
|
| 377 |
const top = partChunks[0];
|
| 378 |
+
if (!top) break;
|
| 379 |
if (top.length <= need) {
|
| 380 |
slices.push(top);
|
| 381 |
need -= top.length;
|
|
|
|
| 388 |
}
|
| 389 |
partLen -= PART_SIZE;
|
| 390 |
if (partLen < 0) { console.warn('[BUG] partLen underflow, clamping'); partLen = 0; }
|
| 391 |
+
await sendBigPart(Buffer.concat(slices), fileStream);
|
| 392 |
}
|
| 393 |
|
|
|
|
|
|
|
| 394 |
if (final && partLen > 0) {
|
| 395 |
const buf = Buffer.concat(partChunks.splice(0));
|
| 396 |
partLen = 0;
|
| 397 |
+
await sendBigPart(buf, null);
|
| 398 |
}
|
| 399 |
}
|
| 400 |
|
| 401 |
+
// Finalize a big-file TG document -> SendMedia with InputFileBig
|
|
|
|
|
|
|
|
|
|
| 402 |
async function finalizeDoc(committedBytes) {
|
| 403 |
await flushInFlight();
|
| 404 |
if (tgPartIdx === 0) return;
|
|
|
|
| 407 |
const sent = await client.invoke(new Api.messages.SendMedia({
|
| 408 |
peer,
|
| 409 |
media: new Api.InputMediaUploadedDocument({
|
| 410 |
+
file: new Api.InputFileBig({ // β InputFileBig for >10 MB files
|
| 411 |
id: tgFileId,
|
| 412 |
+
parts: tgPartIdx, // ACTUAL parts sent β critical
|
| 413 |
name: tgName,
|
| 414 |
}),
|
| 415 |
+
mimeType: mimeType || 'application/octet-stream',
|
| 416 |
forceFile: true,
|
| 417 |
attributes: [new Api.DocumentAttributeFilename({ fileName: tgName })],
|
| 418 |
}),
|
|
|
|
| 431 |
partLabel: `.part${docIdx + 1}`,
|
| 432 |
});
|
| 433 |
|
| 434 |
+
console.log(`[Upload] big-file part ${docIdx + 1} done -> msgId=${msgId} ${formatBytes(realSize || committedBytes)}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
docIdx++;
|
| 436 |
tgPartIdx = 0;
|
| 437 |
tgFileId = makeTgFileId(docIdx);
|
| 438 |
}
|
| 439 |
|
| 440 |
+
// ββ Stream: busboy -> dual-path Telegram upload βββββββββββββββββββββββ
|
| 441 |
await new Promise((resolve, reject) => {
|
| 442 |
const bb = busboy({ headers: req.headers });
|
| 443 |
|
|
|
|
| 449 |
fileStream.on('data', async (chunk) => {
|
| 450 |
fileStream.pause();
|
| 451 |
try {
|
| 452 |
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
| 453 |
+
partChunks.push(buf);
|
| 454 |
+
partLen += buf.length;
|
| 455 |
+
totalBytes += buf.length;
|
| 456 |
+
docBytes += buf.length;
|
| 457 |
+
|
| 458 |
+
if (isBigFile) {
|
| 459 |
+
// ββ Big-file path: drain complete 512 KB parts, enforce doc boundary β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
await drainPartBuf(fileStream, false);
|
| 461 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
if (docBytes >= PIECE_BYTES) {
|
| 463 |
+
await drainPartBuf(fileStream, false); // drain any newly-aligned parts
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
const committedToThisDoc = PIECE_BYTES - partLen;
|
| 465 |
await finalizeDoc(committedToThisDoc);
|
| 466 |
+
docBytes = partLen; // carry-over belongs to next doc
|
|
|
|
|
|
|
|
|
|
| 467 |
}
|
| 468 |
+
|
| 469 |
+
} else if (declaredSize <= 0 && partLen > SMALL_FILE_THRESHOLD) {
|
| 470 |
+
// ββ Unknown size crossed threshold mid-stream: switch to big-file ββ
|
| 471 |
+
isBigFile = true;
|
| 472 |
+
console.log(`[Upload] "${fileName}" exceeds ${formatBytes(SMALL_FILE_THRESHOLD)}, switching to big-file path`);
|
| 473 |
+
await drainPartBuf(fileStream, false);
|
| 474 |
}
|
| 475 |
+
// else: small-file path β just keep buffering until stream end
|
| 476 |
|
| 477 |
fileStream.resume();
|
| 478 |
} catch (e) { reject(e); }
|
|
|
|
| 480 |
|
| 481 |
fileStream.on('end', async () => {
|
| 482 |
try {
|
| 483 |
+
// Resolve path for files with unknown declared size
|
| 484 |
+
if (!isBigFile && declaredSize <= 0) {
|
| 485 |
+
isBigFile = (totalBytes > SMALL_FILE_THRESHOLD);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
if (isBigFile) {
|
| 489 |
+
// ββ Big-file path: flush remainder and finalize ββββββββββββββ
|
| 490 |
+
await drainPartBuf(null, true);
|
| 491 |
+
await finalizeDoc(docBytes);
|
| 492 |
+
} else {
|
| 493 |
+
// ββ Small-file path: entire file is in partChunks (β€ 10 MB) β
|
| 494 |
+
const fullBuf = Buffer.concat(partChunks.splice(0));
|
| 495 |
+
partLen = 0;
|
| 496 |
+
await uploadSmallFile(fullBuf, totalBytes);
|
| 497 |
+
}
|
| 498 |
resolve();
|
| 499 |
} catch (e) { reject(e); }
|
| 500 |
});
|