基于cloudflare worker的telegraph图床,支持图片管理和压缩!
项目地址:
介绍
基于Cloudflare Workers的Telegraph图床目前提供D1和KV两个版本,二者的主要区别在于存储位置。D1版本使用Cloudflare D1数据库进行存储,而KV版本则使用Cloudflare KV空间。
功能
- 支持上传大于5MB的图片。
- 在图床界面中可以直接粘贴上传。
- 选择图片后会自动上传,使用方便。
- 管理界面支持查看和播放MP4文件。
- 显示上传时间,并支持按上传时间排序。
- 支持修改后台路径为 /admin,可在代码的第二行进行调整。
- 图片管理功能可通过访问域名 /admin 实现,且图片支持懒加载。
- 仅允许代理自己上传的图片,无法访问通过其他TG图床上传的链接。
- 支持JPEG、JPG、PNG、GIF和MP4格式,GIF和MP4的大小需≤5MB。
- 支持URL、BBCode和Markdown格式,点击对应按钮可自动复制相应格式的链接。
- 选择图片后会自动压缩,以节省Cloudflare和Telegraph的存储空间,同时加快上传速度。
- 对于需要自定义用户界面的用户,您可以自行修改代码。在修改时希望您能保留项目的开源地址。
D1数据库限制
- 对于个人用户,500MB的免费存储空间足够用于储存图片链接使用。
类别 | 限制 |
数据库数量 | 50,000 (付费用户) beta / 10 (免费用户) |
最大数据库大小 | 2 GB (付费用户) beta / 500 MB (免费用户) |
每个帐户的最大存储空间 | 50 GB (付费用户) beta / 5 GB (免费用户) |
Time Travel 间隔时间 (时间点恢复) | 30 days (付费用户) / 7 days (免费用户) |
最大 Time Travel 还原操作数 | 每 10 分钟 10 次还原(每个数据库) |
每个工作线程调用的查询数(读取子请求限制) | 50 (Bundled) / 1000 (Unbound) |
每个表的最大列数 | 100 |
每个表的最大行数 | 无限制(不包括每个数据库的存储限制) |
最大字符串或 BLOB 表行大小 | 1,000,000 bytes (1 MB) |
最大 SQL 语句长度 | 100,000 bytes (100 KB) |
每个查询的最大绑定参数数 | 100 |
每个 SQL 函数的最大参数数 | 32 |
LIKE 或 GLOB 模式中的最大字符数(字节) | 50 bytes |
每个工作线程脚本的最大绑定数 | 约5,000 人 |
D1版本的后台管理页面加载快了不少。
数据库绑定变量
DATABASE
环境变量设置账号
USERNAME
环境变量设置密码
PASSWORD
数据库初始化命令:
shell
CREATE TABLE media ( key TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, url TEXT NOT NULL );
Shell
接下来是图片教程,照着弄就行:





















