Spaces:
Paused
Paused
| 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); |