介绍
基于 Cloudflare R2 储存的图床/视频床/文件床服务,使用大善人CF的免费套餐就可以了。图床默认开启压缩,可以储存更多的图片文件!初次使用找张卡填入即可。
免费套餐介绍
- 存储: 每月 10 GB
- A类操作: 每月 100 万次请求
- B类操作: 每月 1000 万次请求
- 出口(数据传输到互联网): 免费
功能特点
- 可选的访客验证功能
- 可选的图片压缩功能(默认开启)
- 可选的文件大小限制(默认 10MB)
- 支持查看本地历史记录
- 支持所有文件格式上传
- 支持多文件上传和粘贴上传
- 支持批量操作和显示上传时间
- Cloudflare Cache API 缓存支持
- 基于 Cloudflare R2 的文件存储
部署步骤
⚠️虽然项目的代码使用了Worker的缓存API,但还是建议配置好边缘 TTL 并开启访客验证,防止被刷导致扣费!
1. 创建 R2 存储桶
- 登录 Cloudflare Dashboard
- 进入
R2对象储存
→创建存储桶
- 设置存储桶名称和区域
- 保存存储桶的名称以便后续使用
2. 创建 D1 数据库
- 登录 Cloudflare Dashboard
- 进入
存储和数据库
→D1 SQL 数据库
- 点击
创建
创建数据库- 数据库名称可自定义,例如
images
- 建议选择数据库位置为
亚太地区
,可以获得更好的访问速度
- 数据库名称可自定义,例如
- 创建数据表:
- 点击数据库名称进入详情页
- 选择
控制台
标签 - 执行以下 SQL 语句:
CREATE TABLE media (
url TEXT PRIMARY KEY
);
3.创建 Worker
- 进入
Workers & Pages
- 点击
创建
- 选择
创建 Worker
- 为 Worker 设置一个名称
- 点击
部署
创建 Worker - 点击编辑代码
点击编辑代码粘贴下面内容进去后点击部署后返回Worker继续设置。
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
const domain = env.DOMAIN;
const DATABASE = env.DATABASE;
const USERNAME = env.USERNAME;
const PASSWORD = env.PASSWORD;
const adminPath = env.ADMIN_PATH;
const enableAuth = env.ENABLE_AUTH === 'true';
const R2_BUCKET = env.R2_BUCKET;
const maxSizeMB = env.MAX_SIZE_MB ? parseInt(env.MAX_SIZE_MB, 10) : 10;
const maxSize = maxSizeMB * 1024 * 1024;
switch (pathname) {
case '/':
return await handleRootRequest(request, USERNAME, PASSWORD, enableAuth);
case `/${adminPath}`:
return await handleAdminRequest(DATABASE, request, USERNAME, PASSWORD);
case '/upload':
return request.method === 'POST' ? await handleUploadRequest(request, DATABASE, enableAuth, USERNAME, PASSWORD, domain, R2_BUCKET, maxSize) : new Response('Method Not Allowed', { status: 405 });
case '/bing-images':
return handleBingImagesRequest();
case '/delete-images':
return await handleDeleteImagesRequest(request, DATABASE, USERNAME, PASSWORD, R2_BUCKET);
default:
return await handleImageRequest(request, DATABASE, R2_BUCKET);
}
}
};
function authenticate(request, USERNAME, PASSWORD) {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return false;
return isValidCredentials(authHeader, USERNAME, PASSWORD);
}
async function handleRootRequest(request, USERNAME, PASSWORD, enableAuth) {
const cache = caches.default;
const cacheKey = new Request(request.url);
if (enableAuth) {
if (!authenticate(request, USERNAME, PASSWORD)) {
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Admin"' } });
}
}
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const response = new Response(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="JSimages-基于CloudFlare的图床服务">
<meta name="keywords" content="JSimages,Workers图床, Pages图床,R2储存, Cloudflare, Workers, 图床">
<title>JSimages-基于CloudFlare的图床服务</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" />
<link href="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/font-awesome/5.15.4/css/all.min.css" type="text/css" rel="stylesheet" />
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
position: relative;
}
.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;
}
.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;
position: relative;
}
.uniform-height {
margin-top: 20px;
}
#viewCacheBtn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: rgba(0, 0, 0, 0.1);
cursor: pointer;
font-size: 24px;
transition: color 0.3s ease;
}
#viewCacheBtn:hover {
color: rgba(0, 0, 0, 0.4);
}
#compressionToggleBtn {
position: absolute;
top: 10px;
right: 50px;
background: none;
border: none;
color: rgba(0, 0, 0, 0.1);
cursor: pointer;
font-size: 24px;
transition: color 0.3s ease;
}
#compressionToggleBtn:hover {
color: rgba(0, 0, 0, 0.4);
}
#cacheContent {
margin-top: 20px;
max-height: 200px;
border-radius: 5px;
overflow-y: auto;
}
.cache-title {
text-align: left;
margin-bottom: 10px;
}
.cache-item {
display: block;
cursor: pointer;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
text-align: left;
padding: 10px;
}
.cache-item:hover {
background-color: #e9ecef;
}
.project-link {
font-size: 14px;
text-align: center;
margin-top: 5px;
margin-bottom: 0;
}
textarea.form-control {
max-height: 200px;
overflow-y: hidden;
resize: none;
}
</style>
</head>
<body>
<div class="background" id="background"></div>
<div class="card">
<div class="title">JSimages</div>
<button type="button" class="btn" id="viewCacheBtn" title="查看历史记录"><i class="fas fa-clock"></i></button>
<button type="button" class="btn" id="compressionToggleBtn"><i class="fas fa-compress"></i></button>
<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" multiple>
</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="cacheContent" style="display: none;"></div>
</form>
</div>
<p class="project-link">项目开源于 GitHub - <a href="https://host163.xyz/?golink=aHR0cHM6Ly9naXRodWIuY29tLzAtUlRUL0pTaW1hZ2Vz" target="_blank" rel="noopener noreferrer">0-RTT/JSimages</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://lf26-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 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 originalImageURLs = [];
let isCacheVisible = false;
let enableCompression = true;
initFileInput();
setBackgroundImages();
const tooltipText = enableCompression ? '关闭压缩' : '开启压缩';
$('#compressionToggleBtn').attr('title', tooltipText);
$('#compressionToggleBtn').on('click', function() {
enableCompression = !enableCompression;
const icon = $(this).find('i');
icon.toggleClass('fa-compress fa-expand');
const tooltipText = enableCompression ? '关闭压缩' : '开启压缩';
$(this).attr('title', tooltipText);
});
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 files = $('#fileInput')[0].files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileHash = await calculateFileHash(file);
const cachedData = getCachedData(fileHash);
if (cachedData) {
handleCachedFile(cachedData);
} else {
await uploadFile(file, fileHash);
}
}
}
function getCachedData(fileHash) {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
return cacheData.find(item => item.hash === fileHash);
}
function handleCachedFile(cachedData) {
if (!originalImageURLs.includes(cachedData.url)) {
originalImageURLs.push(cachedData.url);
updateFileLinkDisplay();
toastr.info('已从缓存中读取数据');
}
}
function updateFileLinkDisplay() {
$('#fileLink').val(originalImageURLs.join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
}
async function calculateFileHash(file) {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function isFileInCache(fileHash) {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
return cacheData.some(item => item.hash === fileHash);
}
async function uploadFile(file, fileHash) {
try {
toastr.info('上传中...', '', { timeOut: 0 });
const interfaceInfo = {
enableCompression: enableCompression
};
if (file.type.startsWith('image/') && file.type !== 'image/gif' && interfaceInfo.enableCompression) {
toastr.info('正在压缩...', '', { timeOut: 0 });
const compressedFile = await compressImage(file);
file = compressedFile;
}
const formData = new FormData($('#uploadForm')[0]);
formData.set('file', file, file.name);
const uploadResponse = await fetch('/upload', { method: 'POST', body: formData });
const responseData = await handleUploadResponse(uploadResponse);
if (responseData.error) {
toastr.error(responseData.error);
} else {
originalImageURLs.push(responseData.data);
$('#fileLink').val(originalImageURLs.join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
toastr.success('文件上传成功!');
saveToLocalCache(responseData.data, file.name, fileHash);
}
} catch (error) {
console.error('处理文件时出现错误:', error);
$('#fileLink').val('文件处理失败!');
toastr.error('文件处理失败!');
} finally {
toastr.clear();
}
}
async function handleUploadResponse(response) {
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
return { error: errorData.error };
}
}
$(document).on('paste', async 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();
const dataTransfer = new DataTransfer();
const existingFiles = $('#fileInput')[0].files;
for (let j = 0; j < existingFiles.length; j++) {
dataTransfer.items.add(existingFiles[j]);
}
dataTransfer.items.add(pasteFile);
$('#fileInput')[0].files = dataTransfer.files;
$('#fileInput').trigger('change');
break;
}
}
}
});
async function compressImage(file, quality = 0.75) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const targetWidth = image.width;
const targetHeight = image.height;
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' });
toastr.success('图片压缩成功!');
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 fileLinks = originalImageURLs.map(url => url.trim()).filter(url => url !== '');
if (fileLinks.length > 0) {
let formattedLinks = '';
switch ($(this).attr('id')) {
case 'urlBtn':
formattedLinks = fileLinks.join('\\n\\n');
break;
case 'bbcodeBtn':
formattedLinks = fileLinks.map(url => '[img]' + url + '[/img]').join('\\n\\n');
break;
case 'markdownBtn':
formattedLinks = fileLinks.map(url => '![image](' + url + ')').join('\\n\\n');
break;
default:
formattedLinks = fileLinks.join('\\n');
}
$('#fileLink').val(formattedLinks);
adjustTextareaHeight($('#fileLink')[0]);
copyToClipboardWithToastr(formattedLinks);
}
});
function handleFileClear(event) {
$('#fileLink').val('');
adjustTextareaHeight($('#fileLink')[0]);
hideButtonsAndTextarea();
originalImageURLs = [];
}
function adjustTextareaHeight(textarea) {
textarea.style.height = '1px';
textarea.style.height = (textarea.scrollHeight > 200 ? 200 : textarea.scrollHeight) + 'px';
if (textarea.scrollHeight > 200) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
}
function copyToClipboardWithToastr(text) {
const input = document.createElement('textarea');
input.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();
}
function saveToLocalCache(url, fileName, fileHash) {
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
cacheData.push({ url, fileName, hash: fileHash, timestamp });
localStorage.setItem('uploadCache', JSON.stringify(cacheData));
}
$('#viewCacheBtn').on('click', function() {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
const cacheContent = $('#cacheContent');
cacheContent.empty();
if (isCacheVisible) {
cacheContent.hide();
$('#fileLink').val('');
$('#fileLink').parent('.form-group').hide();
isCacheVisible = false;
} else {
if (cacheData.length > 0) {
cacheData.reverse();
cacheData.forEach((item) => {
const listItem = $('<div class="cache-item"></div>')
.text(item.timestamp + ' - ' + item.fileName)
.data('url', item.url);
cacheContent.append(listItem);
cacheContent.append('<br>');
});
cacheContent.show();
} else {
cacheContent.append('<div>还没有记录哦!</div>').show();
}
isCacheVisible = true;
}
});
$(document).on('click', '.cache-item', function() {
const url = $(this).data('url');
originalImageURLs = [];
$('#fileLink').val('');
originalImageURLs.push(url);
$('#fileLink').val(originalImageURLs.map(url => url.trim()).join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
});
});
</script>
</body>
</html>
`, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
await cache.put(cacheKey, response.clone());
return response;
}
async function handleAdminRequest(DATABASE, request, USERNAME, PASSWORD) {
if (!authenticate(request, 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;
}
async function generateAdminPage(DATABASE) {
const mediaData = await fetchMediaData(DATABASE);
const mediaHtml = mediaData.map(({ url }) => {
const fileExtension = url.split('.').pop().toLowerCase();
const timestamp = url.split('/').pop().split('.')[0];
const mediaType = fileExtension;
let displayUrl = url;
const supportedImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'];
const supportedVideoExtensions = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'];
const isSupported = [...supportedImageExtensions, ...supportedVideoExtensions].includes(fileExtension);
const backgroundStyle = isSupported ? '' : `style="font-size: 50px; display: flex; justify-content: center; align-items: center;"`;
const icon = isSupported ? '' : '📁';
return `
<div class="media-container" data-key="${url}" onclick="toggleImageSelection(this)" ${backgroundStyle}>
<div class="media-type">${mediaType}</div>
${supportedVideoExtensions.includes(fileExtension) ? `
<video class="gallery-video" preload="none" style="width: 100%; height: 100%; object-fit: contain;" controls>
<source data-src="${displayUrl}" type="video/${fileExtension}">
您的浏览器不支持视频标签。
</video>
` : `
${isSupported ? `<img class="gallery-image lazy" data-src="${displayUrl}" alt="Image">` : icon}
`}
<div class="upload-time">上传时间: ${new Date(parseInt(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;
flex-wrap: wrap;
}
.header-left {
flex: 1;
}
.header-right {
display: flex;
gap: 10px;
justify-content: flex-end;
flex: 1;
justify-content: flex-end;
flex-wrap: wrap;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.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;
}
.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;
}
.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: contain;
transition: opacity 0.3s;
opacity: 0;
}
.gallery-image.loaded {
opacity: 1;
}
.media-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, .copy-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, .copy-button:hover {
background-color: #ff1a1a;
}
.hidden {
display: none;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.dropdown-content button {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
background: none;
border: none;
width: 100%;
text-align: left;
}
.dropdown-content button:hover {
background-color: #f1f1f1;
}
.dropdown:hover .dropdown-content {
display: block;
}
@media (max-width: 768px) {
.header-left, .header-right {
flex: 1 1 100%;
justify-content: flex-start;
}
.header-right {
margin-top: 10px;
}
.gallery {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
<script>
let selectedCount = 0;
const selectedKeys = new Set();
let isAllSelected = false;
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 confirmation = confirm('你确定要删除选中的媒体文件吗?此操作无法撤回。');
if (!confirmation) 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('删除失败');
}
}
function copyFormattedLinks(format) {
const urls = Array.from(selectedKeys).map(url => url.trim()).filter(url => url !== '');
let formattedLinks = '';
switch (format) {
case 'url':
formattedLinks = urls.join('\\n\\n');
break;
case 'bbcode':
formattedLinks = urls.map(url => '[img]' + url + '[/img]').join('\\n\\n');
break;
case 'markdown':
formattedLinks = urls.map(url => '![image](' + url + ')').join('\\n\\n');
break;
}
navigator.clipboard.writeText(formattedLinks).then(() => {
alert('复制成功');
}).catch((err) => {
alert('复制失败');
});
}
function selectAllImages() {
const mediaContainers = document.querySelectorAll('.media-container');
if (isAllSelected) {
mediaContainers.forEach(container => {
container.classList.remove('selected');
const key = container.getAttribute('data-key');
selectedKeys.delete(key);
container.querySelector('.upload-time').style.display = 'none';
});
selectedCount = 0;
} else {
mediaContainers.forEach(container => {
if (!container.classList.contains('selected')) {
container.classList.add('selected');
const key = container.getAttribute('data-key');
selectedKeys.add(key);
selectedCount++;
container.querySelector('.upload-time').style.display = 'block';
}
});
}
isAllSelected = !isAllSelected;
updateDeleteButton();
}
document.addEventListener('DOMContentLoaded', () => {
const mediaContainers = document.querySelectorAll('.media-container[data-key]');
const options = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const mediaObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const container = entry.target;
const video = container.querySelector('video');
if (video) {
const source = video.querySelector('source');
video.src = source.getAttribute('data-src');
video.load();
} else {
const img = container.querySelector('img');
if (img && !img.src) {
img.src = img.getAttribute('data-src');
img.onload = () => img.classList.add('loaded');
}
}
observer.unobserve(container);
}
});
}, options);
mediaContainers.forEach(container => {
mediaObserver.observe(container);
});
});
</script>
</head>
<body>
<div class="header">
<div class="header-left">
<span>媒体文件 ${mediaData.length} 个</span>
<span>已选中: <span id="selected-count">0</span>个</span>
</div>
<div class="header-right hidden">
<div class="dropdown">
<button class="copy-button">复制</button>
<div class="dropdown-content">
<button onclick="copyFormattedLinks('url')">URL</button>
<button onclick="copyFormattedLinks('bbcode')">BBCode</button>
<button onclick="copyFormattedLinks('markdown')">Markdown</button>
</div>
</div>
<button id="select-all-button" class="delete-button" onclick="selectAllImages()">全选</button>
<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' } });
}
async function fetchMediaData(DATABASE) {
const result = await DATABASE.prepare('SELECT url FROM media').all();
const mediaData = result.results.map(row => {
const timestamp = parseInt(row.url.split('/').pop().split('.')[0]);
return { url: row.url, timestamp: timestamp };
});
mediaData.sort((a, b) => b.timestamp - a.timestamp);
return mediaData.map(({ url }) => ({ url }));
}
async function handleUploadRequest(request, DATABASE, enableAuth, USERNAME, PASSWORD, domain, R2_BUCKET, maxSize) {
try {
const formData = await request.formData();
const file = formData.get('file');
if (!file) throw new Error('缺少文件');
if (file.size > maxSize) {
return new Response(JSON.stringify({ error: `文件大小超过${maxSize / (1024 * 1024)}MB限制` }), { status: 413, headers: { 'Content-Type': 'application/json' } });
}
if (enableAuth && !authenticate(request, USERNAME, PASSWORD)) {
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Admin"' } });
}
const r2Key = `${Date.now()}`;
await R2_BUCKET.put(r2Key, file.stream(), {
httpMetadata: { contentType: file.type }
});
const fileExtension = file.name.split('.').pop();
const imageURL = `https://${domain}/${r2Key}.${fileExtension}`;
await DATABASE.prepare('INSERT INTO media (url) VALUES (?) ON CONFLICT(url) DO NOTHING').bind(imageURL).run();
return new Response(JSON.stringify({ data: imageURL }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.error('R2 上传错误:', error);
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
async function handleImageRequest(request, DATABASE, R2_BUCKET) {
const requestedUrl = request.url;
const cache = caches.default;
const cacheKey = new Request(requestedUrl);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) return cachedResponse;
const result = await DATABASE.prepare('SELECT url FROM media WHERE url = ?').bind(requestedUrl).first();
if (!result) {
const notFoundResponse = new Response('资源不存在', { status: 404 });
await cache.put(cacheKey, notFoundResponse.clone());
return notFoundResponse;
}
const urlParts = requestedUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const [r2Key, fileExtension] = fileName.split('.');
const object = await R2_BUCKET.get(r2Key);
if (!object) {
return new Response('获取文件内容失败', { status: 404 });
}
let contentType = 'text/plain';
if (fileExtension === 'jpg' || fileExtension === 'jpeg') contentType = 'image/jpeg';
if (fileExtension === 'png') contentType = 'image/png';
if (fileExtension === 'gif') contentType = 'image/gif';
if (fileExtension === 'webp') contentType = 'image/webp';
if (fileExtension === 'mp4') contentType = 'video/mp4';
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Disposition', 'inline');
const responseToCache = new Response(object.body, { status: 200, headers });
await cache.put(cacheKey, responseToCache.clone());
return responseToCache;
}
async function handleBingImagesRequest(request) {
const cache = caches.default;
const cacheKey = new Request('https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=5');
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) return cachedResponse;
const res = await fetch(cacheKey);
if (!res.ok) {
return new Response('请求 Bing API 失败', { status: res.status });
}
const bingData = await res.json();
const images = bingData.images.map(image => ({ url: `https://cn.bing.com${image.url}` }));
const returnData = { status: true, message: "操作成功", data: images };
const response = new Response(JSON.stringify(returnData), { status: 200, headers: { 'Content-Type': 'application/json' } });
await cache.put(cacheKey, response.clone());
return response;
}
async function handleDeleteImagesRequest(request, DATABASE, USERNAME, PASSWORD, R2_BUCKET) {
if (!authenticate(request, USERNAME, PASSWORD)) {
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Admin"' } });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
try {
const keysToDelete = await request.json();
if (!Array.isArray(keysToDelete) || keysToDelete.length === 0) {
return new Response(JSON.stringify({ message: '没有要删除的项' }), { status: 400 });
}
const placeholders = keysToDelete.map(() => '?').join(',');
const result = await DATABASE.prepare(`DELETE FROM media WHERE url IN (${placeholders})`).bind(...keysToDelete).run();
if (result.changes === 0) {
return new Response(JSON.stringify({ message: '未找到要删除的项' }), { status: 404 });
}
const cache = caches.default;
for (const url of keysToDelete) {
const cacheKey = new Request(url);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
await cache.delete(cacheKey);
}
const urlParts = url.split('/');
const fileName = urlParts[urlParts.length - 1];
const r2Key = fileName.split('.')[0];
await R2_BUCKET.delete(r2Key);
}
return new Response(JSON.stringify({ message: '删除成功' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: '删除失败', details: error.message }), { status: 500 });
}
}
或者进入GitHub复制代码: https://github.com/0-RTT/JSimages/blob/main/_worker.js
4.配置变量和机密
- 在 Worker 的
设置
→变量和机密
中 - 点击
添加
添加以下变量
变量名 | 说明 | 必填 | 示例 |
---|---|---|---|
DOMAIN | 自定义域名 | 是 | example.workers.dev |
USERNAME | 管理员用户名 | 是 | admin |
PASSWORD | 管理员密码 | 是 | password123 |
ADMIN_PATH | 管理后台路径 | 是 | admin |
ENABLE_AUTH | 访客验证 | 否 | true开启false 则关闭) |
MAX_SIZE_MB | 单文件最大支持大小 | 否 | 单位:MB,默认值为 10 |
- 点击
部署
5.绑定 D1 数据库和 R2 储存
- 在 Worker 设置页面找到
设置
→绑定
- 点击
添加
添加以下变量
- DATABASE
- R2_BUCKET
- 点击
部署
6. 绑定域名
- 在 Worker 的
设置
→域和路由
- 点击
添加
→自定义域
- 输入你在 Cloudflare 绑定的域名
- 点击
添加域
- 等待域名生效
7. 配置缓存
- 进入 Cloudflare Dashboard
- 进入
网站
→选择你的自定义域名
→缓存
→Cache Rules
→创建缓存规则
- 选择
缓存所有内容模板
- 设置
边缘 TTL
→忽略缓存控制标头,使用此 TTL
→30天
(根据需要设置) - 点击
部署
接下来快去打开域名愉快的使用吧。
作者源码地址:https://github.com/0-RTT/JSimages 感谢作者分享。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END
暂无评论内容