Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README-zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@

8.可插拔的存储后端:文件默认存储在 Telegram,也可通过一个环境变量切换到 Cloudflare R2 存储桶——切换后旧链接依然有效

9.批量上传,支持拖拽和粘贴上传、逐文件进度显示,以及 URL / Markdown / BBCode / HTML 四种格式一键复制;可选的 Referer 白名单防盗链
9.批量上传,支持拖拽和粘贴上传、图片原画质上传、逐文件进度显示,以及 URL / Markdown / BBCode / HTML 四种格式一键复制;可选的 Referer 白名单防盗链

10.部署自检:配置缺失时首页会直接指出缺哪个环境变量或绑定、去哪里补,而不是等到第一次上传才失败

Expand Down Expand Up @@ -211,6 +211,12 @@
curl -F "file=@/path/to/image.png" https://your.domain/upload
```

使用 Telegram 存储时,如需保留图片原画质,可增加 `imageUploadMode=document` 将图片作为文件发送。为了保证生成的链接仍能通过 Bot API 加载,原画质图片最大支持 20MB:

```bash
curl -F "file=@/path/to/image.png" -F "imageUploadMode=document" https://your.domain/upload
```

返回一个 JSON 数组,`src` 为文件的访问路径(开启短链接功能后,这里会直接返回短链接):

```json
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Bindings (`Settings` -> `Functions`):

8. Pluggable storage: files live on Telegram by default, or in a Cloudflare R2 bucket with one environment variable — old links keep working either way

9. Batch upload with drag & drop and paste support, per-file progress, and one-click copy as URL / Markdown / BBCode / HTML; optional anti-hotlinking via a referer allowlist
9. Batch upload with drag & drop and paste support, optional original-quality image uploads, per-file progress, and one-click copy as URL / Markdown / BBCode / HTML; optional anti-hotlinking via a referer allowlist

10. Deployment self-check: when configuration is incomplete the homepage says which environment variable or binding is missing and where to set it, instead of failing on the first upload

