tgapi / server.js
WalleGriffkinder's picture
Update server.js
8a31883 verified
import express from 'express';
import fs from 'fs/promises';
import fssync from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { glob } from 'glob';
import { createProxyMiddleware } from 'http-proxy-middleware';
import crypto from 'crypto';
const APP_PORT = parseInt(process.env.PORT || '7860', 10);
const INTERNAL_TELEGRAM_API_PORT = parseInt(process.env.INTERNAL_TELEGRAM_API_PORT || '8081', 10);
const TELEGRAM_DATA_DIR = process.env.TELEGRAM_DATA_DIR || '/var/lib/telegram-bot-api';
const FILES_TTL_HOURS = parseInt(process.env.FILES_TTL || '-1', 10);
const PROXY_TELEGRAM_PATH_PREFIX = process.env.PROXY_TELEGRAM_PATH_PREFIX || '/tg';
const GITHUB_USERNAME = process.env.GITHUB_USERNAME || '';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
const ENV_GIST_ID = process.env.ENV_GIST_ID || '';
const LINK_ENCRYPTION_KEY_PASSPHRASE = process.env.LINK_ENCRYPTION_KEY;
const DEFAULT_LINK_EXPIRY_HOURS = parseInt(process.env.DEFAULT_LINK_EXPIRY_HOURS || '24', 10);
const app = express();
let encryptionKeyBuffer;
if (LINK_ENCRYPTION_KEY_PASSPHRASE) {
encryptionKeyBuffer = crypto.createHash('sha256').update(String(LINK_ENCRYPTION_KEY_PASSPHRASE)).digest();
} else {
console.warn("[Crypto] LINK_ENCRYPTION_KEY is not set. Encrypted link generation will be disabled.");
}
const CRYPTO_ALGORITHM = 'aes-256-gcm';
const CRYPTO_IV_LENGTH_BYTES = 12;
const CRYPTO_AUTH_TAG_LENGTH_BYTES = 16;
function calculateExpiryTimestamp(hours) {
if (hours === -1) return null;
return Date.now() + hours * 60 * 60 * 1000;
}
function encryptPayload(payloadObject) {
if (!encryptionKeyBuffer) return null;
try {
const text = JSON.stringify(payloadObject);
const iv = crypto.randomBytes(CRYPTO_IV_LENGTH_BYTES);
const cipher = crypto.createCipheriv(CRYPTO_ALGORITHM, encryptionKeyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}.${encrypted}.${authTag.toString('hex')}`;
} catch (error) {
console.error("[Crypto] Encryption failed:", error);
return null;
}
}
function decryptPayload(encryptedString) {
if (!encryptionKeyBuffer) return null;
try {
const parts = encryptedString.split('.');
if (parts.length !== 3) throw new Error("Invalid encrypted string format");
const iv = Buffer.from(parts[0], 'hex');
const encryptedData = parts[1];
const authTag = Buffer.from(parts[2], 'hex');
if (iv.length !== CRYPTO_IV_LENGTH_BYTES) throw new Error("Invalid IV length");
if (authTag.length !== CRYPTO_AUTH_TAG_LENGTH_BYTES) throw new Error("Invalid authTag length");
const decipher = crypto.createDecipheriv(CRYPTO_ALGORITHM, encryptionKeyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
} catch (error) {
console.error("[Crypto] Decryption failed:", error.message);
return null;
}
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
async function getDirectoryStats(dirPath) {
try {
const files = await glob(`${dirPath}/**/*`, { nodir: true, dot: true });
let totalSize = 0;
for (const file of files) {
try {
const stats = await fs.stat(file);
totalSize += stats.size;
} catch (e) { }
}
return {
fileCount: files.length,
totalSizeBytes: totalSize,
totalSizeHuman: formatBytes(totalSize),
};
} catch (error) {
console.error(`Error getting directory stats for ${dirPath}:`, error);
return { fileCount: 0, totalSizeBytes: 0, totalSizeHuman: '0 Bytes', error: error.message };
}
}
async function cleanupOldFiles(dirPath, ttlHours) {
if (ttlHours <= 0) {
return { processed: 0, deleted: 0, errors: 0, message: 'Cleanup disabled (FILES_TTL <= 0)' };
}
console.log(`[TTL] Starting cleanup for files older than ${ttlHours} hours in ${dirPath}`);
const now = Date.now();
const ttlMs = ttlHours * 60 * 60 * 1000;
let processed = 0, deleted = 0, errors = 0;
try {
const files = await glob(`${dirPath}/**/*`, { nodir: true, dot: true, stat: true, withFileTypes: false });
for (const file of files) {
processed++;
try {
const stats = await fs.stat(file);
if (stats.isFile()) {
if ((now - stats.mtimeMs) > ttlMs) {
await fs.unlink(file);
deleted++;
}
}
} catch (e) { errors++; console.error(`[TTL] Error processing file ${file}:`, e.message); }
}
} catch (globError) {
console.error(`[TTL] Error during glob search:`, globError);
return { processed, deleted, errors: errors + 1, message: `Glob error: ${globError.message}` };
}
const result = { processed, deleted, errors, message: `Cleanup completed. Processed: ${processed}, Deleted: ${deleted}, Errors: ${errors}` };
console.log(`[TTL] ${result.message}`);
return result;
}
async function updateEnvGistInGithub() {
if (!GITHUB_USERNAME || !GITHUB_TOKEN || !ENV_GIST_ID) {
console.warn('Gist update skipped: GITHUB_USERNAME, GITHUB_TOKEN, or ENV_GIST_ID is not set.');
return;
}
const gistFilename = `hf_space_telegram_bot_api_proxy_info.json`;
let aggregateData = {
description: `Hugging Face Spaces with Telegram Bot API Proxy & Tools - Aggregated Info.`,
schema_version: "1.2.1",
spaces_info: {}
};
try {
const gistFetchResponse = await fetch(`https://api.github.com/gists/${ENV_GIST_ID}`, {
headers: { 'Authorization': `token ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'HFSpaceTelegramBotAPIProxy' }
});
if (gistFetchResponse.ok) {
const existingGist = await gistFetchResponse.json();
if (existingGist.files && existingGist.files[gistFilename] && existingGist.files[gistFilename].content) {
try {
const parsedContent = JSON.parse(existingGist.files[gistFilename].content);
if (parsedContent && typeof parsedContent.spaces_info === 'object') {
aggregateData = parsedContent;
}
} catch (parseError) {}
}
}
} catch (fetchError) {}
const spaceId = process.env.SPACE_ID || 'unknown_space';
const spaceHost = process.env.SPACE_HOST;
const publicSpaceUrl = spaceHost ? `https://${spaceHost}` : 'N/A (SPACE_HOST not set)';
const currentSpaceEntry = {
last_updated: new Date().toISOString(),
public_space_url: publicSpaceUrl,
telegram_api_proxy_url_template: `${publicSpaceUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot<YOUR_BOT_TOKEN>/<METHOD>`,
stats_url: `${publicSpaceUrl}/stats`,
file_operations_base_url: `${publicSpaceUrl}/file`,
example_urls: {
list_files: `${publicSpaceUrl}/file/list?token=<YOUR_BOT_TOKEN>`,
get_file_info_augmented: `${publicSpaceUrl}/file/<YOUR_BOT_TOKEN>/getFile?file_id=<FILE_ID>`,
download_file_by_id: `${publicSpaceUrl}/file/<YOUR_BOT_TOKEN>/downloadFile?file_id=<FILE_ID>`,
download_file_by_path: `${publicSpaceUrl}/file/<BOT_TOKEN_AS_DIR>/<PATH_TO_FILE>`,
delete_file_by_path: `${publicSpaceUrl}/file/deleteFile?file=<BOT_TOKEN_AS_DIR>/<PATH_TO_FILE>`,
delete_file_by_id: `${publicSpaceUrl}/file/<YOUR_BOT_TOKEN>/deleteById?file_id=<FILE_ID>`,
download_encrypted: `${publicSpaceUrl}/file/downloadEncrypted?payload=<ENCRYPTED_PAYLOAD>`,
},
app_main_port_on_space: APP_PORT,
internal_telegram_api_port: INTERNAL_TELEGRAM_API_PORT,
files_ttl_hours: FILES_TTL_HOURS > 0 ? FILES_TTL_HOURS : 'disabled',
link_encryption_enabled: !!encryptionKeyBuffer,
default_link_expiry_hours: DEFAULT_LINK_EXPIRY_HOURS
};
if (!aggregateData.spaces_info) aggregateData.spaces_info = {};
aggregateData.spaces_info[spaceId] = currentSpaceEntry;
aggregateData.last_updated_by_space = spaceId;
aggregateData.overall_last_updated = new Date().toISOString();
const gistPatchData = {
description: aggregateData.description,
files: { [gistFilename]: { content: JSON.stringify(aggregateData, null, 2) } },
};
try {
const response = await fetch(`https://api.github.com/gists/${ENV_GIST_ID}`, {
method: 'PATCH',
headers: {
'Authorization': `token ${GITHUB_TOKEN}`, 'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'HFSpaceTelegramBotAPIProxy', 'Content-Type': 'application/json',
},
body: JSON.stringify(gistPatchData),
});
if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${await response.text()}`);
console.log(`Gist ${ENV_GIST_ID} (file: ${gistFilename}) updated successfully.`);
} catch (error) { console.error('Error updating Gist:', error.message); }
}
app.get('/stats', async (req, res) => {
const stats = await getDirectoryStats(TELEGRAM_DATA_DIR);
let ttlResult = { message: "TTL cleanup not run or disabled." };
if (req.query.run_ttl_now === 'true' && FILES_TTL_HOURS > 0) {
ttlResult = await cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS);
}
res.json({ directory: TELEGRAM_DATA_DIR, ...stats, files_ttl_hours: FILES_TTL_HOURS > 0 ? FILES_TTL_HOURS : 'disabled', ttl_cleanup_on_this_request: ttlResult });
});
const fileRouter = express.Router();
fileRouter.get('/list', async (req, res) => {
const botToken = req.query.token;
if (!botToken) return res.status(400).json({ error: 'Missing bot_token query parameter.' });
const botDir = path.join(TELEGRAM_DATA_DIR, botToken);
try {
if (!fssync.existsSync(botDir) || !fssync.statSync(botDir).isDirectory()) {
return res.status(404).json({ error: 'Bot directory not found or is not a directory.' });
}
const filesInBotDir = await glob(`${botDir}/**/*`, { nodir: true, dot: true });
const fileDetails = [];
let totalSizeForToken = 0;
for (const filePath of filesInBotDir) {
try {
const stats = await fs.stat(filePath);
fileDetails.push({
path: path.relative(TELEGRAM_DATA_DIR, filePath),
size: stats.size,
sizeHuman: formatBytes(stats.size),
lastModified: stats.mtime,
});
totalSizeForToken += stats.size;
} catch (statError) {
fileDetails.push({ path: path.relative(TELEGRAM_DATA_DIR, filePath), error: "Could not stat file" });
}
}
res.json({
bot_token: botToken,
directory: botDir,
file_count: fileDetails.length,
total_size_bytes: totalSizeForToken,
total_size_human: formatBytes(totalSizeForToken),
files: fileDetails
});
} catch (error) {
console.error(`Error listing files for token ${botToken}:`, error);
res.status(500).json({ error: 'Failed to list files.', details: error.message });
}
});
fileRouter.get('/deleteFile', async (req, res) => {
const relativePath = req.query.file;
if (!relativePath || relativePath.includes('..') || !relativePath.trim()) {
return res.status(400).json({ error: 'Invalid or missing file path query parameter.' });
}
const absoluteRequestedPath = path.normalize(path.join(TELEGRAM_DATA_DIR, relativePath));
if (!path.resolve(absoluteRequestedPath).startsWith(path.resolve(TELEGRAM_DATA_DIR))) {
return res.status(403).json({ error: 'Forbidden: Path traversal attempt detected.' });
}
try {
if (fssync.existsSync(absoluteRequestedPath) && fssync.statSync(absoluteRequestedPath).isFile()) {
await fs.unlink(absoluteRequestedPath);
res.json({ success: true, message: `File deleted: ${relativePath}` });
} else {
res.status(404).json({ error: 'File not found or is not a file.' });
}
} catch (error) {
console.error(`Error deleting file ${relativePath}:`, error);
res.status(500).json({ error: 'Failed to delete file.', details: error.message });
}
});
fileRouter.get('/:botToken/deleteById', async (req, res) => {
const { botToken } = req.params;
const { file_id } = req.query;
if (!botToken || !file_id) {
return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' });
}
try {
const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`);
if (!tgApiResponse.ok) {
const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" }));
return res.status(tgApiResponse.status).json({ error: `Telegram API error (getFile): ${errorData.description || tgApiResponse.statusText}` });
}
const fileInfo = await tgApiResponse.json();
if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) {
return res.status(404).json({ error: 'File not found or path not available via Telegram API (getFile).', details: fileInfo });
}
const filePathOnDisk = fileInfo.result.file_path;
const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR);
const resolvedFilePathOnDisk = path.resolve(filePathOnDisk);
if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) {
console.error(`[Security] Attempt to delete file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`);
return res.status(403).json({ error: 'Forbidden: File path is outside allowed directory.' });
}
if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) {
await fs.unlink(resolvedFilePathOnDisk);
res.json({ success: true, message: `File deleted successfully. Path: ${filePathOnDisk}`, file_id: file_id });
} else {
res.status(404).json({ error: 'File not found on disk for the given file_id.', path_attempted: filePathOnDisk });
}
} catch (error) {
console.error(`Error in /:botToken/deleteById for file_id ${file_id}:`, error);
res.status(500).json({ error: 'Server error during file deletion by ID.', details: error.message });
}
});
fileRouter.get('/downloadEncrypted', async (req, res) => {
const encryptedPayload = req.query.payload;
if (!encryptedPayload) return res.status(400).json({ error: 'Missing payload.' });
if (!encryptionKeyBuffer) return res.status(503).json({ error: 'Encryption/decryption is not configured on the server (LINK_ENCRYPTION_KEY not set).' });
const decrypted = decryptPayload(encryptedPayload);
if (!decrypted) {
return res.status(400).json({ error: 'Invalid or undecryptable payload.' });
}
if (decrypted.expiresAt && Date.now() > decrypted.expiresAt) {
return res.status(410).json({ error: 'Link expired.' });
}
let filePathOnDisk;
let originalFilename;
try {
if (decrypted.type === 'id' && decrypted.bot_token && decrypted.file_id) {
const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${decrypted.bot_token}/getFile?file_id=${decrypted.file_id}`);
if (!tgApiResponse.ok) {
const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" }));
return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` });
}
const fileInfo = await tgApiResponse.json();
if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) {
return res.status(404).json({ error: 'File not found or path not available via Telegram API.', details: fileInfo });
}
filePathOnDisk = fileInfo.result.file_path;
originalFilename = path.basename(filePathOnDisk);
} else if (decrypted.type === 'path' && decrypted.file_path_relative_to_data_dir) {
filePathOnDisk = path.join(TELEGRAM_DATA_DIR, decrypted.file_path_relative_to_data_dir);
originalFilename = path.basename(filePathOnDisk);
} else {
return res.status(400).json({ error: 'Invalid payload structure.' });
}
const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR);
const resolvedFilePathOnDisk = path.resolve(filePathOnDisk);
if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) {
console.error(`[Security] Attempt to access file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`);
return res.status(403).send('Forbidden: File path is outside allowed directory.');
}
if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) {
res.sendFile(resolvedFilePathOnDisk, { headers: { 'Content-Disposition': `attachment; filename="${originalFilename}"` } }, err => {
if (err && !res.headersSent) res.status(500).send('Error sending file.');
});
} else {
res.status(404).send('File not found on disk.');
}
} catch (error) {
console.error(`Error in /downloadEncrypted:`, error);
res.status(500).json({ error: 'Server error during encrypted download process.', details: error.message });
}
});
fileRouter.get('/:botToken/getFile', async (req, res) => {
const { botToken } = req.params;
const { file_id } = req.query;
const customExpiryHours = req.query.link_expiry_hours ? parseInt(req.query.link_expiry_hours, 10) : DEFAULT_LINK_EXPIRY_HOURS;
if (!botToken || !file_id) {
return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' });
}
try {
const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`);
if (!tgApiResponse.ok) {
const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" }));
return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` });
}
const fileInfo = await tgApiResponse.json();
if (fileInfo.ok && fileInfo.result && fileInfo.result.file_path) {
const spaceHost = process.env.SPACE_HOST;
const publicSpaceUrl = spaceHost ? `https://${spaceHost}` : `${req.protocol}://${req.get('host')}`;
const apiFilePath = fileInfo.result.file_path;
let relativePathForLink = "";
const resolvedNormalizedDataDir = path.resolve(TELEGRAM_DATA_DIR);
const resolvedApiFilePath = path.resolve(apiFilePath);
if (resolvedApiFilePath.startsWith(resolvedNormalizedDataDir)) {
relativePathForLink = path.relative(resolvedNormalizedDataDir, resolvedApiFilePath);
} else {
relativePathForLink = apiFilePath;
}
const expiresAt = calculateExpiryTimestamp(customExpiryHours);
const urlSafeRelativePath = relativePathForLink.split(path.sep).join('/');
if (!relativePathForLink.startsWith('..') && relativePathForLink) {
fileInfo.result.direct_download_link_by_path = `${publicSpaceUrl}/file/${urlSafeRelativePath}`;
fileInfo.result.delete_link_by_path = `${publicSpaceUrl}/file/deleteFile?file=${encodeURIComponent(urlSafeRelativePath)}`;
if (encryptionKeyBuffer) {
const encryptedPayloadByPath = encryptPayload({ type: 'path', file_path_relative_to_data_dir: relativePathForLink, expiresAt });
if (encryptedPayloadByPath) {
fileInfo.result.encrypted_download_link_by_path = `${publicSpaceUrl}/file/downloadEncrypted?payload=${encryptedPayloadByPath}`;
}
}
} else {
fileInfo.result.direct_download_link_by_path = "Error: could not construct safe relative path.";
}
fileInfo.result.direct_download_link_by_id = `${publicSpaceUrl}/file/${botToken}/downloadFile?file_id=${file_id}`;
fileInfo.result.delete_link_by_id = `${publicSpaceUrl}/file/${botToken}/deleteById?file_id=${file_id}`;
if (encryptionKeyBuffer) {
const encryptedPayloadById = encryptPayload({ type: 'id', bot_token: botToken, file_id: file_id, expiresAt });
if (encryptedPayloadById) {
fileInfo.result.encrypted_download_link_by_id = `${publicSpaceUrl}/file/downloadEncrypted?payload=${encryptedPayloadById}`;
}
}
}
res.json(fileInfo);
} catch (error) {
console.error(`Error in /:botToken/getFile for file_id ${file_id}:`, error);
res.status(500).json({ error: 'Server error processing getFile request.', details: error.message });
}
});
fileRouter.get('/:botToken/downloadFile', async (req, res) => {
const { botToken } = req.params;
const { file_id } = req.query;
if (!botToken || !file_id) {
return res.status(400).json({ error: 'Missing botToken in path or file_id in query.' });
}
try {
const tgApiResponse = await fetch(`http://localhost:${INTERNAL_TELEGRAM_API_PORT}/bot${botToken}/getFile?file_id=${file_id}`);
if (!tgApiResponse.ok) {
const errorData = await tgApiResponse.json().catch(() => ({ description: "Unknown error from Telegram API" }));
return res.status(tgApiResponse.status).json({ error: `Telegram API error: ${errorData.description || tgApiResponse.statusText}` });
}
const fileInfo = await tgApiResponse.json();
if (!fileInfo.ok || !fileInfo.result || !fileInfo.result.file_path) {
return res.status(404).json({ error: 'File not found or path not available via Telegram API.', details: fileInfo });
}
const filePathOnDisk = fileInfo.result.file_path;
const resolvedDataDir = path.resolve(TELEGRAM_DATA_DIR);
const resolvedFilePathOnDisk = path.resolve(filePathOnDisk);
if (!resolvedFilePathOnDisk.startsWith(resolvedDataDir)) {
console.error(`[Security] Attempt to access file outside TELEGRAM_DATA_DIR. Requested: ${filePathOnDisk}, Allowed base: ${resolvedDataDir}`);
return res.status(403).send('Forbidden: File path is outside allowed directory.');
}
if (fssync.existsSync(resolvedFilePathOnDisk) && fssync.statSync(resolvedFilePathOnDisk).isFile()) {
res.sendFile(resolvedFilePathOnDisk, { headers: { 'Content-Disposition': `attachment; filename="${path.basename(fileInfo.result.file_path)}"` } }, err => {
if (err && !res.headersSent) res.status(500).send('Error sending file.');
});
} else {
res.status(404).send('File not found on disk.');
}
} catch (error) {
console.error(`Error in /:botToken/downloadFile for file_id ${file_id}:`, error);
res.status(500).json({ error: 'Server error during download process.', details: error.message });
}
});
fileRouter.get('/:filepath(*)', (req, res) => {
const relativePath = req.params.filepath;
if (!relativePath || relativePath.includes('..')) return res.status(400).send('Invalid file path.');
const absoluteRequestedPath = path.normalize(path.join(TELEGRAM_DATA_DIR, relativePath));
if (!path.resolve(absoluteRequestedPath).startsWith(path.resolve(TELEGRAM_DATA_DIR))) {
return res.status(403).send('Forbidden: Path traversal attempt detected.');
}
if (fssync.existsSync(absoluteRequestedPath) && fssync.statSync(absoluteRequestedPath).isFile()) {
res.sendFile(absoluteRequestedPath, err => {
if (err && !res.headersSent) res.status(500).send('Error sending file.');
});
} else res.status(404).send('File not found.');
});
app.use('/file', fileRouter);
app.use(PROXY_TELEGRAM_PATH_PREFIX, createProxyMiddleware({
target: `http://localhost:${INTERNAL_TELEGRAM_API_PORT}`,
changeOrigin: true,
pathRewrite: (pathValue, req) => {
return pathValue.startsWith(PROXY_TELEGRAM_PATH_PREFIX) ? pathValue.substring(PROXY_TELEGRAM_PATH_PREFIX.length) : pathValue;
},
onProxyReq: (proxyReq, req, res) => {
console.log(`[Proxy] Request to Telegram API: ${req.method} ${proxyReq.path}`);
},
onError: (err, req, res) => {
console.error('[Proxy] Error:', err);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: 'Proxy Error', message: err.message }));
}
}));
app.get('/', (req, res) => {
const spaceHost = process.env.SPACE_HOST;
const baseUrl = req.protocol + '://' + (spaceHost || req.get('host'));
// Placeholders without angle brackets for better rendering if HTML escaping is tricky
const botTokenPlaceholder = "YOUR_BOT_TOKEN";
const fileIdPlaceholder = "FILE_ID";
const methodPlaceholder = "METHOD_NAME";
const categoryPlaceholder = "CATEGORY";
const filenamePlaceholder = "FILENAME";
const relativeFilePathWithTokenPlaceholder = `bot${botTokenPlaceholder}/${categoryPlaceholder}/${filenamePlaceholder}`;
const linkExpiryPlaceholder = "HOURS";
const encryptedPayloadPlaceholder = "ENCRYPTED_PAYLOAD";
const htmlDoc = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Telegram Bot API Proxy & Tools Documentation</title>
<style>
body { font-family: sans-serif; line-height: 1.6; padding: 20px; max-width: 900px; margin: auto; }
h1, h2, h3, h4 { color: #333; }
code { background-color: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: monospace; word-break: break-all;}
pre { background-color: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }
.endpoint { margin-bottom: 15px; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
.endpoint strong { display: inline-block; min-width: 70px; }
.lang-toggle { text-align: center; margin-bottom: 20px; }
.lang-toggle button { padding: 8px 15px; margin: 0 5px; cursor: pointer; border: 1px solid #ccc; background-color: #eee; border-radius: 4px;}
.lang-toggle button.active { background-color: #007bff; color: white; border-color: #007bff; }
.ru, .en { display: none; }
.ru.active, .en.active { display: block; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f9f9f9; }
</style>
</head>
<body>
<div class="lang-toggle">
<button id="lang-ru-btn" onclick="setLang('ru')">Русский</button>
<button id="lang-en-btn" onclick="setLang('en')">English</button>
</div>
<div class="ru">
<h1>Прокси Telegram Bot API с расширенными инструментами</h1>
<p>Этот Space запускает локальный сервер Telegram Bot API и предоставляет многофункциональное Node.js приложение-прокси с дополнительными инструментами.</p>
<p><strong>Базовый URL вашего Space:</strong> <code>${baseUrl}</code></p>
<h2>1. Основные возможности</h2>
<ul>
<li>"Чистое" проксирование Telegram Bot API (эндпоинт <code>${PROXY_TELEGRAM_PATH_PREFIX}/</code>).</li>
<li>Специальный эндпоинт <code>/file/${botTokenPlaceholder}/getFile</code> для получения стандартного ответа Telegram API, дополненного ссылками на скачивание/удаление.</li>
<li>Управление файлами: статистика, скачивание, удаление, листинг.</li>
<li>Зашифрованные ссылки с истечением срока действия.</li>
<li>Автоматическая очистка кэша (TTL).</li>
<li>Интеграция с GitHub Gist.</li>
</ul>
<h2>2. Прокси Telegram Bot API (Стандартный)</h2>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot${botTokenPlaceholder}/${methodPlaceholder}</code><br>
<strong>Описание:</strong> Используйте этот URL вместо стандартного <code>api.telegram.org</code>. Возвращает <strong>неизмененный</strong> ответ от Telegram API.<br>
</div>
<h2>3. Расширенные инструменты и операции с файлами</h2>
<h3>3.1. Получение информации о файле с дополнительными ссылками</h3>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}/file/${botTokenPlaceholder}/getFile?file_id=${fileIdPlaceholder}</code><br>
<strong>Метод:</strong> <code>GET</code><br>
<strong>Описание:</strong> Делает запрос к локальному Telegram API, получает стандартный ответ и дополняет его полями: <code>direct_download_link_by_path</code>, <code>direct_download_link_by_id</code>, <code>delete_link_by_path</code>, <code>delete_link_by_id</code>, <code>encrypted_download_link_by_path</code>, <code>encrypted_download_link_by_id</code>.<br>
<strong>Управление сроком действия зашифрованных ссылок:</strong> Добавьте параметр <code>?link_expiry_hours=${linkExpiryPlaceholder}</code> к URL (<code>${linkExpiryPlaceholder}</code> - часы, <code>-1</code> для бессрочной). По умолчанию: ${DEFAULT_LINK_EXPIRY_HOURS} ч.
</div>
<h3>3.2. Статистика</h3>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}/stats</code><br>
<strong>Метод:</strong> <code>GET</code><br>
<strong>Параметры Query:</strong> <code>run_ttl_now=true</code> (опционально) - запустить очистку.<br>
<strong>Ответ:</strong> JSON со статистикой.
</div>
<h3>3.3. Другие операции с файлами (базовый путь: <code>${baseUrl}/file</code>)</h3>
<table>
<thead><tr><th>Операция</th><th>URL</th><th>Метод</th><th>Описание</th></tr></thead>
<tbody>
<tr><td>Список файлов бота</td><td><code>/list?token=${botTokenPlaceholder}</code></td><td>GET</td><td>JSON со списком файлов и их размерами.</td></tr>
<tr><td>Скачать по ID</td><td><code>/${botTokenPlaceholder}/downloadFile?file_id=${fileIdPlaceholder}</code></td><td>GET</td><td>Файл для скачивания.</td></tr>
<tr><td>Скачать по пути</td><td><code>/${relativeFilePathWithTokenPlaceholder}</code></td><td>GET</td><td>Файл для скачивания.</td></tr>
<tr><td>Скачать по зашифр. ссылке</td><td><code>/downloadEncrypted?payload=${encryptedPayloadPlaceholder}</code></td><td>GET</td><td>Файл, если ссылка действительна.</td></tr>
<tr><td>Удалить по пути</td><td><code>/deleteFile?file=${relativeFilePathWithTokenPlaceholder}</code></td><td>GET</td><td>JSON с результатом.</td></tr>
<tr><td>Удалить по ID</td><td><code>/${botTokenPlaceholder}/deleteById?file_id=${fileIdPlaceholder}</code></td><td>GET</td><td>JSON с результатом.</td></tr>
</tbody>
</table>
<p><strong>Замечание о Gist:</strong> Если настроено, информация о Space обновляется в <a href="https://gist.github.com/${GITHUB_USERNAME || "_"}/${ENV_GIST_ID}" target="_blank">вашем Gist</a> (файл <code>hf_space_telegram_bot_api_proxy_info.json</code>).</p>
<p><strong>Шифрование ссылок:</strong> ${encryptionKeyBuffer ? "Включено" : "Отключено (LINK_ENCRYPTION_KEY не установлен)"}.</p>
</div>
<div class="en">
<h1>Telegram Bot API Proxy & Tools</h1>
<p>This Space runs a local Telegram Bot API server and provides a feature-rich Node.js proxy application with additional tools.</p>
<p><strong>Your Space Base URL:</strong> <code>${baseUrl}</code></p>
<h2>1. Core Features</h2>
<ul>
<li>"Clean" Telegram Bot API Proxying (<code>${PROXY_TELEGRAM_PATH_PREFIX}/</code> endpoint).</li>
<li>Dedicated endpoint <code>/file/${botTokenPlaceholder}/getFile</code> to get standard Telegram API response augmented with download/delete links.</li>
<li>File Management: statistics, download, delete, list.</li>
<li>Encrypted links with expiry.</li>
<li>Automatic Cache Cleanup (TTL).</li>
<li>GitHub Gist Integration.</li>
</ul>
<h2>2. Telegram Bot API Proxy (Standard)</h2>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}${PROXY_TELEGRAM_PATH_PREFIX}/bot${botTokenPlaceholder}/${methodPlaceholder}</code><br>
<strong>Description:</strong> Use this URL instead of the standard <code>api.telegram.org</code>. Returns the <strong>unmodified</strong> response from the Telegram API.<br>
</div>
<h2>3. Enhanced Tools & File Operations</h2>
<h3>3.1. Get File Info with Augmented Links</h3>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}/file/${botTokenPlaceholder}/getFile?file_id=${fileIdPlaceholder}</code><br>
<strong>Method:</strong> <code>GET</code><br>
<strong>Description:</strong> Makes a request to the local Telegram API, gets the standard response, and augments it with: <code>direct_download_link_by_path</code>, <code>direct_download_link_by_id</code>, <code>delete_link_by_path</code>, <code>delete_link_by_id</code>, <code>encrypted_download_link_by_path</code>, <code>encrypted_download_link_by_id</code>.<br>
<strong>Encrypted Link Expiry Management:</strong> Add <code>?link_expiry_hours=${linkExpiryPlaceholder}</code> to the URL (<code>${linkExpiryPlaceholder}</code> is hours, <code>-1</code> for indefinite). Default: ${DEFAULT_LINK_EXPIRY_HOURS}h.
</div>
<h3>3.2. Statistics</h3>
<div class="endpoint">
<strong>URL:</strong> <code>${baseUrl}/stats</code><br>
<strong>Method:</strong> <code>GET</code><br>
<strong>Query Parameters:</strong> <code>run_ttl_now=true</code> (optional) - trigger cleanup.<br>
<strong>Response:</strong> JSON with statistics.
</div>
<h3>3.3. Other File Operations (base path: <code>${baseUrl}/file</code>)</h3>
<table>
<thead><tr><th>Operation</th><th>URL</th><th>Method</th><th>Description</th></tr></thead>
<tbody>
<tr><td>List Bot Files</td><td><code>/list?token=${botTokenPlaceholder}</code></td><td>GET</td><td>JSON list of files and their sizes.</td></tr>
<tr><td>Download by ID</td><td><code>/${botTokenPlaceholder}/downloadFile?file_id=${fileIdPlaceholder}</code></td><td>GET</td><td>The file for download.</td></tr>
<tr><td>Download by Path</td><td><code>/${relativeFilePathWithTokenPlaceholder}</code></td><td>GET</td><td>The file for download.</td></tr>
<tr><td>Download via Encrypted Link</td><td><code>/downloadEncrypted?payload=${encryptedPayloadPlaceholder}</code></td><td>GET</td><td>File, if link is valid.</td></tr>
<tr><td>Delete by Path</td><td><code>/deleteFile?file=${relativeFilePathWithTokenPlaceholder}</code></td><td>GET</td><td>JSON with result.</td></tr>
<tr><td>Delete by ID</td><td><code>/${botTokenPlaceholder}/deleteById?file_id=${fileIdPlaceholder}</code></td><td>GET</td><td>JSON with result.</td></tr>
</tbody>
</table>
<p><strong>Gist Note:</strong> If configured, Space info is updated in <a href="https://gist.github.com/${GITHUB_USERNAME || "_"}/${ENV_GIST_ID}" target="_blank">your Gist</a> (file <code>hf_space_telegram_bot_api_proxy_info.json</code>).</p>
<p><strong>Link Encryption:</strong> ${encryptionKeyBuffer ? "Enabled" : "Disabled (LINK_ENCRYPTION_KEY not set)"}.</p>
</div>
<script>
function setLang(lang) {
document.querySelectorAll('.ru, .en').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.' + lang).forEach(el => el.classList.add('active'));
document.getElementById('lang-ru-btn').classList.toggle('active', lang === 'ru');
document.getElementById('lang-en-btn').classList.toggle('active', lang === 'en');
localStorage.setItem('preferredLang', lang);
}
const preferredLang = localStorage.getItem('preferredLang') || (navigator.language.startsWith('ru') ? 'ru' : 'en');
setLang(preferredLang);
</script>
</body>
</html>`;
res.send(htmlDoc);
});
app.listen(APP_PORT, () => {
console.log(`Main app (Proxy & Tools) listening on port ${APP_PORT}`);
console.log(`Telegram Bot API (internal) should be on port ${INTERNAL_TELEGRAM_API_PORT}`);
console.log(`Statistics available at /stats`);
console.log(`File operations available under /file/`);
console.log(`Telegram API (standard) proxied from ${PROXY_TELEGRAM_PATH_PREFIX}/`);
console.log(`Augmented file info via /file/:token/getFile`);
console.log(`Default link expiry: ${DEFAULT_LINK_EXPIRY_HOURS} hours (-1 for indefinite)`);
updateEnvGistInGithub().catch(console.error);
if (FILES_TTL_HOURS > 0) {
const ttlIntervalMs = FILES_TTL_HOURS * 60 * 60 * 1000;
console.log(`[TTL] Scheduling cleanup every ${FILES_TTL_HOURS} hours. First run in 5 minutes.`);
setTimeout(() => {
cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS).catch(console.error);
setInterval(() => cleanupOldFiles(TELEGRAM_DATA_DIR, FILES_TTL_HOURS).catch(console.error), ttlIntervalMs);
}, 5 * 60 * 1000);
}
});
function gracefulShutdown() { console.log('Received shutdown signal. Exiting...'); process.exit(0); }
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);