Worker.js
shell
import { handleRequest } from './function.js'; var src_default = { async fetch(request, env) { const { DATABASE } = env; // 处理请求 return handleRequest(request, DATABASE, env); } }; export { src_default as default };
Shell
Function.js
shell
const domain = 'example.com'; const adminPath = 'admin'; // 自定义管理路径 // 处理请求 export async function handleRequest(request, DATABASE, env) { const { pathname } = new URL(request.url); const USERNAME = env.USERNAME; const PASSWORD = env.PASSWORD; switch (pathname) { case '/': return handleRootRequest(); case `/${adminPath}`: return handleAdminRequest(DATABASE, request, USERNAME, PASSWORD); case '/upload': return request.method === 'POST' ? handleUploadRequest(request, DATABASE) : new Response('Method Not Allowed', { status: 405 }); case '/bing-images': return handleBingImagesRequest(); case '/delete-images': return handleDeleteImagesRequest(request, DATABASE); default: return handleImageRequest(pathname, DATABASE); } } // 处理根请求,返回首页 HTML function handleRootRequest() { return new Response(` <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="description" content="Telegraph图床-基于Workers的图床服务"> <meta name="keywords" content="Telegraph图床,Workers图床, Cloudflare, Workers,telegra.ph, 图床"> <title>Telegraph图床-基于Workers的图床服务</title> <link rel="icon" href="https://p1.meituan.net/csc/c195ee91001e783f39f41ffffbbcbd484286.ico" type="image/x-icon"> <link href="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/twitter-bootstrap/4.6.1/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/bootstrap-fileinput/5.2.7/css/fileinput.min.css" rel="stylesheet" /> <link href="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/toastr.js/2.1.4/toastr.min.css" rel="stylesheet" /> <style> body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; } .background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-size: cover; z-index: -1; transition: opacity 1s ease-in-out; opacity: 1; } .background.next { opacity: 0; } .card { background-color: rgba(255, 255, 255, 0.8); border: none; border-radius: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); padding: 20px; width: 90%; max-width: 400px; text-align: center; margin: 0 auto; } @media (max-width: 576px) { .card { margin: 20px; } } .uniform-height { margin-top: 20px; } </style> </head> <body> <div class="background" id="background"></div> <div class="card"> <div class="title">Telegraph图床</div> <div class="card-body"> <form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data"> <div class="file-input-container"> <input id="fileInput" name="file" type="file" class="form-control-file" data-browse-on-zone-click="true"> </div> <div class="form-group mb-3 uniform-height" style="display: none;"> <button type="button" class="btn btn-light mr-2" id="urlBtn">URL</button> <button type="button" class="btn btn-light mr-2" id="bbcodeBtn">BBCode</button> <button type="button" class="btn btn-light" id="markdownBtn">Markdown</button> </div> <div class="form-group mb-3 uniform-height" style="display: none;"> <textarea class="form-control" id="fileLink" readonly></textarea> </div> <div id="uploadingText" class="uniform-height" style="display: none; text-align: center;">文件上传中...</div> <div id="compressingText" class="uniform-height" style="display: none; text-align: center;">图片压缩中...</div> </form> </div> <p style="font-size: 14px; text-align: center;"> 项目开源于 GitHub - <a href="https://github.com/0-RTT/telegraph" target="_blank" rel="noopener noreferrer">0-RTT/telegraph</a> </p> <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/jquery/3.6.0/jquery.min.js" type="application/javascript"></script> <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/bootstrap-fileinput/5.2.7/js/fileinput.min.js" type="application/javascript"></script> <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/bootstrap-fileinput/5.2.7/js/locales/zh.min.js" type="application/javascript"></script> <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/toastr.js/2.1.4/toastr.min.js" type="application/javascript"></script> <script> async function fetchBingImages() { const response = await fetch('/bing-images'); const data = await response.json(); return data.data.map(image => image.url); } async function setBackgroundImages() { const images = await fetchBingImages(); const backgroundDiv = document.getElementById('background'); if (images.length > 0) { backgroundDiv.style.backgroundImage = 'url(' + images[0] + ')'; } let index = 0; let currentBackgroundDiv = backgroundDiv; setInterval(() => { const nextIndex = (index + 1) % images.length; const nextImage = new Image(); nextImage.src = images[nextIndex]; const nextBackgroundDiv = document.createElement('div'); nextBackgroundDiv.className = 'background next'; nextBackgroundDiv.style.backgroundImage = 'url(' + images[nextIndex] + ')'; document.body.appendChild(nextBackgroundDiv); nextBackgroundDiv.style.opacity = 0; setTimeout(() => { nextBackgroundDiv.style.opacity = 1; }, 50); setTimeout(() => { document.body.removeChild(currentBackgroundDiv); currentBackgroundDiv = nextBackgroundDiv; index = nextIndex; }, 1000); }, 5000); } $(document).ready(function() { let originalImageURL = ''; initFileInput(); setBackgroundImages(); function initFileInput() { $("#fileInput").fileinput({ theme: 'fa', language: 'zh', browseClass: "btn btn-primary", removeClass: "btn btn-danger", showUpload: false, showPreview: false, }).on('filebatchselected', handleFileSelection) .on('fileclear', handleFileClear); } async function handleFileSelection() { const file = $('#fileInput')[0].files[0]; if (file) { await uploadFile(file); } } async function uploadFile(file) { try { const interfaceInfo = { acceptTypes: 'image/gif,image/jpeg,image/jpg,image/png,video/mp4', gifAndVideoMaxSize: 5 * 1024 * 1024, otherMaxSize: 5 * 1024 * 1024, compressImage: true }; if (['image/gif', 'video/mp4'].includes(file.type)) { if (file.size > interfaceInfo.gifAndVideoMaxSize) { toastr.error('文件必须≤' + interfaceInfo.gifAndVideoMaxSize / (1024 * 1024) + 'MB'); return; } } else { if (interfaceInfo.compressImage === true) { const compressedFile = await compressImage(file); file = compressedFile; } else if (interfaceInfo.compressImage === false) { if (file.size > interfaceInfo.otherMaxSize) { toastr.error('文件必须≤' + interfaceInfo.otherMaxSize / (1024 * 1024) + 'MB'); return; } } } $('#uploadingText').show(); const formData = new FormData($('#uploadForm')[0]); formData.set('file', file, file.name); const uploadResponse = await fetch('/upload', { method: 'POST', body: formData }); originalImageURL = await handleUploadResponse(uploadResponse); $('#fileLink').val(originalImageURL); $('.form-group').show(); adjustTextareaHeight($('#fileLink')[0]); } catch (error) { console.error('上传文件时出现错误:', error); $('#fileLink').val('文件上传失败!'); } finally { $('#uploadingText').hide(); } } async function handleUploadResponse(response) { if (response.ok) { const result = await response.json(); return result.data; } else { return '文件上传失败!'; } } $(document).on('paste', function(event) { const clipboardData = event.originalEvent.clipboardData; if (clipboardData && clipboardData.items) { for (let i = 0; i < clipboardData.items.length; i++) { const item = clipboardData.items[i]; if (item.kind === 'file') { const pasteFile = item.getAsFile(); uploadFile(pasteFile); break; } } } }); async function compressImage(file, quality = 0.5, maxResolution = 20000000) { $('#compressingText').show(); return new Promise((resolve) => { const image = new Image(); image.onload = () => { const width = image.width; const height = image.height; const resolution = width * height; let scale = 1; if (resolution > maxResolution) { scale = Math.sqrt(maxResolution / resolution); } const targetWidth = Math.round(width * scale); const targetHeight = Math.round(height * scale); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = targetWidth; canvas.height = targetHeight; ctx.drawImage(image, 0, 0, targetWidth, targetHeight); canvas.toBlob((blob) => { const compressedFile = new File([blob], file.name, { type: 'image/jpeg' }); $('#compressingText').hide(); resolve(compressedFile); }, 'image/jpeg', quality); }; const reader = new FileReader(); reader.onload = (event) => { image.src = event.target.result; }; reader.readAsDataURL(file); }); } $('#urlBtn, #bbcodeBtn, #markdownBtn').on('click', function() { const fileLink = originalImageURL.trim(); if (fileLink !== '') { let formattedLink; switch ($(this).attr('id')) { case 'urlBtn': formattedLink = fileLink; break; case 'bbcodeBtn': formattedLink = '[img]' + fileLink + '[/img]'; break; case 'markdownBtn': formattedLink = ''; break; default: formattedLink = fileLink; } $('#fileLink').val(formattedLink); adjustTextareaHeight($('#fileLink')[0]); copyToClipboardWithToastr(formattedLink); } }); function handleFileClear(event) { $('#fileLink').val(''); adjustTextareaHeight($('#fileLink')[0]); hideButtonsAndTextarea(); } function adjustTextareaHeight(textarea) { textarea.style.height = '1px'; textarea.style.height = (textarea.scrollHeight) + 'px'; } function copyToClipboardWithToastr(text) { const input = document.createElement('input'); input.setAttribute('value', text); document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); toastr.success('已复制到剪贴板', '', { timeOut: 300 }); } function hideButtonsAndTextarea() { $('#urlBtn, #bbcodeBtn, #markdownBtn, #fileLink').parent('.form-group').hide(); } }); </script> </body> </html> `, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } }); } // 处理管理请求 async function handleAdminRequest(DATABASE, request, USERNAME, PASSWORD) { const authHeader = request.headers.get('Authorization'); if (!authHeader || !isValidCredentials(authHeader, USERNAME, PASSWORD)) { return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Admin"' } }); } return await generateAdminPage(DATABASE); } // 验证凭据 function isValidCredentials(authHeader, USERNAME, PASSWORD) { const base64Credentials = authHeader.split(' ')[1]; const credentials = atob(base64Credentials).split(':'); const username = credentials[0]; const password = credentials[1]; return username === USERNAME && password === PASSWORD; } // 生成管理页面的 HTML async function generateAdminPage(DATABASE) { const mediaData = await fetchMediaData(DATABASE); const mediaHtml = mediaData.map(({ key, url, timestamp }) => { const fileExtension = url.split('.').pop().toLowerCase(); if (fileExtension === 'mp4') { return ` <div class="media-container" data-key="${key}" onclick="toggleImageSelection(this)"> <div class="media-type">视频</div> <video class="gallery-video" style="width: 100%; height: 100%; object-fit: contain;" data-src="${url}" controls> <source src="${url}" type="video/mp4"> 您的浏览器不支持视频标签。 </video> <div class="upload-time">上传时间: ${new Date(timestamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</div> </div> `; } else { return ` <div class="image-container" data-key="${key}" onclick="toggleImageSelection(this)"> <img data-src="${url}" alt="Image" class="gallery-image lazy"> <div class="upload-time">上传时间: ${new Date(timestamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</div> </div> `; } }).join(''); const html = ` <!DOCTYPE html> <html> <head> <title>图库</title> <link rel="icon" href="https://p1.meituan.net/csc/c195ee91001e783f39f41ffffbbcbd484286.ico" type="image/x-icon"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; } .header { position: sticky; top: 0; background-color: #ffffff; z-index: 1000; display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 15px 20px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border-radius: 8px; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .image-container, .media-container { position: relative; overflow: hidden; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); aspect-ratio: 1 / 1; transition: transform 0.3s, box-shadow 0.3s; } .media-type { position: absolute; top: 10px; left: 10px; background-color: rgba(0, 0, 0, 0.7); color: white; padding: 5px; border-radius: 5px; font-size: 14px; z-index: 10; cursor: pointer; } .image-container .upload-time, .media-container .upload-time { position: absolute; bottom: 10px; left: 10px; background-color: rgba(255, 255, 255, 0.7); padding: 5px; border-radius: 5px; color: #000; font-size: 14px; z-index: 10; display: none; } .image-container:hover, .media-container:hover { transform: scale(1.05); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); } .gallery-image { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.3s; opacity: 0; } .gallery-image.loaded { opacity: 1; } .media-container.selected, .image-container.selected { border: 2px solid #007bff; background-color: rgba(0, 123, 255, 0.1); } .footer { margin-top: 20px; text-align: center; font-size: 18px; color: #555; } .delete-button { background-color: #ff4d4d; color: white; border: none; border-radius: 5px; padding: 10px 15px; cursor: pointer; transition: background-color 0.3s; width: auto; } .delete-button:hover { background-color: #ff1a1a; } .hidden { display: none; } @media (max-width: 600px) { .gallery { grid-template-columns: repeat(2, 1fr); } .header { flex-direction: column; align-items: flex-start; } .header-right { margin-top: 10px; } .footer { font-size: 16px; } .delete-button { width: 100%; margin-top: 10px; } } </style> <script> let selectedCount = 0; const selectedKeys = new Set(); function toggleImageSelection(container) { const key = container.getAttribute('data-key'); container.classList.toggle('selected'); const uploadTime = container.querySelector('.upload-time'); if (container.classList.contains('selected')) { selectedKeys.add(key); selectedCount++; uploadTime.style.display = 'block'; } else { selectedKeys.delete(key); selectedCount--; uploadTime.style.display = 'none'; } updateDeleteButton(); } function updateDeleteButton() { const deleteButton = document.getElementById('delete-button'); const countDisplay = document.getElementById('selected-count'); countDisplay.textContent = selectedCount; const headerRight = document.querySelector('.header-right'); if (selectedCount > 0) { headerRight.classList.remove('hidden'); } else { headerRight.classList.add('hidden'); } } async function deleteSelectedImages() { if (selectedKeys.size === 0) return; const response = await fetch('/delete-images', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Array.from(selectedKeys)) }); if (response.ok) { alert('选中的媒体已删除'); location.reload(); } else { alert('删除失败'); } } document.addEventListener('DOMContentLoaded', () => { const images = document.querySelectorAll('.gallery-image[data-src]'); const options = { root: null, rootMargin: '0px', threshold: 0.1 }; const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; img.onload = () => img.classList.add('loaded'); observer.unobserve(img); } }); }, options); images.forEach(image => { imageObserver.observe(image); }); }); </script> </head> <body> <div class="header"> <div class="header-left"> <span>当前共有 ${mediaData.length} 个媒体文件</span> </div> <div class="header-right hidden"> <span>选中数量: <span id="selected-count">0</span></span> <button id="delete-button" class="delete-button" onclick="deleteSelectedImages()">删除选中</button> </div> </div> <div class="gallery"> ${mediaHtml} </div> <div class="footer"> 到底啦 </div> </body> </html> `; return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } // 从 D1 数据库获取媒体数据 async function fetchMediaData(DATABASE) { const result = await DATABASE.prepare('SELECT * FROM media ORDER BY timestamp DESC').all(); return result.results.map(row => ({ key: row.key, timestamp: row.timestamp, url: row.url })); } // 处理文件上传请求 async function handleUploadRequest(request, DATABASE) { try { const formData = await request.formData(); const file = formData.get('file'); if (!file) throw new Error('缺少文件'); const response = await fetch('https://telegra.ph/upload', { method: 'POST', body: formData }); if (!response.ok) throw new Error('上传失败'); const responseData = await response.json(); const imageKey = responseData[0].src; const imageURL = `https://${domain}${imageKey}`; const timestamp = Date.now(); await DATABASE.prepare('INSERT INTO media (key, timestamp, url) VALUES (?, ?, ?)').bind(imageKey, timestamp, imageURL).run(); return new Response(JSON.stringify({ data: imageURL }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.error('内部服务器错误:', error); return new Response(JSON.stringify({ error: '内部服务器错误' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // 处理 Bing 图片请求 async function handleBingImagesRequest() { const res = await fetch(`https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=5`); const bing_data = await res.json(); const images = bing_data.images.map(image => ({ url: `https://cn.bing.com${image.url}` })); const return_data = { status: true, message: "操作成功", data: images }; return new Response(JSON.stringify(return_data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } // 处理图片请求 async function handleImageRequest(pathname, DATABASE) { const result = await DATABASE.prepare('SELECT url FROM media WHERE key = ?').bind(pathname).first(); if (result) { const url = new URL(result.url); url.hostname = 'telegra.ph'; return fetch(url); } return new Response(null, { status: 404 }); } // 处理删除请求 async function handleDeleteImagesRequest(request, DATABASE) { if (request.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } try { const keysToDelete = await request.json(); if (keysToDelete.length === 0) { return new Response(JSON.stringify({ message: '没有要删除的项' }), { status: 400 }); } const placeholders = keysToDelete.map(() => '?').join(','); await DATABASE.prepare(`DELETE FROM media WHERE key IN (${placeholders})`).bind(...keysToDelete).run(); return new Response(JSON.stringify({ message: '删除成功' }), { status: 200 }); } catch (error) { console.error('删除图片时出错:', error); return new Response(JSON.stringify({ error: '删除失败' }), { status: 500 }); } }
Shell
效果图

- 作者:团子
- 链接:https://so.ikun.su/article/Worker-D1
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章