Expand Down Expand Up @@ -211,6 +211,12 @@ The upload endpoint is `POST /upload` using `multipart/form-data`, with the file
curl -F "file=@/path/to/image.png" https://your.domain/upload
```

To preserve an image's original quality with Telegram storage, send it as a document by adding `imageUploadMode=document`. Original-quality Telegram images are limited to 20MB so the resulting file can still be served through the Bot API:

```bash
curl -F "file=@/path/to/image.png" -F "imageUploadMode=document" https://your.domain/upload
```

The response is a JSON array where `src` is the file's access path (with short links enabled, this returns the short link directly):

```json
Expand Down
2 changes: 1 addition & 1 deletion functions/storage/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { r2Provider } from './r2.js';
// Storage provider contract:
// key - tag persisted in KV metadata for provenance
// validateConfig(env) - throws when required bindings/vars are missing
// upload(env, file, { fileName, fileExtension }) -> long file id (string)
// upload(env, file, { fileName, fileExtension, imageUploadMode }) -> long file id (string)
// fetchFile(env, request, url, fileId) -> Response with the file body
const PROVIDERS = {
[telegramProvider.key]: telegramProvider,
Expand Down
4 changes: 2 additions & 2 deletions functions/storage/telegram.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const telegramProvider = {
validateTelegramConfig(env);
},

async upload(env, file, { fileExtension }) {
const { endpoint, field } = getUploadTarget(file);
async upload(env, file, { fileExtension, imageUploadMode }) {
const { endpoint, field } = getUploadTarget(file, imageUploadMode);
const formData = createTelegramFormData(env.TG_Chat_ID, field, file);

const result = await sendToTelegram(formData, endpoint, env);
Expand Down
29 changes: 28 additions & 1 deletion functions/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { createDefaultMetadata, putMetadata } from "./utils/metadata.js";
import { allocateShortId, isShortUrlsEnabled, putShortLink } from "./utils/shortlink.js";
import { getUploadProvider } from "./storage/index.js";

const TELEGRAM_SERVABLE_FILE_LIMIT = 20 * 1024 * 1024;

export async function onRequestPost(context) {
const { request, env } = context;

Expand All @@ -28,10 +30,35 @@ export async function onRequestPost(context) {
throw new Error('No file uploaded');
}

const requestedImageUploadMode = formData.get('imageUploadMode');
if (
requestedImageUploadMode !== null
&& requestedImageUploadMode !== 'photo'
&& requestedImageUploadMode !== 'document'
) {
return jsonResponse({ error: 'Invalid image upload mode' }, { status: 400 });
}
const imageUploadMode = requestedImageUploadMode || 'photo';

if (
provider.key === 'telegram'
&& imageUploadMode === 'document'
&& uploadFile.type.startsWith('image/')
&& uploadFile.size > TELEGRAM_SERVABLE_FILE_LIMIT
) {
return jsonResponse({
error: 'Original-quality Telegram images must not exceed 20 MB',
}, { status: 413 });
}

const fileName = uploadFile.name;
const fileExtension = fileName.split('.').pop().toLowerCase();

const longId = await provider.upload(env, uploadFile, { fileName, fileExtension });
const longId = await provider.upload(env, uploadFile, {
fileName,
fileExtension,
imageUploadMode,
});
let shortId = null;

// 将文件信息保存到 KV 存储
Expand Down
5 changes: 4 additions & 1 deletion functions/utils/telegram.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ export function validateTelegramConfig(env) {
}
}

export function getUploadTarget(file) {
export function getUploadTarget(file, imageUploadMode = 'photo') {
if (file.type.startsWith('image/')) {
if (imageUploadMode === 'document') {
return { endpoint: 'sendDocument', field: 'document' };
}
return { endpoint: 'sendPhoto', field: 'photo' };
}

Expand Down
38 changes: 35 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@
.btn.small { padding: 7px 14px; font-size: 12px; gap: 6px; }
.btn .icon { width: 13px; height: 13px; }
#file-input { display: none; }
.quality-option {
display: flex; align-items: center; gap: 8px; width: 100%; max-width: 220px;
padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px;
background: rgba(255, 255, 255, .72); cursor: pointer;
}
.quality-option:hover { border-color: #dce3e9; background: #fff; }
.quality-option[hidden] { display: none; }
.quality-option input { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); flex: none; }
.quality-option span { min-width: 0; font-size: 11px; line-height: 1.45; color: var(--muted); }
.quality-option b { display: block; color: var(--ink); font-size: 12px; }

/* ---- right: results ---- */
.results { flex: 1; min-width: 0; display: flex; flex-direction: column; }
Expand Down Expand Up @@ -285,6 +295,10 @@ <h1 class="brand" id="site-name">Telegraph-Image</h1>
</div>
<p class="hint"><b>点击、拖拽或粘贴上传</b>支持批量上传<br>图片 / 视频 / 音频 / 文档</p>
<button class="btn" type="button" id="pick"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V4.5m0 0L7.25 9.25M12 4.5l4.75 4.75M4 16.5v2.25A1.75 1.75 0 0 0 5.75 20.5h12.5A1.75 1.75 0 0 0 20 18.75V16.5"/></svg><span>选择上传文件</span></button>
<label class="quality-option" id="quality-option">
<input type="checkbox" id="original-quality">
<span><b>保留图片原画质</b>作为文件上传,最大 20 MB</span>
</label>
</div>
</div>
<input id="file-input" type="file" multiple>
Expand Down Expand Up @@ -328,6 +342,7 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
var COPY_ICON = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11.5" height="11.5" rx="2.2"/><path d="M5.5 15.5H5a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 5 3.5h9A1.5 1.5 0 0 1 15.5 5v.5"/></svg>';

var MAX_CONCURRENT = 3;
var ORIGINAL_QUALITY_MAX_SIZE = 20 * 1024 * 1024;
var uploaded = []; // { url, fileName }
var activeUploads = 0;
var pending = [];
Expand All @@ -340,6 +355,8 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
var linkOutput = document.getElementById('link-output');
var fileCount = document.getElementById('file-count');
var toast = document.getElementById('toast');
var originalQuality = document.getElementById('original-quality');
var qualityOption = document.getElementById('quality-option');
var toastTimer = null;

// ---- wallpaper slideshow ----
Expand Down Expand Up @@ -387,6 +404,9 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
var adminLink = document.getElementById('admin-link');
if (adminLink) adminLink.remove();
}
if (config.setup && config.setup.storageProvider === 'r2') {
qualityOption.hidden = true;
}
renderSetupProblems(config.problems);
}).catch(function () { /* defaults already in markup */ });

Expand Down Expand Up @@ -417,6 +437,8 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
event.stopPropagation(); // the card itself already opens the picker
fileInput.click();
});
qualityOption.addEventListener('click', function (event) { event.stopPropagation(); });
qualityOption.addEventListener('keydown', function (event) { event.stopPropagation(); });
fileInput.addEventListener('change', function () {
enqueueFiles(fileInput.files);
fileInput.value = '';
Expand Down Expand Up @@ -456,8 +478,17 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
// ---- upload queue ----
function enqueueFiles(fileList) {
Array.prototype.forEach.call(fileList, function (file) {
var imageUploadMode = originalQuality.checked ? 'document' : 'photo';
var item = createQueueItem(file);
pending.push({ file: file, item: item });
if (
imageUploadMode === 'document'
&& file.type.indexOf('image/') === 0
&& file.size > ORIGINAL_QUALITY_MAX_SIZE
) {
failItem(item, file, '原画质模式最大支持 20 MB');
return;
}
pending.push({ file: file, item: item, imageUploadMode: imageUploadMode });
});
updateCount();
pumpQueue();
Expand All @@ -467,7 +498,7 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
while (activeUploads < MAX_CONCURRENT && pending.length) {
var next = pending.shift();
activeUploads++;
uploadFile(next.file, next.item);
uploadFile(next.file, next.item, next.imageUploadMode);
}
}

Expand Down Expand Up @@ -516,9 +547,10 @@ <h2><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
return { root: item, meta: meta, bar: progress.firstChild };
}

function uploadFile(file, item) {
function uploadFile(file, item, imageUploadMode) {
var formData = new FormData();
formData.append('file', file);
formData.append('imageUploadMode', imageUploadMode);

var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
Expand Down
78 changes: 77 additions & 1 deletion test/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ describe('upload function', function () {
restoreConsole();
});

async function createUploadRequest(file) {
async function createUploadRequest(file, imageUploadMode) {
const formData = new FormData();
formData.append('file', file);
if (imageUploadMode) formData.append('imageUploadMode', imageUploadMode);

return new Request('https://example.com/upload', {
method: 'POST',
Expand Down Expand Up @@ -108,6 +109,81 @@ describe('upload function', function () {
assert.strictEqual(fetchMock.calls.length, 2);
});

it('uploads images directly as documents when original quality is selected', async function () {
const { onRequestPost } = await import('../functions/upload.js');

fetchMock = installFetchMock(async (input, init) => {
assert.strictEqual(String(input), 'https://api.telegram.org/botbot-token/sendDocument');
assert.ok(init.body.get('document') instanceof File);
assert.strictEqual(init.body.get('photo'), null);
return Response.json({
ok: true,
result: { document: { file_id: 'original-id' } },
});
});

const request = await createUploadRequest(
new File(['image-bytes'], 'cat.png', { type: 'image/png' }),
'document',
);
const res = await onRequestPost(makeContext({
request,
env: {
disable_telemetry: 'true',
TG_Bot_Token: 'bot-token',
TG_Chat_ID: '-100123',
},
}));

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(JSON.parse(await res.text()), [{ src: '/file/original-id.png' }]);
assert.strictEqual(fetchMock.calls.length, 1);
});

it('rejects original-quality Telegram images larger than the 20 MB serving limit', async function () {
const { onRequestPost } = await import('../functions/upload.js');
const oversizedImage = new File(
[new Uint8Array(20 * 1024 * 1024 + 1)],
'large.png',
{ type: 'image/png' },
);
const request = await createUploadRequest(oversizedImage, 'document');

const res = await onRequestPost(makeContext({
request,
env: {
disable_telemetry: 'true',
TG_Bot_Token: 'bot-token',
TG_Chat_ID: '-100123',
},
}));

assert.strictEqual(res.status, 413);
assert.deepStrictEqual(JSON.parse(await res.text()), {
error: 'Original-quality Telegram images must not exceed 20 MB',
});
});

it('rejects an unsupported image upload mode', async function () {
const { onRequestPost } = await import('../functions/upload.js');
const request = await createUploadRequest(
new File(['image-bytes'], 'cat.png', { type: 'image/png' }),
'lossless-ish',
);

const res = await onRequestPost(makeContext({
request,
env: {
disable_telemetry: 'true',
TG_Bot_Token: 'bot-token',
TG_Chat_ID: '-100123',
},
}));

assert.strictEqual(res.status, 400);
assert.deepStrictEqual(JSON.parse(await res.text()), { error: 'Invalid image upload mode' });
});

it('uploads non-media files with sendDocument', async function () {
const { onRequestPost } = await import('../functions/upload.js');

Expand Down