├── static ├── css │ ├── md-editor.css │ ├── fonts │ │ ├── ch-icon.eot │ │ ├── ch-icon.ttf │ │ ├── ch-icon.woff │ │ └── ch-icon.woff2 │ └── code-editor.css ├── img │ ├── icon-192.png │ ├── icon-512.png │ ├── apple-icon-152.png │ ├── apple-icon-167.png │ ├── apple-icon-180.png │ └── favicon.svg ├── manifest.json ├── js │ ├── multi-editor.js │ └── code-editor.js └── templates │ ├── render.html │ ├── code-editor.html │ ├── multi-editor.html │ └── pwa-loader.html ├── .dockerignore ├── src ├── db │ ├── schema.ts │ ├── adapters │ │ ├── index.ts │ │ ├── sqlite.ts │ │ ├── postgres.ts │ │ └── registry.ts │ ├── db.ts │ ├── repositories │ │ ├── IMetadataRepository.ts │ │ └── metadataRepository.ts │ ├── helpers │ │ ├── retry.ts │ │ └── migrate.ts │ └── models │ │ └── metadata.ts ├── config │ ├── env.ts │ └── constants.ts ├── utils │ ├── response.ts │ ├── crypto.ts │ ├── common.ts │ ├── messages.ts │ ├── validator.ts │ ├── types.ts │ ├── cache.ts │ └── render.ts ├── routes │ ├── paste.routes.ts │ ├── index.ts │ ├── api.routes.ts │ ├── admin.routes.ts │ └── frontend.routes.ts ├── middlewares │ ├── logger.ts │ ├── error.ts │ ├── etag.ts │ └── auth.ts ├── server.ts └── controllers │ ├── user.controller.ts │ ├── admin.controller.ts │ └── paste.controller.ts ├── .gitignore ├── vercel.json ├── index.ts ├── api └── vercel.ts ├── docker-entrypoint.sh ├── drizzle.config.ts ├── deno.json ├── Dockerfile ├── .github └── workflows │ ├── sync.yml │ └── docker-image.yml ├── Docs ├── document.md ├── REST API.md └── self-host.md ├── docker-compose.yml ├── .env.template ├── README.md └── README_EN.md /static/css/md-editor.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .env 4 | .idea 5 | README.md 6 | README_EN.md 7 | -------------------------------------------------------------------------------- /static/img/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/img/icon-192.png -------------------------------------------------------------------------------- /static/img/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/img/icon-512.png -------------------------------------------------------------------------------- /static/css/fonts/ch-icon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/css/fonts/ch-icon.eot -------------------------------------------------------------------------------- /static/css/fonts/ch-icon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/css/fonts/ch-icon.ttf -------------------------------------------------------------------------------- /static/css/fonts/ch-icon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/css/fonts/ch-icon.woff -------------------------------------------------------------------------------- /static/img/apple-icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/img/apple-icon-152.png -------------------------------------------------------------------------------- /static/img/apple-icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/img/apple-icon-167.png -------------------------------------------------------------------------------- /static/img/apple-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/img/apple-icon-180.png -------------------------------------------------------------------------------- /static/css/fonts/ch-icon.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quick-Bin/qbin/HEAD/static/css/fonts/ch-icon.woff2 -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import * as MetadataSchema from "./models/metadata.ts"; 2 | 3 | export const schema = { 4 | ...MetadataSchema, 5 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | drizzle/ 3 | node_modules/ 4 | TEST/ 5 | .github/ 6 | .idea 7 | .env 8 | deno.lock 9 | *.db 10 | *.log 11 | pnpm-lock.yaml -------------------------------------------------------------------------------- /src/db/adapters/index.ts: -------------------------------------------------------------------------------- 1 | export { getDb, registerAdapter, closeAllDb } from "./registry.ts"; 2 | 3 | export type SupportedDialect = "postgres" | "sqlite"; 4 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/**/*.[tj]s": { 4 | "runtime": "vercel-deno@3.1.1" 5 | } 6 | }, 7 | "routes": [ 8 | { 9 | "src": "/(.*)", 10 | "dest": "/api/vercel.ts" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /src/config/env.ts: -------------------------------------------------------------------------------- 1 | import { loadSync } from "https://deno.land/std/dotenv/mod.ts"; 2 | const env = loadSync(); 3 | 4 | 5 | export function get_env(key: string, fallback?: string): string | undefined { 6 | return Deno.env.get(key) || env[key] || fallback; 7 | } 8 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "./src/server.ts"; 2 | import { get_env } from "./src/config/env.ts"; 3 | 4 | /** 5 | * 程序入口 6 | */ 7 | async function main() { 8 | const app = createServer(); 9 | const PORT = parseInt(get_env("PORT")) || 8000; 10 | console.log(`Server is running on port ${PORT}...`); 11 | await app.listen({ port: PORT }); 12 | } 13 | 14 | await main(); 15 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | // 创建响应处理器,自动设置状态码 2 | export function Response(ctx, status: number, message: string, data?: any) { 3 | ctx.response.status = status; 4 | ctx.response.body = { status, message, data }; 5 | } 6 | 7 | // 错误响应类 8 | export class PasteError extends Error { 9 | constructor(public status: number = 500, message: string) { 10 | super(message); 11 | this.name = "PasteError"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/db/db.ts: -------------------------------------------------------------------------------- 1 | export { createMetadataRepository } from "./repositories/metadataRepository.ts"; 2 | export type { IMetadataRepository } from "./repositories/IMetadataRepository.ts"; 3 | export { getDb } from "./adapters/index.ts"; 4 | 5 | 6 | import {getDb} from "./adapters/index.ts"; 7 | 8 | export async function initializeServices() { 9 | await Promise.all([ 10 | getDb(), 11 | ]); 12 | console.log("所有服务初始化完成"); 13 | } -------------------------------------------------------------------------------- /api/vercel.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env DENO_DIR=/tmp deno run --allow-net --allow-env --allow-read --unstable-kv --unstable-broadcast-channel 2 | 3 | import { createServer } from "../src/server.ts"; 4 | import { get_env } from "../src/config/env.ts"; 5 | 6 | const app = createServer(); 7 | const PORT = parseInt(get_env("PORT", "8000")); 8 | 9 | // Vercel expects a handler function 10 | export default async function handler(req: Request) { 11 | return await app.handle(req); 12 | } -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # 修正挂载目录权限 5 | mkdir -p /app/data 6 | chown -R deno:deno /app/data 7 | 8 | # 第一次启动时复制种子数据库 9 | if [ ! -f /app/data/qbin_local.db ]; then 10 | echo "数据库文件不存在,正在从初始化备份中复制..." 11 | cp /app/seed/qbin_local.db /app/data/qbin_local.db 12 | chown deno:deno /app/data/qbin_local.db 13 | echo "数据库初始化完成!" 14 | fi 15 | 16 | cd /app 17 | exec deno run -NER --allow-ffi --allow-sys --unstable-kv --unstable-broadcast-channel index.ts "$@" 18 | -------------------------------------------------------------------------------- /src/routes/paste.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import { 3 | getRaw, 4 | save, 5 | remove, 6 | queryRaw, 7 | } from "../controllers/paste.controller.ts"; 8 | 9 | const router = new Router(); 10 | 11 | router 12 | .get("/r/:key/:pwd?", getRaw) 13 | .head("/r/:key/:pwd?", queryRaw) 14 | .post("/save/:key?/:pwd?", save) 15 | .put("/save/:key?/:pwd?", save) 16 | .delete("/delete/:key/:pwd?", remove); 17 | 18 | export default router; -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import frontend from "./frontend.routes.ts"; 3 | import paste from "./paste.routes.ts"; 4 | import api from "./api.routes.ts"; 5 | import admin from "./admin.routes.ts"; 6 | 7 | const router = new Router(); 8 | 9 | router 10 | .use(frontend.routes(), frontend.allowedMethods()) 11 | .use(paste.routes(), paste.allowedMethods()) 12 | .use(api.routes(), api.allowedMethods()) 13 | .use(admin.routes(), admin.allowedMethods()) 14 | 15 | export default router; -------------------------------------------------------------------------------- /src/db/adapters/sqlite.ts: -------------------------------------------------------------------------------- 1 | import {drizzle} from "drizzle-orm/libsql"; 2 | import {createClient} from "@libsql/client/node"; 3 | import {get_env} from "../../config/env.ts"; 4 | import {registerAdapter} from "./registry.ts"; 5 | 6 | 7 | registerAdapter("sqlite", async () => { 8 | const raw = get_env("SQLITE_URL", "./data/qbin_local.db"); 9 | const url = /^(?:file:|https?:)/.test(raw) ? raw : `file:${raw}`; 10 | const authToken = get_env("SQLITE_AUTH_TOKEN"); 11 | const client = createClient({ url, authToken }); 12 | const db = drizzle(client); 13 | return { 14 | db, 15 | close: () => client.close?.(), 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | let client = Deno.env.get("DB_CLIENT") || "postgres"; // "postgres" | "sqlite" 4 | const dbCredentials = {} 5 | if(client === "postgres"){ 6 | client = 'postgresql'; 7 | dbCredentials["url"] = Deno.env.get("DATABASE_URL"); 8 | } else if(client === "sqlite"){ 9 | // client = 'turso'; 10 | dbCredentials["url"] = Deno.env.get("SQLITE_URL") || "file:data/qbin_local.db"; 11 | dbCredentials["authToken"] = Deno.env.get("SQLITE_AUTH_TOKEN"); 12 | } 13 | 14 | export default defineConfig({ 15 | dialect: client, 16 | schema: "./src/db/models/*.ts", 17 | out: "./drizzle", 18 | dbCredentials 19 | }); 20 | -------------------------------------------------------------------------------- /src/middlewares/logger.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "https://deno.land/x/oak/mod.ts"; 2 | 3 | 4 | export async function loggerMiddleware(ctx: Context, next: () => Promise,) { 5 | const requestId = crypto.randomUUID(); 6 | ctx.state.requestId = requestId; 7 | ctx.response.headers.set("X-Request-Id", requestId); 8 | 9 | const start = performance.now(); 10 | 11 | try { 12 | await next(); 13 | } finally { 14 | const cost = performance.now() - start; 15 | const status = ctx.response.status || 404; 16 | 17 | const logStr = 18 | `[${requestId}] ${ctx.request.method} ${ctx.request.url.pathname} ` + 19 | `=> ${status} ${cost.toFixed(2)}ms`; 20 | 21 | if (status >= 500) { 22 | console.error(logStr); 23 | } else if (status >= 400) { 24 | console.warn(logStr); 25 | } else { 26 | console.info(logStr); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/db/repositories/IMetadataRepository.ts: -------------------------------------------------------------------------------- 1 | import {KVMeta, Metadata} from "../../utils/types.ts"; 2 | 3 | export interface IMetadataRepository { 4 | create(data: Metadata): Promise; 5 | getByFkey(fkey: string): Promise; 6 | list(limit?: number, offset?: number): Promise; 7 | update(fkey: string, patch: Partial): Promise; 8 | delete(fkey: string): Promise; 9 | findByMime(mime: string): Promise; 10 | getActiveMetas(): Promise; 11 | /** 12 | * 获取指定邮箱的全部 fkey 13 | */ 14 | getByEmailAllFkeys(email: string): Promise; 15 | /** 16 | * 普通用户分页查询 17 | */ 18 | paginateByEmail( 19 | email: string, 20 | limit?: number, 21 | offset?: number, 22 | ): Promise<{ items: Omit[]; total: number }>; 23 | /** 24 | * 管理员分页查询 25 | */ 26 | listAlive( 27 | limit?: number, 28 | offset?: number, 29 | ): Promise<{ items: Omit[]; total: number }>; 30 | } -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "https://deno.land/x/oak/mod.ts"; 2 | import { AppState } from "./utils/types.ts"; 3 | import router from "./routes/index.ts"; 4 | import { errorMiddleware } from "./middlewares/error.ts"; 5 | import { authMiddleware } from "./middlewares/auth.ts"; 6 | import { etagMiddleware } from "./middlewares/etag.ts"; 7 | import { loggerMiddleware } from "./middlewares/logger.ts"; 8 | import {initializeServices} from "./db/db.ts"; 9 | 10 | /** 11 | * 创建并配置应用服务器 12 | * @returns 配置好的Application实例 13 | */ 14 | export function createServer(): Application { 15 | initializeServices().catch(console.error); // 预热数据库连接 16 | 17 | const app = new Application({ 18 | state: { 19 | session: new Map() 20 | }, 21 | }); 22 | 23 | app.use(errorMiddleware); 24 | app.use(etagMiddleware); 25 | app.use(authMiddleware); 26 | app.use(loggerMiddleware); 27 | app.use(router.routes()); 28 | app.use(router.allowedMethods()); 29 | 30 | return app; 31 | } -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { create, verify, getNumericDate, decode } from "https://deno.land/x/djwt/mod.ts"; 2 | import { jwtSecret } from "../config/constants.ts"; 3 | 4 | 5 | /** 6 | * Web Crypto API 导入 Key 7 | */ 8 | export const JWT_KEY = await crypto.subtle.importKey( 9 | "raw", 10 | new TextEncoder().encode(jwtSecret), 11 | { name: "HMAC", hash: "SHA-256" }, // "SHA-512" 12 | true, 13 | ["sign", "verify"], 14 | ); 15 | 16 | /** 17 | * 生成 JWT,并设置过期时间 18 | */ 19 | export async function generateJwtToken(payload: Record, exp): Promise { 20 | const header = { alg: "HS256", typ: "JWT" }; 21 | const claims = { 22 | ...payload, 23 | exp: getNumericDate(exp), 24 | }; 25 | return await create(header, claims, JWT_KEY); 26 | } 27 | 28 | /** 29 | * 验证 JWT,如果成功返回其 payload,失败则抛出异常。 30 | */ 31 | export async function verifyJwtToken(jwt: string) { 32 | return await verify(jwt, JWT_KEY, { alg: "HS256" }); 33 | } 34 | 35 | export function decodeJwtToken(token: string) { 36 | // decode 只解析 token 而不验证签名或过期时间 37 | const [header, payload] = decode(token); 38 | return { header, payload }; 39 | } -------------------------------------------------------------------------------- /src/db/adapters/postgres.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres"; 2 | import pg from "pg"; 3 | import { schema } from "../schema.ts"; 4 | import { get_env } from "../../config/env.ts"; 5 | import { registerAdapter } from "./registry.ts"; 6 | 7 | const { Pool } = pg; 8 | 9 | function newPool() { 10 | const CA_CERT_DATA = get_env("CA_CERT_DATA") ?? ""; 11 | const poolConfig = { 12 | connectionString: get_env("DATABASE_URL") ?? "", 13 | idleTimeoutMillis: Number(get_env("PG_IDLE_TIMEOUT") ?? 30_000), 14 | connectionTimeoutMillis: Number(get_env("PG_CONN_TIMEOUT") ?? 5_000), 15 | }; 16 | if (CA_CERT_DATA) { 17 | poolConfig.ssl = { 18 | rejectUnauthorized: true, 19 | ca: CA_CERT_DATA, 20 | }; 21 | } 22 | return new Pool(poolConfig); 23 | } 24 | 25 | registerAdapter("postgres", async () => { 26 | let pool = newPool(); 27 | 28 | pool.on("error", async (err) => { 29 | console.error("[postgres] pool error – reconnecting", err); 30 | try { await pool.end(); } catch (_) {} 31 | pool = newPool(); 32 | }); 33 | 34 | const db = drizzle(pool, { schema }); 35 | return { db, close: () => pool.end() }; 36 | }); -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run -NER --allow-ffi --allow-sys --unstable-kv --unstable-broadcast-channel index.ts", 4 | "dev": "deno run -NER --allow-ffi --allow-sys --unstable-kv --unstable-broadcast-channel --watch --watch-exclude='*.ts' index.ts", 5 | "compile": "deno compile --target x86_64-pc-windows-msvc -NER --allow-ffi --unstable-kv --unstable-broadcast-channel --no-check --output ./release/QBin.exe --icon static\\img\\apple-icon-152.png index.ts", 6 | "clean": "rm -rf ./release", 7 | "drizzle-kit": "deno run -A --env-file npm:drizzle-kit", 8 | "db:generate": "deno run -A --env-file npm:drizzle-kit generate", 9 | "db:migrate": "deno run -A --env-file npm:drizzle-kit migrate", 10 | "db:push": "deno run -A --env-file npm:drizzle-kit push" 11 | }, 12 | "imports": { 13 | "@libsql/client": "npm:@libsql/client@^0.15.4", 14 | "@types/pg": "npm:@types/pg@^8.11.13", 15 | "drizzle-kit": "npm:drizzle-kit@^0.30.6", 16 | "drizzle-orm": "npm:drizzle-orm@^0.41.0", 17 | "postgres": "npm:postgres@^3.4.5", 18 | "pg": "npm:pg@^8.14.1" 19 | }, 20 | "nodeModulesDir": "auto", 21 | "fmt": { 22 | "lineWidth": 100, 23 | "proseWrap": "preserve" 24 | }, 25 | "unstable": ["fmt-component"] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | // 获取当前时间 2 | export const getTimestamp = () => Math.floor(Date.now() / 1000); 3 | 4 | export function cyrb53(buffer: ArrayBuffer, seed = 0): number { 5 | let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; 6 | const bytes = new Uint8Array(buffer); 7 | for(let i = 0; i < bytes.length; i++) { 8 | h1 = Math.imul(h1 ^ bytes[i], 0x85ebca77); 9 | h2 = Math.imul(h2 ^ bytes[i], 0xc2b2ae3d); 10 | } 11 | h1 ^= Math.imul(h1 ^ (h2 >>> 15), 0x735a2d97); 12 | h2 ^= Math.imul(h2 ^ (h1 >>> 15), 0xcaf649a9); 13 | h1 ^= h2 >>> 16; h2 ^= h1 >>> 16; 14 | return 2097152 * (h2 >>> 0) + (h1 >>> 11); 15 | } 16 | 17 | export function cyrb53_str(str: string, seed = 8125): number { 18 | let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; 19 | for(let i = 0, ch: number; i < str.length; i++) { 20 | ch = str.charCodeAt(i); 21 | h1 = Math.imul(h1 ^ ch, 0x85ebca77); 22 | h2 = Math.imul(h2 ^ ch, 0xc2b2ae3d); 23 | } 24 | h1 ^= Math.imul(h1 ^ (h2 >>> 15), 0x735a2d97); 25 | h2 ^= Math.imul(h2 ^ (h1 >>> 15), 0xcaf649a9); 26 | h1 ^= h2 >>> 16; h2 ^= h1 >>> 16; 27 | return 2097152 * (h2 >>> 0) + (h1 >>> 11); 28 | } 29 | 30 | export function generateKey(): string { 31 | return `${crypto.randomUUID().split("-").pop()}${Date.now()}`.toLowerCase(); 32 | } 33 | -------------------------------------------------------------------------------- /src/routes/api.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import { handleAdminLogin, handleLogin, handleOAuthCallback } from "../middlewares/auth.ts"; 3 | import {getStorage, getToken} from "../controllers/user.controller.ts"; 4 | import { Response } from "../utils/response.ts"; 5 | import { ResponseMessages } from "../utils/messages.ts"; 6 | 7 | const router = new Router(); 8 | 9 | router 10 | .post("/api/login/admin", handleAdminLogin) 11 | .get("/api/login/:provider", handleLogin) 12 | .get("/api/login/oauth2/callback/:provider", handleOAuthCallback) 13 | .post("/api/user/logout", async (ctx) => { 14 | await ctx.cookies.delete("token", { path: "/", httpOnly: true, sameSite: "strict" }); 15 | return new Response(ctx, 200, ResponseMessages.LOGGED_OUT); 16 | }) 17 | .get("/api/user/info", async (ctx) => { 18 | const data = await ctx.state.session?.get("user"); 19 | return new Response(ctx, 200, ResponseMessages.SUCCESS, data); 20 | }) 21 | .post("/api/user/token", getToken) 22 | .get("/api/user/storage", getStorage) 23 | .get("/api/health", (ctx) => ctx.response.body = "healthy"); 24 | // .post("/api/user/shares", async (ctx) => { 25 | // return new Response(ctx, 200, ResponseMessages.SUCCESS, [{ }]); 26 | // }) 27 | 28 | export default router; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ──────── build stage ───────────────────── 2 | FROM denoland/deno:2.3.3 AS build 3 | 4 | ARG DB_CLIENT=sqlite 5 | ENV DB_CLIENT=${DB_CLIENT} 6 | ENV SQLITE_URL="file:/app/data/qbin_local.db" 7 | 8 | WORKDIR /app 9 | COPY . . 10 | 11 | RUN mkdir -p node_modules/.deno && \ 12 | chown -R deno:deno /app 13 | 14 | # 预先缓存依赖 15 | RUN deno cache index.ts 16 | 17 | # 执行sqlite数据库初始化任务 18 | RUN mkdir -p data/ && \ 19 | chown -R deno:deno data/ && \ 20 | sed -i -e 's/"deno"/"no-deno"/' node_modules/@libsql/client/package.json && \ 21 | deno task db:generate && \ 22 | deno task db:migrate && \ 23 | deno task db:push && \ 24 | sed -i -e 's/"no-deno"/"deno"/' node_modules/@libsql/client/package.json 25 | 26 | # ──────── runtime stage ─────────────────── 27 | FROM denoland/deno:2.3.1 28 | 29 | # 安装 curl 30 | RUN apt-get update && apt-get install -y curl && \ 31 | apt-get clean && \ 32 | rm -rf /var/lib/apt/lists/* 33 | 34 | ENV DB_CLIENT=sqlite 35 | ENV SQLITE_URL="file:/app/data/qbin_local.db" 36 | 37 | # 把种子文件存到 /app/seed 38 | COPY --from=build /app/data/qbin_local.db /app/seed/qbin_local.db 39 | COPY --from=build /app /app 40 | 41 | # 入口脚本 42 | COPY docker-entrypoint.sh /usr/local/bin/ 43 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 44 | 45 | ENTRYPOINT ["docker-entrypoint.sh"] 46 | -------------------------------------------------------------------------------- /src/db/helpers/retry.ts: -------------------------------------------------------------------------------- 1 | import {get_env} from "../../config/env.ts"; 2 | 3 | /** 4 | * 数据库调用重试包装 5 | * – 指数退避:baseDelay * 2^(n‑1)(附带 0‑50ms 随机抖动) 6 | * – 默认最多重试 3 次,可用环境变量 DB_MAX_RETRY / DB_BASE_DELAY 覆盖 7 | * – 仅对“可恢复/瞬时”错误重试 8 | */ 9 | export async function withRetry( 10 | fn: () => Promise, 11 | retry = Number(get_env("DB_MAX_RETRY") ?? 3), 12 | baseDelay = Number(get_env("DB_BASE_DELAY") ?? 100), 13 | ): Promise { 14 | let attempt = 0; 15 | while (true) { 16 | try { 17 | return await fn(); 18 | } catch (err) { 19 | attempt++; 20 | if (attempt > retry || !isTransient(err)) throw err; 21 | const delay = baseDelay * 2 ** (attempt - 1) + Math.random() * 50; 22 | await new Promise((r) => setTimeout(r, delay)); 23 | } 24 | } 25 | } 26 | 27 | function isTransient(e: any): boolean { 28 | if (e?.code && typeof e.code === "string") { 29 | return [ 30 | "ECONNRESET", 31 | "ECONNREFUSED", 32 | "EPIPE", 33 | "ETIMEDOUT", 34 | "PROTOCOL_CONNECTION_LOST", 35 | "57P01", // admin_shutdown 36 | "57P02", // crash_shutdown 37 | "57P03", // cannot_connect_now 38 | "53300", // too_many_connections 39 | ].includes(e.code); 40 | } 41 | const msg = String(e?.message ?? ""); 42 | return /(timeout|terminat|reset|refused|too many)/i.test(msg); 43 | } -------------------------------------------------------------------------------- /src/db/adapters/registry.ts: -------------------------------------------------------------------------------- 1 | import { get_env } from "../../config/env.ts"; 2 | import {initPostgresSchema} from "../models/metadata.ts"; 3 | 4 | export type DrizzleDB = any; // drizzle 数据库实例 5 | 6 | type DbInstance = { db: DrizzleDB; close?: () => Promise | void }; 7 | type FactoryFn = () => Promise; 8 | 9 | const factories = new Map(); 10 | const instances = new Map(); 11 | 12 | export function registerAdapter(name: string, factory: FactoryFn) { 13 | factories.set(name.toLowerCase(), factory); 14 | } 15 | 16 | /** 内部解析方言优先级:实参 > 环境变量 > postgres */ 17 | function resolveDialect(d?: string) { 18 | return (d ?? get_env("DB_CLIENT", "postgres")).toLowerCase(); 19 | } 20 | 21 | /** 统一的单例获取入口 */ 22 | export async function getDb(dialect?: string) { 23 | const d = resolveDialect(dialect); 24 | if (!instances.has(d)) { 25 | await import(`./${d}.ts`); // 动态加载数据库 26 | const ctor = factories.get(d); 27 | if (!ctor) throw new Error(`Unsupported DB_CLIENT "${d}"`); 28 | instances.set(d, await ctor()); 29 | if(d === "postgres"){ 30 | await initPostgresSchema(await ctor()) 31 | } 32 | } 33 | const db = instances.get(d)!.db; 34 | return db; 35 | } 36 | 37 | export async function closeAllDb() { 38 | await Promise.all([...instances.values()].map((i) => i.close?.())); 39 | } -------------------------------------------------------------------------------- /src/utils/messages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Standardized response messages for the application 3 | * Provides consistent, clear, and helpful messages for users 4 | */ 5 | export const ResponseMessages = { 6 | // Success messages 7 | SUCCESS: "操作成功完成", 8 | LOGGED_OUT: "成功退出登录", 9 | 10 | // General errors 11 | SERVER_ERROR: "服务器处理请求出错,请稍后重试", 12 | UNAUTHORIZED: "未登录,请先登录后再操作", 13 | FORBIDDEN: "当前账号没有执行此操作的权限", 14 | 15 | // Content related 16 | CONTENT_TOO_LARGE: "内容超出允许大小限制", 17 | INVALID_CONTENT_TYPE: "不支持此内容格式", 18 | CONTENT_NOT_FOUND: "内容不存在或已过期", 19 | 20 | // Path and key related 21 | PATH_EMPTY: "访问路径不能为空", 22 | PATH_UNAVAILABLE: "该访问路径格式无效或不可用", 23 | PATH_RESERVED: "此路径已被系统保留,无法使用", 24 | KEY_EXISTS: "此路径已被使用,请更换", 25 | 26 | // Authentication related 27 | PASSWORD_INCORRECT: "访问密码错误,请检查后重试", 28 | LOGIN_REQUIRED: "请先登录再操作", 29 | ADMIN_REQUIRED: "需要管理员权限", 30 | PERMISSION_DENIED: "当前账号无权修改或删除此内容", 31 | 32 | // Security related 33 | REFERER_INVALID: "检测到可疑来源,操作被拒绝", 34 | REFERER_DISALLOWED: "不允许从此页面发起请求", 35 | TOKEN_INVALID: "登录已过期,请重新登录", 36 | 37 | // Demo limitations 38 | DEMO_RESTRICTED: "演示环境不支持此操作", 39 | 40 | // Pagination related 41 | INVALID_PAGE: "页码无效", 42 | INVALID_PAGE_SIZE: "每页数量参数无效", 43 | 44 | // Database related 45 | DB_UPDATE_FAILED: "数据更新失败,请重试", 46 | SYNC_COMPLETED: "数据同步完成", 47 | }; -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "QBin", 3 | "short_name": "QBin", 4 | "description": "轻量的 Cloud Note & PasteBin 替代方案,一键保存文本、代码、图片、视频等任意内容,分享更便捷!", 5 | "display": "standalone", 6 | "start_url": "/?source=pwa", 7 | "background_color": "#ffffff", 8 | "theme_color": "#4a6cf7", 9 | "categories": ["productivity", "utilities"], 10 | "lang": "zh", 11 | "prefer_related_applications": false, 12 | "icons": [ 13 | { 14 | "src": "/static/img/apple-icon-180.png", 15 | "sizes": "180x180", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "/static/img/icon-192.png", 20 | "sizes": "192x192", 21 | "type": "image/png", 22 | "purpose": "any maskable" 23 | }, 24 | { 25 | "src": "/static/img/icon-512.png", 26 | "sizes": "512x512", 27 | "type": "image/png", 28 | "purpose": "any maskable" 29 | } 30 | ], 31 | "shortcuts": [ 32 | { 33 | "name": "通用编辑器", 34 | "short_name": "通用编辑器", 35 | "url": "/e" 36 | }, 37 | { 38 | "name": "代码编辑器", 39 | "short_name": "代码编辑器", 40 | "url": "/c" 41 | }, 42 | { 43 | "name": "文档编辑器", 44 | "short_name": "文档编辑器", 45 | "url": "/m" 46 | }, 47 | { 48 | "name": "存储管理", 49 | "short_name": "存储管理", 50 | "url": "/home#storage" 51 | } 52 | ], 53 | "share_target": { 54 | "action": "/save", 55 | "method": "POST", 56 | "enctype": "text/plain", 57 | "params": { 58 | "title": "保存内容" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Context, 3 | isHttpError, 4 | Status, 5 | } from "https://deno.land/x/oak/mod.ts"; 6 | import {HEADERS, QBIN_ENV} from "../config/constants.ts"; 7 | import { PasteError } from "../utils/response.ts"; 8 | 9 | 10 | export async function errorMiddleware( 11 | ctx: Context, 12 | next: () => Promise, 13 | ) { 14 | try { 15 | await next(); 16 | } catch (err) { 17 | if(err.message || err.stack){ 18 | let status: Status; 19 | if (err instanceof PasteError) { 20 | status = err.status as Status; 21 | } else if (isHttpError(err)) { 22 | status = err.status; 23 | } else { 24 | status = Status.InternalServerError; 25 | } 26 | const isClientErr = status >= 400 && status < 500; 27 | 28 | const logMsg = `${ctx.request.method} ${ctx.request.url} -> ${status}`; 29 | if (isClientErr) { 30 | console.warn(logMsg, "-", err.message); 31 | } else { 32 | console.error(logMsg, "\n", err.stack || err); 33 | } 34 | 35 | ctx.response.status = status; 36 | ctx.response.headers.set( 37 | "Content-Type", 38 | HEADERS.JSON["Content-Type"], 39 | ); 40 | 41 | ctx.response.body = { 42 | code: status, 43 | message: isClientErr 44 | ? err.message 45 | : QBIN_ENV === "dev" 46 | ? err.message 47 | : "内部服务器错误", 48 | ...(QBIN_ENV === "dev" && !isClientErr ? { stack: err.stack } : {}), 49 | }; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/db/helpers/migrate.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'drizzle-orm'; 2 | import {initPostgresSchema} from "../models/metadata.ts"; 3 | 4 | 5 | // 旧版本数据表迁移 v2->v3 6 | export async function migrateToV3( db, dialect) { 7 | if (dialect === 'postgres') { 8 | await initPostgresSchema(db); 9 | 10 | const { rows } = await db.db.execute(sql` 11 | SELECT EXISTS ( 12 | SELECT FROM information_schema.tables 13 | WHERE table_name = 'qbindbv2' 14 | ) AS "exists"; 15 | `); 16 | if (!rows[0].exists) return; 17 | 18 | return await db.db.execute(sql` 19 | INSERT INTO qbindbv3 (fkey,title,time,expire,ip,content,mime,len,pwd,email,uname,hash) 20 | SELECT fkey,NULL as title, time,expire,ip,content,mime,len,pwd,email,uname,hash 21 | FROM qbindbv2 22 | ON CONFLICT (fkey) DO NOTHING; 23 | `); 24 | } 25 | } 26 | 27 | // 旧版本数据表迁移 v1->v2 28 | export async function migrateToV2( db, dialect) { 29 | if (dialect === 'postgres') { 30 | await initPostgresSchema(db); 31 | 32 | const { rows } = await db.db.execute(sql` 33 | SELECT EXISTS ( 34 | SELECT FROM information_schema.tables 35 | WHERE table_name = 'qbindb' 36 | ) AS "exists"; 37 | `); 38 | if (!rows[0].exists) return; 39 | 40 | return await db.db.execute(sql` 41 | INSERT INTO qbindbv2 (fkey,time,expire,ip,content,mime,len,pwd,email,uname,hash) 42 | SELECT fkey,time,expire,ip,content,type AS mime,len,pwd,email,uname,hash 43 | FROM qbindb 44 | ON CONFLICT (fkey) DO NOTHING; 45 | `); 46 | } 47 | } -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: Upstream Sync 2 | 3 | permissions: 4 | contents: write 5 | issues: write 6 | actions: write 7 | 8 | on: 9 | schedule: 10 | - cron: '0 */6 * * *' # every 6 hours 11 | workflow_dispatch: 12 | 13 | jobs: 14 | sync_latest_from_upstream: 15 | name: Sync latest commits from upstream repo 16 | runs-on: ubuntu-latest 17 | if: ${{ github.event.repository.fork }} 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Clean issue notice 23 | uses: actions-cool/issues-helper@v3 24 | with: 25 | actions: 'close-issues' 26 | labels: '🚨 Sync Fail' 27 | 28 | - name: Sync upstream changes 29 | id: sync 30 | uses: aormsby/Fork-Sync-With-Upstream-action@v3.4 31 | with: 32 | upstream_sync_repo: Quick-Bin/Qbin 33 | upstream_sync_branch: main 34 | target_sync_branch: main 35 | target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set 36 | test_mode: false 37 | 38 | - name: Sync check 39 | if: failure() 40 | uses: actions-cool/issues-helper@v3 41 | with: 42 | actions: 'create-issue' 43 | title: '🚨 同步失败 | Sync Fail' 44 | labels: '🚨 Sync Fail' 45 | body: | 46 | 由于上游仓库变更,导致GitHub自动暂停了本次自动更新,你需要手动Sync Fork一次。 47 | 48 | To fix this issue: 49 | 1. Go to your forked repository 50 | 2. Click on "Sync fork" button 51 | 3. Click "Update branch" 52 | 53 | After manual sync, automatic updates should resume working. -------------------------------------------------------------------------------- /src/utils/validator.ts: -------------------------------------------------------------------------------- 1 | import { VALID_CHARS_REGEX } from "../config/constants.ts"; 2 | import { PasteError } from "./response.ts"; 3 | import { ResponseMessages } from "./messages.ts"; 4 | import {generateKey} from "./common.ts"; 5 | 6 | /** 解析 /:key/:pwd? 并完成合法性校验 */ 7 | export function parsePathParams( 8 | params: Record, 9 | operation: 'save' | 'other' = 'other' 10 | ): { key: string; pwd: string } { 11 | 12 | const key = params.key?.toLowerCase() || (operation === 'save' ? generateKey() : ""); 13 | const pwd = params.pwd ?? ""; 14 | 15 | if ( 16 | key && (key.length > 32 || key.length < 2 || !VALID_CHARS_REGEX.test(key)) 17 | ) throw new PasteError(403, ResponseMessages.PATH_UNAVAILABLE); 18 | 19 | if ( 20 | pwd && (pwd.length > 32 || pwd.length < 1 || !VALID_CHARS_REGEX.test(pwd)) 21 | ) throw new PasteError(403, ResponseMessages.PATH_UNAVAILABLE); 22 | 23 | return { 24 | key: key, 25 | pwd: pwd, 26 | }; 27 | } 28 | 29 | /** 通用分页校验 */ 30 | export function parsePagination(url: URL): { page: number; pageSize: number } { 31 | const page = +(url.searchParams.get("page") ?? "1"); 32 | const pageSize = +(url.searchParams.get("pageSize") ?? "10"); 33 | if (!Number.isInteger(page) || page < 1) { 34 | throw new PasteError(400, ResponseMessages.INVALID_PAGE); 35 | } 36 | if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) { 37 | throw new PasteError(400, ResponseMessages.INVALID_PAGE_SIZE); 38 | } 39 | return { page, pageSize }; 40 | } 41 | 42 | export function checkPassword(dbpwd:string, pwd?: string) { 43 | if (!dbpwd) return true; // 无密码 44 | return dbpwd === pwd; // 有密码则需匹配 45 | } 46 | -------------------------------------------------------------------------------- /src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "../utils/response.ts"; 2 | import { ResponseMessages } from "../utils/messages.ts"; 3 | import { parsePagination } from "../utils/validator.ts"; 4 | import { createMetadataRepository } from "../db/db.ts"; 5 | 6 | export async function getStorage(ctx) { 7 | const user = ctx.state.session?.get("user"); 8 | if (!user?.email) return new Response(ctx, 401, ResponseMessages.LOGIN_REQUIRED); 9 | 10 | const { page, pageSize } = parsePagination(new URL(ctx.request.url)); 11 | const offset = (page - 1) * pageSize; 12 | 13 | const repo = await createMetadataRepository(); 14 | const { items, total } = await repo.paginateByEmail(user.email, pageSize, offset); 15 | const totalPages = Math.ceil(total / pageSize); 16 | 17 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 18 | items, 19 | pagination: { total, page, pageSize, totalPages }, 20 | }); 21 | } 22 | 23 | export async function getToken(ctx) { 24 | // 获取基本请求信息 25 | const referer = ctx.request.headers.get("referer"); 26 | const origin = ctx.request.headers.get("origin"); 27 | 28 | if (!referer || referer.includes("/r/") || referer.includes("/m/")) { 29 | return new Response(ctx, 403, ResponseMessages.REFERER_DISALLOWED); 30 | } 31 | if (referer === origin) { 32 | return new Response(ctx, 403, ResponseMessages.REFERER_INVALID); 33 | } 34 | const refererUrl = new URL(referer); 35 | if (refererUrl.pathname === "/" || refererUrl.pathname === "") { 36 | return new Response(ctx, 403, ResponseMessages.REFERER_DISALLOWED); 37 | } 38 | 39 | const token = await ctx.cookies.get("token"); 40 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { token: token }); 41 | } 42 | -------------------------------------------------------------------------------- /src/routes/admin.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import { Response } from "../utils/response.ts"; 3 | import { ResponseMessages } from "../utils/messages.ts"; 4 | import {createMetadataRepository} from "../db/repositories/metadataRepository.ts"; 5 | import {EMAIL, QBIN_ENV} from "../config/constants.ts"; 6 | import {purgeExpiredCacheEntries, getAllStorage, syncDBToKV} from "../controllers/admin.controller.ts"; 7 | import {migrateToV3} from "../db/helpers/migrate.ts"; 8 | import {get_env} from "../config/env.ts"; 9 | 10 | 11 | const router = new Router(); 12 | 13 | router 14 | .get("/api/admin/storage", getAllStorage) 15 | .get("/api/admin/sync", async (ctx) => { 16 | const email = await ctx.state.session?.get("user")?.email; 17 | if(QBIN_ENV === "dev") return new Response(ctx, 403, ResponseMessages.DEMO_RESTRICTED); 18 | if (email !== EMAIL) return new Response(ctx, 403, ResponseMessages.ADMIN_REQUIRED); 19 | const repo = await createMetadataRepository(); 20 | return await syncDBToKV(ctx, repo); 21 | }) // kv与pg同步 22 | .get("/api/database/migrate", async (ctx) => { 23 | // 旧版本数据迁移至新数据表 24 | const email = await ctx.state.session?.get("user")?.email; 25 | if(QBIN_ENV === "dev") return new Response(ctx, 403, ResponseMessages.DEMO_RESTRICTED); 26 | if (email !== EMAIL) return new Response(ctx, 403, ResponseMessages.ADMIN_REQUIRED); 27 | const repo = await createMetadataRepository(); 28 | const {rowCount} = await migrateToV3(repo, get_env("DB_CLIENT", "postgres")); 29 | return new Response(ctx, 200, ResponseMessages.SUCCESS, {rowCount: rowCount}); 30 | }) 31 | .get("/api/cache/purge", purgeExpiredCacheEntries); // 清理过期缓存 32 | 33 | export default router; -------------------------------------------------------------------------------- /Docs/document.md: -------------------------------------------------------------------------------- 1 | # QBin 使用指南 2 | 3 | QBin 提供便捷的内容编辑&分享服务,让您轻松存储和分享各种文字、代码和文件。 4 | 5 | ## 🚀 快速开始 6 | 7 | 1. 访问你部署好的地址, 示例 [QBin Demo](https://qbin.me) 8 | 2. 选择适合您需求的编辑器: 9 | - 📒 **Markdown编辑器**:支持实时预览,方便文档编写 10 | - 📝 **代码编辑器**:提供语法高亮,适合各类编程代码 11 | - 📦 **通用编辑器**:快速上传和分享各种文件类型 12 | 3. 默认管理员账号密码: 13 | - 账号: admin@qbin.github 14 | - 密码: qbin 15 | 16 | ## 💾 内容保存 17 | 18 | 1. 在编辑器中输入或上传您的内容 19 | 2. 您可以设置**自定义过期时间**,控制内容的保存期限 20 | 3. 您可以**自定义访问路径**和**密码**,方便记忆和分享 21 | 4. 点击 ![预览图标](https://s3.tebi.io/lite/preview.svg) 图标进入预览&管理界面 22 | 5. 系统会根据您的设置生成: 23 | - **访问路径**:内容的唯一标识符(2-32字符) 24 | - **密码**:可选的额外保护措施(0-32字符) 25 | 26 | ## 📤 分享方式 27 | 28 | QBin 提供两种链接格式,满足不同分享需求: 29 | 30 | - **预览模式**:`https://qbin.me/p/访问路径/密码` 31 | - 带有完整管理界面,方便浏览和操作 32 | - 例如:`https://qbin.me/p/document` 33 | 34 | - **直链模式**:`https://qbin.me/r/访问路径/密码` 35 | - 仅显示纯内容,无界面元素 36 | - 例如:`https://qbin.me/r/document` 37 | 38 | ## 📱 内容管理 39 | 40 | 在预览界面(p/链接)中,您可以: 41 | 42 | - **复制内容**:单击 Copy 按钮 43 | - **复制分享链接**:双击 Copy 按钮 44 | - **生成二维码**:点击 QR 按钮,方便移动设备扫描访问 45 | - **查看直链内容**:点击 Raw 按钮,切换到纯内容模式 46 | - **创建新内容**:点击 New 按钮,开始新的创作 47 | - **删除内容**:点击 Del 按钮,永久删除内容 48 | 49 | 在个人中心界面, 你可以: 50 | - 选择(通用 / 代码 / Markdown)编辑器 51 | - 存储管理(List/Grid)视图 52 | - 设置 浅色 / 深色 / 跟随系统主题 53 | - 生成第三方接入Token 54 | - 设置默认编辑器 55 | 56 | ## 💡 使用技巧 57 | 58 | - **HTML渲染**:在代码编辑器中选择 HTML 语言,通过直链模式链接(r/)可直接渲染出网页效果 59 | - **自定义路径**:使用有意义的自定义访问路径,便于记忆和分享 60 | - **嵌入网站**:直链模式链接(r/)可轻松嵌入到其他网站或用于直接下载 61 | - **交互体验**:预览模式链接(p/)适合与他人共享,提供更完整的查看体验 62 | - **私密分享**:为重要内容设置密码,确保只有知道密码的人能访问 63 | - **定时过期**:为临时内容设置合适的过期时间,自动清理不再需要的数据 64 | - **高级接口**: 如果你需要将接口对接到第三方程序,请查看 [API Docs](https://github.com/Quick-Bin/qbin/blob/main/Docs/REST%20API.md) 65 | 66 | ## ⚠️ 安全提示 67 | 68 | - 内容仅能由创建者修改或删除 69 | - 建议为敏感或重要内容设置密码 70 | - 记录好您的访问路径和密码,这是再次访问内容的唯一方式 71 | -------------------------------------------------------------------------------- /src/routes/frontend.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "https://deno.land/x/oak/mod.ts"; 2 | import { 3 | getEditHtml, 4 | getCodeEditHtml, 5 | getMDEditHtml, 6 | getFavicon, 7 | getLoginPageHtml, 8 | getHomeHtml, 9 | getRenderHtml, 10 | getDocumentHtml, 11 | getPWALoaderHtml, 12 | getServiceWorker, 13 | getManifest, 14 | getJS, 15 | getCSS, 16 | getFONTS, 17 | getIMG, 18 | } from "../utils/render.ts"; 19 | 20 | const router = new Router(); 21 | 22 | router 23 | .get("/favicon.ico", (ctx) => getFavicon(ctx, 200)) 24 | .get("/login", (ctx) => getLoginPageHtml(ctx, 200)) 25 | .get("/home", (ctx) => getHomeHtml(ctx, 200)) 26 | .get("/e/:key?/:pwd?", (ctx) => getEditHtml(ctx, 200)) 27 | .get("/c/:key?/:pwd?", (ctx) => getCodeEditHtml(ctx, 200)) 28 | .get("/m/:key?/:pwd?", (ctx) => getMDEditHtml(ctx, 200)) 29 | .get("/p/:key?/:pwd?", async (ctx) => await getRenderHtml(ctx, 200)) 30 | .get(/^\/?[a-zA-Z0-9]?\/?$/, async (ctx) => { 31 | const editor = await ctx.cookies.get("qbin-editor") || "m"; 32 | const map = { e: getEditHtml, c: getCodeEditHtml, m: getMDEditHtml }; 33 | return await (map[editor] || getMDEditHtml)(ctx, 200); 34 | }) 35 | .get("/service-worker.js", (ctx) => getServiceWorker(ctx, 200)) 36 | .get("/manifest.json", (ctx) => getManifest(ctx, 200)) 37 | .get("/pwa-loader", (ctx) => getPWALoaderHtml(ctx, 200)) 38 | .get("/document", async (ctx) => { 39 | return await getDocumentHtml(ctx, 200); 40 | }) 41 | .get("/static/js/:file", (ctx) => getJS(ctx, ctx.params.file, 200)) 42 | .get("/static/css/:file", (ctx) => getCSS(ctx, ctx.params.file, 200)) 43 | .get("/static/css/fonts/:file", (ctx) => getFONTS(ctx, ctx.params.file, 200)) 44 | .get("/static/img/:file", (ctx) => getIMG(ctx, ctx.params.file, 200)); 45 | 46 | export default router; -------------------------------------------------------------------------------- /src/middlewares/etag.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from "https://deno.land/x/oak/mod.ts"; 2 | 3 | 4 | function getETagValue(etag: string | null): string { 5 | if (!etag) return ""; 6 | const match = etag.match(/(\d+)/); 7 | return match ? match[1] : "0"; 8 | } 9 | 10 | 11 | export const etagMiddleware = async (ctx: Context, next: Next) => { 12 | await next(); 13 | 14 | if (ctx.request.method !== 'GET') { 15 | return; 16 | } 17 | // 如果没有 metadata 或没有 etag,则跳过中间件 18 | const metadata = ctx.state.metadata; 19 | if(ctx.response.status !=200){ 20 | ctx.response.headers.delete("cache-control"); 21 | } 22 | if (!metadata && (!metadata?.etag || !metadata?.time)) { 23 | return; 24 | } 25 | 26 | // 获取客户端传来的 If-None-Match 头 27 | const clientETag = parseInt(getETagValue(ctx.request.headers.get("If-None-Match"))); 28 | // 获取客户端传来的 If-Modified-Since 头 29 | const clientLastModified = ctx.request.headers.get("If-Modified-Since"); 30 | // 比较缓存的 ETag 和客户端的 ETag 31 | if (clientETag === metadata.etag) { 32 | // 如果匹配,则返回 304 Not Modified 33 | ctx.response.status = 304; 34 | ctx.response.body = undefined; 35 | ctx.response.headers.set("Content-Length", "0"); 36 | return; 37 | } 38 | 39 | // 检查 Last-Modified 是否匹配 40 | if (clientLastModified) { 41 | const clientDate = new Date(clientLastModified).getTime() / 1000; // 转换为时间戳 42 | if (clientDate >= metadata.time) { 43 | // 如果客户端提供的时间大于或等于资源的最后修改时间,则返回 304 44 | ctx.response.status = 304; 45 | ctx.response.body = undefined; 46 | ctx.response.headers.set("Content-Length", "0"); 47 | return; 48 | } 49 | } 50 | // 如果不匹配,则继续处理请求并设置 ETag 头 51 | if(metadata?.etag){ 52 | ctx.response.headers.set("ETag", `"${metadata.etag}"`); 53 | } 54 | if(metadata?.time){ 55 | const lastModified = new Date(metadata.time * 1000).toUTCString(); 56 | ctx.response.headers.set("Last-Modified", lastModified); 57 | } 58 | return; 59 | }; 60 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 类型定义文件 3 | */ 4 | 5 | export interface Metadata { 6 | /** 主键(唯一标识) */ 7 | fkey: string; 8 | /** 内容标题 */ 9 | title: string; 10 | /** 创建时间戳 */ 11 | time: number; 12 | /** 过期时间戳 */ 13 | expire: number; 14 | /** 客户端 IP */ 15 | ip: string; 16 | /** 文件二进制内容(入:ArrayBuffer,出:Uint8Array) */ 17 | content: ArrayBuffer; 18 | /** 客户端声明的 MIME 类型 */ 19 | mime: string; 20 | /** 字节长度 */ 21 | len: number; 22 | /** 访问密码(可选) */ 23 | pwd?: string; 24 | /** 上传者邮箱 */ 25 | email?: string; 26 | /** 上传者昵称 */ 27 | uname?: string; 28 | /** 文件哈希 */ 29 | hash: number; 30 | } 31 | 32 | export interface KVMeta { 33 | fkey: string; 34 | email: string | null; 35 | title: string | null; 36 | name: string | null; 37 | ip: string | null; 38 | len: number; 39 | expire: number; 40 | hash: string; 41 | pwd: string | null; 42 | } 43 | 44 | export interface SessionStore extends Map {} 45 | 46 | export interface AuthUser { 47 | id: number; 48 | name: string; 49 | email?: string; 50 | provider: string; 51 | } 52 | 53 | export interface AppState { 54 | session: SessionStore; 55 | user?: AuthUser; 56 | } 57 | 58 | export type FontExt = 'eot' | 'svg' | 'ttf' | 'woff' | 'woff2'; 59 | export type ImageExt = 'png' | 'jpg' | 'jpeg' | 'gif' | 'svg' | 'webp' | 'ico'; 60 | export type FileExt = FontExt | ImageExt; 61 | export const fontMimeMap = { 62 | eot: 'application/vnd.ms-fontobject', 63 | svg: 'image/svg+xml', 64 | ttf: 'font/ttf', 65 | woff: 'font/woff', 66 | woff2:'font/woff2', 67 | } as const; 68 | export const imageMimeMap = { 69 | png: 'image/png', 70 | jpg: 'image/jpeg', 71 | jpeg: 'image/jpeg', 72 | gif: 'image/gif', 73 | svg: 'image/svg+xml', 74 | webp: 'image/webp', 75 | ico: 'image/x-icon', 76 | } as const; 77 | export const mimeMap: Record = { 78 | ...fontMimeMap, 79 | ...imageMimeMap, 80 | } as const; 81 | export const getMime = (ext: FileExt): string => mimeMap[ext]; 82 | -------------------------------------------------------------------------------- /Docs/REST API.md: -------------------------------------------------------------------------------- 1 | # QBin API文档 2 | 3 | ## 系统状态 4 | ```http 5 | GET /api/health 6 | ``` 7 | 用途:检查服务是否正常运行 8 | - 返回:200 表示服务正常 9 | 10 | ## 内容管理 11 | 12 | ### 1. 上传/更新内容 13 | ```http 14 | POST /save/{访问路径}/{密码} 15 | ``` 16 | - 说明:如果内容存在则更新,不存在则创建 17 | - 权限:更新已有内容需要是创建者 18 | - 请求头: 19 | - `Content-Type`: 内容类型 20 | - `x-expire`: 过期时间(秒) 21 | - `Cookie`: 生成的API Token信息 22 | - 返回:成功返回访问链接 23 | - python代码示例 24 | ```python 25 | import requests 26 | 27 | def upload_content(path, content, password="", expire=315360000): 28 | url = f'https://qbin.me/save/{path}/{password}' 29 | headers = { 30 | 'Content-Type': 'text/plain', 31 | 'x-expire': str(expire), 32 | 'Cookie': 'token=your_jwt_token' # 在设置API Token中生成获取 33 | } 34 | response = requests.post(url, data=content, headers=headers) 35 | 36 | if response.status_code == 200: 37 | result = response.json() 38 | print(f"上传成功,访问链接: {result['data']['url']}") 39 | elif response.status_code == 403: 40 | print("无权限更新") 41 | elif response.status_code == 413: 42 | print("内容超过大小限制") 43 | 44 | # 使用示例 45 | upload_content('test123', 'Hello World', 'password123') 46 | ``` 47 | 48 | ### 2. 上传文件内容 49 | ```http 50 | PUT /save/{访问路径}/{密码} 51 | ``` 52 | - 说明:如果文件存在则更新,不存在则创建 53 | - 权限:更新已有文件需要是创建者 54 | - 请求头: 55 | - `Content-Type`: 内容类型 56 | - `x-expire`: 过期时间(秒) 57 | - `Cookie`: 生成的API Token信息 58 | - 返回:成功返回访问链接 59 | 60 | ### 3. 获取直链内容 61 | ```http 62 | GET /r/{访问路径}/{密码} 63 | ``` 64 | 65 | ## 认证相关 66 | 67 | ### 1. 获取API Token 68 | ```http 69 | POST /api/user/token 70 | ``` 71 | - 用途:获取当前用户的认证token 72 | - 返回: 73 | ```json 74 | { 75 | "token": "jwt_token_string" 76 | } 77 | ``` 78 | - 说明:需要已登录状态 79 | 80 | ### 2. 登出 81 | ```http 82 | POST /api/user/logout 83 | ``` 84 | - 用途:清除用户登录状态 85 | 86 | ### 3. 社交登录 87 | ```http 88 | GET /api/login/{provider} 89 | ``` 90 | - provider支持:github, google, microsoft, custom 91 | 92 | ## 响应格式 93 | 所有API返回统一格式: 94 | ```json 95 | { 96 | "code": 200, // 状态码 97 | "message": "success", // 状态信息 98 | "data": { // 数据体 99 | // 具体内容 100 | } 101 | } 102 | ``` 103 | 104 | ## 常见状态码 105 | - 200: 成功 106 | - 403: 无权限 107 | - 404: 内容不存在 108 | - 409: 内容已存在 109 | - 413: 内容过大 110 | - 500: 服务器错误 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | qbin: 3 | image: naiher/qbin:latest 4 | container_name: qbin 5 | restart: always 6 | environment: 7 | # 必选环境变量 8 | - ADMIN_EMAIL=admin@qbin.github # 请修改为你的邮箱 9 | - ADMIN_PASSWORD=qbin # 请修改为安全的密码 10 | - JWT_SECRET=your_jwt_secret # 请修改为随机安全字符串 11 | 12 | # 可选环境变量 13 | - PORT=8000 14 | - TOKEN_EXPIRE=31536000 15 | - MAX_UPLOAD_FILE_SIZE=52428800 16 | - DB_CLIENT=sqlite 17 | - DATABASE_URL="file:/app/data/qbin_local.db" 18 | - ENABLE_ANONYMOUS_ACCESS=1 19 | 20 | # GitHub OAuth配置(可选,如需使用请取消注释并填写) 21 | # - GITHUB_CLIENT_ID=your_github_client_id 22 | # - GITHUB_CLIENT_SECRET=your_github_client_secret 23 | # - GITHUB_CALLBACK_URL=http://your-domain:8000/api/login/oauth2/callback/github 24 | 25 | # Google OAuth配置(可选,如需使用请取消注释并填写) 26 | # - GOOGLE_CLIENT_ID=your_google_client_id 27 | # - GOOGLE_CLIENT_SECRET=your_google_client_secret 28 | # - GOOGLE_CALLBACK_URL=http://your-domain:8000/api/login/oauth2/callback/google 29 | 30 | # Microsoft OAuth配置(可选,如需使用请取消注释并填写) 31 | # - MICROSOFT_CLIENT_ID=74xxxx-xxxx-xxxx-xxxx-xxxxxxxxxx # Microsoft应用ID 32 | # - MICROSOFT_CLIENT_SECRET=25xxxx-xxxx-xxxx-xxxx-xxxxxxxxxx # Microsoft应用密钥 33 | # - MICROSOFT_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/microsoft # Microsoft登录回调地址 34 | 35 | # Custom OAuth配置(可选,如需使用请取消注释并填写) 36 | # - OAUTH_CLIENT_ID=V4HmbQixxxxxxxxxxxxCJ2CVypqL 37 | # - OAUTH_CLIENT_SECRET=hZtE3cxxxxxxxxxxxxxkZ0al01Hi 38 | # - OAUTH_AUTH_URL=https://provider.example.com/oauth2/authorize 39 | # - OAUTH_TOKEN_URL=https://provider.example.com/oauth2/token 40 | # - OAUTH_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/custom 41 | # - OAUTH_SCOPES=user:profile 42 | # - OAUTH_USER_INFO_URL=https://provider.example.com/api/user 43 | volumes: 44 | - ~/qbin-data:/app/data # 持久化存储数据库文件 45 | ports: 46 | - "8000:8000" 47 | networks: 48 | - qbin-network 49 | healthcheck: 50 | test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] 51 | interval: 30s 52 | timeout: 10s 53 | retries: 3 54 | start_period: 20s 55 | 56 | volumes: 57 | qbin-data: # 定义用于存储SQLite数据库的卷 58 | driver: local 59 | 60 | networks: 61 | qbin-network: 62 | driver: bridge 63 | internal: false 64 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 全局常量配置 3 | */ 4 | import { get_env } from "./env.ts"; 5 | 6 | 7 | export const TOKEN_EXPIRE = parseInt(get_env("TOKEN_EXPIRE", "31536000")); // JWT 过期时长(秒) 8 | export const MAX_UPLOAD_FILE_SIZE = parseInt(get_env("MAX_UPLOAD_FILE_SIZE", "52428800")); 9 | export const MAX_CACHE_SIZE = 1048576; // 单数据缓存上限 10 | export const ENABLE_ANONYMOUS_ACCESS = parseInt(get_env("ENABLE_ANONYMOUS_ACCESS", "1")); // 匿名访问 11 | 12 | export const PASTE_STORE = "qbinv3"; // KV 命名空间 13 | export const DENO_KV_ACCESS_TOKEN = get_env("DENO_KV_ACCESS_TOKEN"); // Deno KV 访问令牌 14 | export const DENO_KV_PROJECT_ID = get_env("DENO_KV_PROJECT_ID"); // Deno KV 项目 ID 15 | export const CACHE_CHANNEL = "qbin-cache-sync"; 16 | 17 | // Deno KV 项目 ID 验证正则表达式 18 | export const DENO_KV_PROJECT_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 19 | 20 | export const jwtSecret = get_env("JWT_SECRET", "input-your-jwtSecret"); // 从环境变量获取jwt密钥 21 | export const exactPaths = ["/favicon.ico", "/document", "/api/health", "/login", "/pwa-loader", "/service-worker.js", "/manifest.json"] 22 | export const prefixPaths = ['/r/', '/e/', '/c/', '/m/', '/p/', '/static/', '/api/login/'] 23 | export const basePath = Deno.cwd(); 24 | 25 | export const EMAIL = get_env("ADMIN_EMAIL", "admin@qbin.github"); 26 | export const PASSWORD = get_env("ADMIN_PASSWORD", "qbin"); 27 | export const QBIN_ENV = get_env("QBIN_ENV", "prod"); 28 | // 校验访问路径和访问密码字符 29 | export const VALID_CHARS_REGEX = /^[a-zA-Z0-9-\.\_]+$/; 30 | // 上传 MIME类型校验正则 31 | export const mimeTypeRegex = /^[-\w.+]+\/[-\w.+]+(?:;\s*[-\w]+=[-\w]+)*$/i; 32 | // 保留访问路径 33 | export const reservedPaths = new Set( 34 | (get_env("RESERVED_PATHS", "")).split(",").map(s => s.trim().toLowerCase()) 35 | ); 36 | 37 | export const HEADERS = { 38 | HTML: { 39 | "X-Content-Type-Options": "nosniff", // 禁止嗅探MIME类型 40 | "X-XSS-Protection": "1; mode=block", // 启用XSS过滤器 41 | "X-Frame-Options": "DENY", // 禁止页面在frame中展示 42 | // "Content-Security-Policy": "default-src 'self'", // 同源加载 43 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", // HSTS强制使用HTTPS 44 | "Referrer-Policy": "strict-origin-when-cross-origin", // 引用策略 45 | }, 46 | JSON: { "Content-Type": "application/json" }, 47 | CORS: { 48 | "Access-Control-Allow-Origin": "*", 49 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 50 | "Access-Control-Allow-Headers": "Content-Type", 51 | "Access-Control-Max-Age": "86400", 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # 管理员信息(登录时需要) 2 | ADMIN_EMAIL=admin@qbin.github # 管理员邮箱(必选) 3 | ADMIN_PASSWORD=qbin # 管理员密码(必选) 4 | 5 | # 数据库配置 6 | DB_CLIENT=postgres # 选择数据库,支持 postgres、sqlite 7 | DATABASE_URL=postgresql://user:password@localhost:5432:/local # PostgreSQL连接URL(必选) 8 | CA_CERT_DATA= # 数据库CA证书内容 9 | 10 | SQLITE_URL= # SQLite地址,默认本地存储 file:data/qbin_local.db 11 | SQLITE_AUTH_TOKEN= # SQLite token验证 12 | 13 | 14 | # 应用配置 15 | ENABLE_ANONYMOUS_ACCESS=1 # 匿名访问,默认开启,0表示关闭, 支持共享编辑 16 | JWT_SECRET=XTV0x5ecm5 # JWT密钥,用于加密验证(建议修改) 17 | MAX_UPLOAD_FILE_SIZE=52428800 # 最大上传文件大小(50MB) = 1024 * 1024 * 50 18 | TOKEN_EXPIRE=31536000 # 令牌过期时间(秒,约1年) = 86400 * 365 19 | RESERVED_PATHS=login,register,api # 保留访问路径,以英文,分隔 20 | QBIN_ENV=prod # 演示模式可选dev,默认prod 21 | PORT=8000 # 访问端口 22 | 23 | # ------------------------- 以下为OAuth2授权参数,皆为可选项 ------------------------- 24 | 25 | # GitHub OAuth登录配置 26 | GITHUB_CLIENT_ID=Ovxxxxxxxxxfle8Oyi # GitHub应用ID 27 | GITHUB_CLIENT_SECRET=56ab9xxxxxxxxxxxxxx4012184b426 # GitHub应用密钥 28 | GITHUB_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/github # GitHub登录回调地址 29 | 30 | # Google OAuth登录配置 31 | GOOGLE_CLIENT_ID=84932xxxxx-gbxxxxxxxxxxxxjg8s3v.apps.googleusercontent.com # Google应用ID 32 | GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx # Google应用密钥 33 | GOOGLE_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/google # Google登录回调地址 34 | 35 | # Microsoft OAuth登录配置 36 | MICROSOFT_CLIENT_ID=74xxxx-xxxx-xxxx-xxxx-xxxxxxxxxx # Microsoft应用ID 37 | MICROSOFT_CLIENT_SECRET=1xxxx~xxxxxxxxxxxxxxxxxxxx_at0 # Microsoft应用密钥 38 | MICROSOFT_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/microsoft # Microsoft登录回调地址 39 | 40 | # 自定义 OAuth登录配置 41 | OAUTH_CLIENT_ID=V4HmbQixxxxxxxxxxxxCJ2CVypqL # OAuth应用ID 42 | OAUTH_CLIENT_SECRET=hZtE3cxxxxxxxxxxxxxkZ0al01Hi # OAuth应用密钥 43 | OAUTH_AUTH_URL=https://provider.example.com/oauth2/authorize # 授权端点 44 | OAUTH_TOKEN_URL=https://provider.example.com/oauth2/token # 令牌端点 45 | OAUTH_CALLBACK_URL=http://localhost:8000/api/login/oauth2/callback/custom # 登录回调地址 46 | OAUTH_SCOPES=user:profile # 请求权限范围 47 | OAUTH_USER_INFO_URL=https://provider.example.com/api/user # 用户信息API端点 48 | -------------------------------------------------------------------------------- /src/db/models/metadata.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgTable, 3 | varchar, 4 | bigint, 5 | integer as pgInteger, 6 | customType, 7 | } from "drizzle-orm/pg-core"; 8 | import { sql } from "drizzle-orm"; 9 | import { 10 | sqliteTable, 11 | text, 12 | integer as sqliteInteger, 13 | blob, 14 | } from "drizzle-orm/sqlite-core"; 15 | 16 | 17 | // 自定义 bytea 列类型 18 | const byteArray = customType<{ data: ArrayBuffer; driverData: Uint8Array }>({ 19 | dataType() { 20 | return "bytea"; 21 | }, 22 | toDriver(value) { // 写入库之前 23 | return new Uint8Array(value); 24 | }, 25 | fromDriver(value) { // 读出库之后 26 | return (value as Uint8Array).buffer; 27 | }, 28 | }); 29 | 30 | export const metadataPg = pgTable("qbindbv3", { 31 | fkey: varchar("fkey", { length: 40 }).primaryKey(), 32 | title: varchar("title", { length: 255 }), 33 | time: bigint("time", { mode: "number" }).notNull(), 34 | expire: bigint("expire", { mode: "number" }).notNull(), 35 | ip: varchar("ip", { length: 45 }).notNull(), 36 | content: byteArray("content").notNull(), 37 | mime: varchar("mime", { length: 255 }).notNull(), 38 | len: pgInteger("len").notNull(), 39 | pwd: varchar("pwd", { length: 40 }), 40 | email: varchar("email", { length: 255 }), 41 | uname: varchar("uname", { length: 255 }), 42 | hash: bigint("hash", { mode: "number" }), 43 | }); 44 | 45 | export const metadataSqlite = sqliteTable("qbindbv3", { 46 | fkey: text("fkey").primaryKey(), 47 | title: text("title"), 48 | time: sqliteInteger("time", { mode: "number" }).notNull(), 49 | expire: sqliteInteger("expire", { mode: "number" }).notNull(), 50 | ip: text("ip").notNull(), 51 | content: blob("content").notNull(), 52 | mime: text("mime").notNull(), 53 | len: sqliteInteger("len").notNull(), 54 | pwd: text("pwd"), 55 | email: text("email"), 56 | uname: text("uname"), 57 | hash: sqliteInteger("hash", { mode: "number" }), 58 | }); 59 | 60 | export async function initPostgresSchema(db: any) { 61 | await db.db.execute(sql` 62 | CREATE TABLE IF NOT EXISTS "qbindbv3" ( 63 | fkey varchar(40) PRIMARY KEY, 64 | title varchar(255), 65 | time bigint NOT NULL, 66 | expire bigint NOT NULL, 67 | ip varchar(45) NOT NULL, 68 | content bytea NOT NULL, 69 | mime varchar(255) NOT NULL, 70 | len integer NOT NULL, 71 | pwd varchar(40), 72 | email varchar(255), 73 | uname varchar(255), 74 | hash bigint 75 | )`); 76 | } 77 | 78 | export async function initSQLiteSchema(db: any) { 79 | await db.db.execute(sql` 80 | CREATE TABLE IF NOT EXISTS "qbindbv3" ( 81 | fkey TEXT PRIMARY KEY, 82 | title TEXT, 83 | time INTEGER NOT NULL, 84 | expire INTEGER NOT NULL, 85 | ip TEXT NOT NULL, 86 | content BLOB NOT NULL, 87 | mime TEXT NOT NULL, 88 | len INTEGER NOT NULL, 89 | pwd TEXT, 90 | email TEXT, 91 | uname TEXT, 92 | hash INTEGER 93 | )`); 94 | } 95 | 96 | export type MetadataPg = typeof metadataPg.$inferSelect; 97 | export type MetadataSqlite = typeof metadataSqlite.$inferSelect; -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 多级缓存管理 3 | * - 内存 (memCache) 4 | * - Cache API (Deno Deploy KV-like) 5 | * - Deno KV (for meta) + Postgres (最终存储) 6 | */ 7 | import { 8 | CACHE_CHANNEL, 9 | DENO_KV_ACCESS_TOKEN, 10 | DENO_KV_PROJECT_ID, 11 | DENO_KV_PROJECT_ID_REGEX, 12 | MAX_CACHE_SIZE, 13 | PASTE_STORE 14 | } from "../config/constants.ts"; 15 | import { Metadata } from "../utils/types.ts"; 16 | import { checkPassword } from "./validator.ts"; 17 | 18 | 19 | export const memCache = new Map>(); 20 | export const kv = await ((() => { 21 | const projectId = DENO_KV_PROJECT_ID?.trim() || ""; 22 | const accessToken = DENO_KV_ACCESS_TOKEN?.trim() || ""; 23 | 24 | if (projectId && accessToken && DENO_KV_PROJECT_ID_REGEX.test(projectId)) { 25 | return Deno.openKv(`https://api.deno.com/databases/${projectId}/connect`, { 26 | accessToken: accessToken, 27 | }); 28 | } 29 | return Deno.openKv(); 30 | })()); 31 | export const cacheBroadcast = new BroadcastChannel(CACHE_CHANNEL); 32 | 33 | cacheBroadcast.onmessage = async (event: MessageEvent) => { 34 | const { type, key, metadata } = event.data; 35 | if (!key) return; 36 | if (type === "update" && key) { 37 | await deleteCache(key, metadata); 38 | } else if (type === "delete" && key) { 39 | await deleteCache(key, metadata); 40 | } 41 | }; 42 | 43 | export async function isCached(key: string, pwd?: string | undefined, repo): Promise { 44 | const memData = memCache.get(key); 45 | if (memData && "pwd" in memData) { 46 | if ("pwd" in memData) return memData; 47 | } 48 | 49 | const kvResult = await kv.get([PASTE_STORE, key]); 50 | if (!kvResult.value) return null; 51 | memCache.set(key, kvResult.value); // 减少内查询 52 | return kvResult.value; 53 | } 54 | 55 | export async function checkCached(key: string, pwd?: string | undefined, repo): Promise { 56 | const memData = memCache.get(key); 57 | if (memData && "pwd" in memData) { 58 | if (!checkPassword(memData.pwd, pwd)) return null; 59 | if ("content" in memData) return memData; 60 | } 61 | 62 | const kvResult = await kv.get([PASTE_STORE, key]); 63 | if (!kvResult.value) return null; 64 | if (!checkPassword(kvResult.value.pwd, pwd)) return null; 65 | return kvResult.value; 66 | } 67 | 68 | /** 69 | * 从缓存中获取数据,如果缓存未命中,则从 KV 中获取并缓存 70 | */ 71 | export async function getCachedContent(key: string, pwd?: string, repo): Promise { 72 | try { 73 | const cache = await checkCached(key, pwd, repo); 74 | if (cache === null) return cache; 75 | if ("content" in cache) return cache; 76 | 77 | const dbData = await repo.getByFkey(key); 78 | if (!dbData) return null; 79 | await updateCache(key, dbData); 80 | return dbData; 81 | } catch (error) { 82 | console.error('Cache fetch error:', error); 83 | return null; 84 | } 85 | } 86 | 87 | /** 88 | * 更新缓存(写入内存和 Cache API) 89 | */ 90 | export async function updateCache(key: string, metadata: Metadata): Promise { 91 | try { 92 | if(metadata.len <= MAX_CACHE_SIZE) memCache.set(key, metadata); 93 | } catch (error) { 94 | console.error('Cache update error:', error); 95 | } 96 | } 97 | 98 | /** 99 | * 删除缓存 (内存 + Cache API) 100 | */ 101 | export async function deleteCache(key: string, meta) { 102 | try { 103 | memCache.delete(key); 104 | } catch (error) { 105 | console.error('Cache deletion error:', error); 106 | } 107 | } -------------------------------------------------------------------------------- /static/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | pull_request: 8 | types: [synchronize, labeled, unlabeled] 9 | 10 | concurrency: 11 | group: ${{ github.ref }}-${{ github.workflow }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | REGISTRY_IMAGE: naiher/qbin 16 | 17 | jobs: 18 | build: 19 | strategy: 20 | matrix: 21 | include: 22 | - platform: linux/amd64 23 | os: ubuntu-latest 24 | - platform: linux/arm64 25 | os: ubuntu-24.04-arm 26 | runs-on: ${{ matrix.os }} 27 | name: Build ${{ matrix.platform }} Image 28 | steps: 29 | - name: Prepare 30 | run: | 31 | platform=${{ matrix.platform }} 32 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 33 | 34 | - name: Checkout base 35 | uses: actions/checkout@v4 36 | with: 37 | fetch-depth: 0 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | 42 | - name: Docker meta 43 | id: meta 44 | uses: docker/metadata-action@v5 45 | with: 46 | images: ${{ env.REGISTRY_IMAGE }} 47 | tags: | 48 | type=semver,pattern={{version}} 49 | type=raw,value=latest,enable={{is_default_branch}} 50 | 51 | - name: Docker login 52 | uses: docker/login-action@v3 53 | with: 54 | username: ${{ secrets.DOCKER_REGISTRY_USER }} 55 | password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} 56 | 57 | - name: Get commit SHA 58 | if: github.ref == 'refs/heads/main' 59 | id: vars 60 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 61 | 62 | - name: Build and export 63 | id: build 64 | uses: docker/build-push-action@v5 65 | with: 66 | platforms: ${{ matrix.platform }} 67 | context: . 68 | file: ./Dockerfile 69 | labels: ${{ steps.meta.outputs.labels }} 70 | build-args: | 71 | SHA=${{ steps.vars.outputs.sha_short }} 72 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 73 | 74 | - name: Export digest 75 | run: | 76 | rm -rf /tmp/digests 77 | mkdir -p /tmp/digests 78 | digest="${{ steps.build.outputs.digest }}" 79 | touch "/tmp/digests/${digest#sha256:}" 80 | 81 | - name: Upload artifact 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: digest-${{ env.PLATFORM_PAIR }} 85 | path: /tmp/digests/* 86 | if-no-files-found: error 87 | retention-days: 1 88 | 89 | merge: 90 | name: Merge 91 | needs: build 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: Checkout base 95 | uses: actions/checkout@v4 96 | with: 97 | fetch-depth: 0 98 | 99 | - name: Download digests 100 | uses: actions/download-artifact@v4 101 | with: 102 | path: /tmp/digests 103 | pattern: digest-* 104 | merge-multiple: true 105 | 106 | - name: Set up Docker Buildx 107 | uses: docker/setup-buildx-action@v3 108 | 109 | - name: Docker meta 110 | id: meta 111 | uses: docker/metadata-action@v5 112 | with: 113 | images: ${{ env.REGISTRY_IMAGE }} 114 | tags: | 115 | type=semver,pattern={{version}} 116 | type=raw,value=latest,enable={{is_default_branch}} 117 | 118 | - name: Docker login 119 | uses: docker/login-action@v3 120 | with: 121 | username: ${{ secrets.DOCKER_REGISTRY_USER }} 122 | password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} 123 | 124 | - name: Create manifest list and push 125 | working-directory: /tmp/digests 126 | run: | 127 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 128 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 129 | 130 | - name: Inspect image 131 | run: | 132 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} -------------------------------------------------------------------------------- /src/controllers/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import {AppState, KVMeta} from "../utils/types.ts"; 2 | import {kv} from "../utils/cache.ts"; 3 | import {EMAIL, QBIN_ENV, PASTE_STORE} from "../config/constants.ts"; 4 | import {PasteError, Response} from "../utils/response.ts"; 5 | import {ResponseMessages} from "../utils/messages.ts"; 6 | import {parsePagination} from "../utils/validator.ts"; 7 | import {createMetadataRepository} from "../db/repositories/metadataRepository.ts"; 8 | import {getTimestamp} from "../utils/common.ts"; 9 | 10 | 11 | export async function syncDBToKV(ctx: Context, repo) { 12 | try { 13 | const now = getTimestamp(); 14 | const rows = await repo.getActiveMetas(); 15 | const dbMap = new Map>(); 16 | for (const r of rows) { 17 | dbMap.set(r.fkey, { 18 | email: r.email, 19 | title: r.title, 20 | name: r.uname, 21 | ip: r.ip, 22 | len: r.len, 23 | expire: r.expire, 24 | hash: r.hash, 25 | pwd: r.pwd, 26 | }); 27 | } 28 | 29 | const toRemove = []; 30 | const kvFkeys = new Set(); 31 | let removed = 0, added = 0, unchanged = 0; 32 | 33 | for await (const entry of kv.list({ prefix: [PASTE_STORE] })) { 34 | const fkey = entry.key[1] as string; 35 | const kvVal: { expire?: number } = entry.value ?? {}; 36 | 37 | // 1. KV 中条目已过期 2. 不存在于数据库(被删除或已过期) 38 | if ( 39 | (kvVal.expire !== undefined && kvVal.expire <= now) || 40 | !dbMap.has(fkey) 41 | ) { 42 | toRemove.push(entry.key); 43 | } else { 44 | kvFkeys.add(fkey); 45 | unchanged++; 46 | } 47 | } 48 | 49 | const batchSize = 100; 50 | for (let i = 0; i < toRemove.length; i += batchSize) { 51 | const atomic = kv.atomic(); 52 | for (const key of toRemove.slice(i, i + batchSize)) atomic.delete(key); 53 | await atomic.commit(); 54 | removed += Math.min(batchSize, toRemove.length - i); 55 | } 56 | 57 | const toAdd: [string, ReturnType] [] = []; 58 | for (const [fkey, meta] of dbMap) { 59 | if (!kvFkeys.has(fkey)) toAdd.push([fkey, meta]); 60 | } 61 | 62 | for (let i = 0; i < toAdd.length; i += batchSize) { 63 | const atomic = kv.atomic(); 64 | for (const [fkey, meta] of toAdd.slice(i, i + batchSize)) { 65 | atomic.set([PASTE_STORE, fkey], meta); 66 | } 67 | await atomic.commit(); 68 | added += Math.min(batchSize, toAdd.length - i); 69 | } 70 | 71 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 72 | stats: { added, removed, unchanged, total: rows.length }, 73 | }); 74 | } catch (error) { 75 | console.error("同步数据库到 KV 时出错: ", error); 76 | throw new PasteError(500, ResponseMessages.SERVER_ERROR); 77 | } 78 | } 79 | 80 | export async function getAllStorage(ctx) { 81 | if(QBIN_ENV === "dev") return new Response(ctx, 403, ResponseMessages.DEMO_RESTRICTED); 82 | const email = await ctx.state.session?.get("user")?.email; 83 | if (email !== EMAIL) return new Response(ctx, 403, ResponseMessages.ADMIN_REQUIRED); 84 | 85 | const { page, pageSize } = parsePagination(new URL(ctx.request.url)); 86 | const offset = (page - 1) * pageSize; 87 | 88 | const repo = await createMetadataRepository(); 89 | const { items, total } = await repo.listAlive(pageSize, offset); 90 | const totalPages = Math.ceil(total / pageSize); 91 | 92 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 93 | items, 94 | pagination: { total, page, pageSize, totalPages }, 95 | }); 96 | } 97 | 98 | export async function purgeExpiredCacheEntries(ctx){ 99 | const now = getTimestamp(); 100 | let removed = 0; // 被删除的条目 101 | let kept = 0; // 保留下来的条目 102 | const BATCH_SZ = 100; 103 | let batch = kv.atomic(); 104 | let counter = 0; 105 | for await (const { key, value } of kv.list({ prefix: [] })) { 106 | const isPasteStore = key[0] === PASTE_STORE; 107 | const isExpired = isPasteStore && value?.expire && value.expire < now; 108 | if (!isPasteStore || isExpired) { 109 | batch = batch.delete(key); 110 | removed++; 111 | counter++; 112 | if (counter === BATCH_SZ) { 113 | await batch.commit(); 114 | batch = kv.atomic(); 115 | counter = 0; 116 | } 117 | } else { 118 | kept++; 119 | } 120 | } 121 | if (counter) { 122 | await batch.commit(); 123 | } 124 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 125 | removed, 126 | kept, 127 | }); 128 | } -------------------------------------------------------------------------------- /static/js/multi-editor.js: -------------------------------------------------------------------------------- 1 | class QBinMultiEditor extends QBinEditorBase { 2 | constructor() { 3 | super(); 4 | this.currentEditor = "multi"; 5 | this.contentType = "text/plain; charset=UTF-8"; 6 | this.initialize(); 7 | } 8 | 9 | async initEditor() { 10 | this.editor = document.getElementById('editor'); 11 | this.setupDragAndPaste(); 12 | this.updateUploadAreaVisibility(); 13 | 14 | // 监听编辑器内容变化 15 | this.editor.addEventListener('input', () => { 16 | this.updateUploadAreaVisibility(); 17 | }); 18 | } 19 | 20 | getEditorContent() { 21 | return this.editor.value; 22 | } 23 | 24 | setEditorContent(content) { 25 | this.editor.value = content; 26 | this.updateUploadAreaVisibility(); 27 | } 28 | 29 | updateUploadAreaVisibility() { 30 | const uploadArea = document.querySelector('.upload-area'); 31 | if (uploadArea) { 32 | const isEmpty = !this.getEditorContent().trim(); 33 | uploadArea.classList.toggle('visible', isEmpty); 34 | } 35 | } 36 | 37 | setupDragAndPaste() { 38 | // 粘贴上传(图片) 39 | this.editor.addEventListener('paste', (e) => { 40 | const items = e.clipboardData.items; 41 | for (let item of items) { 42 | if (item.type.indexOf('image/') === 0) { 43 | e.preventDefault(); 44 | const file = item.getAsFile(); 45 | this.title = file.name.trim(); 46 | this.handleUpload(file, file.type); 47 | return; 48 | } 49 | } 50 | }); 51 | 52 | // 使用事件委托,只在content容器上添加拖放事件监听器 53 | const contentContainer = document.querySelector('.content'); 54 | if (!contentContainer) return; 55 | 56 | // 跟踪拖动状态 57 | let dragCounter = 0; 58 | 59 | // 验证拖放目标是否有效(编辑器或上传按钮) 60 | const isValidDropTarget = (target) => { 61 | return ( 62 | target === this.editor || 63 | target.closest('.upload-button') !== null 64 | ); 65 | }; 66 | 67 | // 使用事件委托处理所有拖放事件 68 | contentContainer.addEventListener('dragenter', (e) => { 69 | if (!isValidDropTarget(e.target)) return; 70 | 71 | e.preventDefault(); 72 | dragCounter++; 73 | 74 | // 检查拖动的是否为文件 75 | if (e.dataTransfer.types.includes('Files')) { 76 | this.editor.classList.add('drag-over'); 77 | } 78 | }); 79 | 80 | contentContainer.addEventListener('dragover', (e) => { 81 | if (!isValidDropTarget(e.target)) return; 82 | 83 | e.preventDefault(); 84 | }); 85 | 86 | contentContainer.addEventListener('dragleave', (e) => { 87 | if (!isValidDropTarget(e.target)) return; 88 | 89 | e.preventDefault(); 90 | dragCounter--; 91 | 92 | // 只有当计数器为0时才移除状态 93 | if (dragCounter <= 0) { 94 | dragCounter = 0; 95 | this.editor.classList.remove('drag-over'); 96 | } 97 | }); 98 | 99 | contentContainer.addEventListener('drop', (e) => { 100 | if (!isValidDropTarget(e.target)) return; 101 | 102 | e.preventDefault(); 103 | dragCounter = 0; 104 | this.editor.classList.remove('drag-over'); 105 | 106 | const files = e.dataTransfer.files; 107 | if (files.length > 0) { 108 | const file = files[0]; 109 | 110 | // 显示处理中状态 111 | this.editor.classList.add('processing'); 112 | 113 | // 处理完成后移除处理中状态 114 | setTimeout(() => { 115 | this.editor.classList.remove('processing'); 116 | }, 500); 117 | 118 | this.title = file.name.trim(); 119 | this.handleUpload(file, file.type); 120 | if (file.type.includes("text/")) { 121 | this.appendTextContent(file); 122 | } 123 | } 124 | }); 125 | 126 | // dragend - 全局确保状态清除 127 | document.addEventListener('dragend', () => { 128 | dragCounter = 0; 129 | this.editor.classList.remove('drag-over'); 130 | }); 131 | 132 | // 文件上传区域 133 | const uploadArea = document.querySelector('.upload-area'); 134 | const fileInput = document.getElementById('file-input'); 135 | 136 | // 恢复更新上传区域可见性的代码 137 | const updateUploadAreaVisibility = () => { 138 | if (uploadArea) { 139 | const isEmpty = !this.editor.value.trim(); 140 | uploadArea.classList.toggle('visible', isEmpty); 141 | } 142 | }; 143 | 144 | updateUploadAreaVisibility(); 145 | this.editor.addEventListener('input', updateUploadAreaVisibility); 146 | 147 | if (fileInput) { 148 | fileInput.addEventListener('change', (e) => { 149 | if (e.target.files.length > 0) { 150 | const file = e.target.files[0]; 151 | this.title = file.name.trim(); 152 | this.handleUpload(file, file.type); 153 | if (file.type.includes("text/")) { 154 | this.appendTextContent(file); 155 | } 156 | } 157 | }); 158 | } 159 | } 160 | 161 | appendTextContent(file) { 162 | const reader = new FileReader(); 163 | reader.onload = (event) => { 164 | const textContent = event.target.result; 165 | this.setEditorContent(this.getEditorContent() + textContent); 166 | }; 167 | reader.onerror = () => { 168 | console.error("读取文件内容时出错"); 169 | }; 170 | reader.readAsText(file); 171 | } 172 | } 173 | new QBinMultiEditor(); 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

QBin · 一键存储

3 | 4 | QBin LOGO 5 | 6 | > ✨ 轻量的 Cloud Note & PasteBin 替代方案,一键保存文本、代码、图片、视频等任意内容,分享更便捷! 7 | 8 | [简体中文] · [**English**](README_EN.md) · [演示站点](https://qbin.me) · [使用文档](Docs/document.md) · [自托管教程](Docs/self-host.md) · [接口文档](Docs/REST%20API.md) 9 | 10 | 11 | 12 | [![][docker-pulls-shield]][docker-pulls-link] 13 | [![][deno-shield]][deno-link] 14 | [![][latest-version-shield]][latest-version-link] 15 | [![][github-stars-shield]][github-stars-link] 16 | [![][github-license-shield]][github-license-link] 17 | 18 |
19 | 20 | ## 🖼️ 功能预览 21 | Mobile 22 | --- 23 | ![Mobile photos](https://s3.tebi.io/lite/mobile-preview.jpg) 24 | 25 | Windows 26 | ---- 27 | 28 | ![Windows photos](https://s3.tebi.io/lite/windows-preview.jpg) 29 | 30 | 31 | ## 📝 项目简介 32 | 33 | QBin 专注于「快速、安全、便捷」的在线编辑与内容分享,适合个人笔记、临时存储、多人协作、跨平台分享等多种场景。 34 | - 前端全程采用纯 HTML+JS+CSS,无需笨重框架,内置 Monaco 代码编辑器、Cherry Markdown 渲染器、通用编辑器,满足多种内容场景; 35 | - 后端选用 Deno Oak 框架 + Drizzle ORM,并结合 Deno KV 与 Edge Cache 多级缓存,让读取与写入都拥有极佳性能; 36 | - 内置 PWA 与 IndexedDB 支持,让你在断网下依然可以编辑、保存与预览; 37 | - 可自由设置访问路径、密码、有效期,保护隐私的同时实现灵活分享; 38 | - 与传统 PasteBin 相比,QBin 提供了更丰富的编辑能力、多层次安全防护和更高扩展性。 39 | 40 | ## ✨ 项目特性 41 | 42 | - 🚀 **极简存储**:轻松保存文字、代码、图片、音视频等任意类型,一键分享 43 | - 🔒 **安全可控**:支持自定义访问路径和密码保护 44 | - ⏱️ **灵活期限**:可设置存储有效期,数据过期自动删除 45 | - 🌓 **明暗切换**:支持深色 / 浅色 / 跟随系统模式,夜间使用更护眼 46 | - 📱 **PWA 离线**:断网也能编辑、读取本地缓存,随时随地记录与查看 47 | - 🔄 **实时保存**:自动定时保存到本地及远程,减少数据丢失风险 48 | - 🔑 **多种登录**:支持账号密码登录 和 OAuth2(Google、GitHub、Microsoft、自定义) 49 | - ♻️ **多级缓存**:Deno KV、Drizzle ORM、Edge Cache 与 ETag 结合,提升访问速度 50 | - ⚡ **一键部署**:支持 Docker Compose、Deno Deploy 等多种场景,轻松自托管 51 | 52 | ## 🚀 快速使用指南 53 | 54 | 1. 访问已部署的 QBin 链接 (或本地环境) 55 | 2. 输入默认管理员账号密码 56 | 3. 登录后可在“通用 / Code / Markdown”任意编辑器里输入内容或粘贴、拖放上传文件 57 | 4. 设置链接路径、过期时间、密码保护 (可选) 58 | 5. 自动保存并生成分享链接或二维码 59 | 6. 访问链接查看或下载内容 (若有密码则需输入密码) 60 | 61 | 更多详细用法可参考 [使用指南](Docs/document.md)。 62 | 63 | ## 🔧 技术栈 64 | 前端: 65 | - 纯 HTML + JS + CSS (无第三方框架) 66 | - Monaco 代码编辑器 + Cherry Markdown + 通用编辑器 67 | 68 | 后端: 69 | - Deno Oak 框架 70 | - Drizzle ORM库,支持PostgreSQL、 SQLite等数据库 71 | - Deno KV & Edge Cache 多级缓存 + ETag 缓存校验 72 | 73 | 安全与认证: 74 | - JWT + 账号密码 75 | - OAuth2 登录 (Google、GitHub、Microsoft、Custom) 76 | 77 | ## ⚡ 自托管部署 78 | 以下提供了多种一键部署与自定义部署方式。 79 | 80 | ### Docker Compose (推荐) 81 | 82 | ```bash 83 | git clone https://github.com/quick-bin/qbin.git 84 | cd qbin 85 | docker-compose up -d 86 | ``` 87 | 88 | 运行后访问 http://localhost:8000 ,即可开始使用。 89 | (默认管理员账号密码可在 docker-compose.yml 内修改) 90 | 91 | ### 直接使用 Docker 92 | 93 | 默认 SQLite 本地存储: 94 | ```bash 95 | # 拉取最新镜像 96 | docker pull naiher/qbin:latest 97 | 98 | # 启动容器 99 | docker run -d -p 8000:8000 \ 100 | -e JWT_SECRET="your_jwt_secret" \ 101 | -e ADMIN_EMAIL="admin@qbin.github" \ 102 | -e ADMIN_PASSWORD="qbin" \ 103 | -e DB_CLIENT="sqlite" \ 104 | -e ENABLE_ANONYMOUS_ACCESS="1" \ 105 | -v ~/qbin-data:/app/data \ 106 | --name qbin \ 107 | --restart always \ 108 | naiher/qbin 109 | ``` 110 | 111 | 然后访问 http://localhost:8000 即可。 112 | 113 | ### 其他部署方式 114 | 115 | 支持将 QBin 运行在 Deno Deploy、本地 Deno 环境等更多场景。详见[自托管教程](Docs/self-host.md)。 116 | 117 | ## 🚀 TODO 118 | - [ ] 增加存储管理自定义排序功能 119 | - [ ] 增加MySQL存储 120 | - [ ] 增加Cloudflare D1存储 121 | - [ ] 打包为多平台本地程序 122 | - [ ] 实现端到端加密 123 | - [x] 优化编辑器设置面板功能 124 | - [x] 增加后端本地存储(SQLite数据库) 125 | - [x] Code高亮、Markdown、音视频、图片预览 126 | - [x] 本地离线访问 127 | - [x] 个人中心面板 128 | - [x] Docker 部署支持 129 | - [x] 第三方 OAuth2 登录 (Google / GitHub / Microsoft / Custom) 130 | - [x] 多级热 - 冷存储 131 | - [x] 移动端 + 浅色 / 深色 / 跟随系统主题适配 132 | - [x] ETag 协商缓存 + IndexedDB 本地存储 133 | - [x] 自定义存储路径、密码和有效期 134 | - [x] 数据自动本地备份 135 | 136 | ## 🤝 参与贡献 137 | 138 | 如果您对这个项目感兴趣,欢迎参与贡献,也欢迎 "Star" 支持一下 ^_^
139 | 以下为提PR并合并的小伙伴,在此感谢项目中所有的贡献者。 140 | 141 | 142 | 143 | 144 | 147 | 148 |
145 |


146 |
149 |
150 | 151 | 1. Fork 本项目 152 | 2. 创建新分支:`git checkout -b feature/amazing-feature` 153 | 3. 提交更改:`git commit -m "Add amazing feature"` 154 | 4. 推送分支:`git push origin feature/amazing-feature` 155 | 5. 发起 Pull Request,等待合并 156 | 157 | ## ❤ 赞助支持 158 | 159 | 如果 QBin 帮到您或贵团队,欢迎通过[爱发电](https://afdian.com/a/naihe)进行赞助,助力项目持续更新与优化! 160 | 161 | 162 | QBin Sponsor 163 | 164 | 165 | ## 😘 鸣谢 166 | 特此感谢为本项目提供支持与灵感的项目 167 | 168 | - [Cherry Markdown](https://github.com/Tencent/cherry-markdown) 169 | - [Monaco Editor](https://github.com/microsoft/monaco-editor) 170 | - [deno_docker](https://github.com/denoland/deno_docker) 171 | - [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) 172 | - [bin](https://github.com/wantguns/bin) 173 | - [line-md](https://github.com/cyberalien/line-md) 174 | - [excalidraw](https://github.com/excalidraw/excalidraw) 175 | 176 | ## 许可证 177 | 178 | 本项目采用 [GPL-3.0](LICENSE) 协议开源,欢迎自由使用与二次开发。 179 | 让我们共建开放、高效的云上存储与分享新生态! 180 | 181 | 182 | ## Stargazers over time 183 | [![Stargazers over time](https://starchart.cc/Quick-Bin/qbin.svg?variant=adaptive)](https://starchart.cc/Quick-Bin/qbin) 184 | 185 | 186 | 187 | [docker-pulls-link]: https://hub.docker.com/r/naiher/qbin 188 | [docker-pulls-shield]: https://img.shields.io/docker/pulls/naiher/qbin?style=flat-square&logo=docker&labelColor=black 189 | [latest-version-shield]: https://img.shields.io/github/v/release/quick-Bin/qbin?style=flat-square&label=latest%20version&labelColor=black 190 | [latest-version-link]: https://github.com/Quick-Bin/Qbin/releases 191 | [github-stars-shield]: https://img.shields.io/github/stars/quick-bin/qbin?style=flat-square&logo=github&labelColor=black 192 | [github-stars-link]: https://github.com/quick-bin/qbin/stargazers 193 | [github-license-shield]: https://img.shields.io/github/license/quick-bin/qbin?style=flat-square&logo=github&labelColor=black 194 | [github-license-link]: https://github.com/quick-bin/qbin/issues 195 | [deno-link]: https://qbin.me 196 | [deno-shield]: https://img.shields.io/website?down_message=offline&label=Deno&labelColor=black&logo=deno&style=flat-square&up_message=online&url=https%3A%2F%2Fqbin.me 197 | -------------------------------------------------------------------------------- /src/db/repositories/metadataRepository.ts: -------------------------------------------------------------------------------- 1 | import { or, eq, and, gt, sql as dsql, count, isNull } from "drizzle-orm"; 2 | import {KVMeta, Metadata} from "../../utils/types.ts"; 3 | import { IMetadataRepository } from "./IMetadataRepository.ts"; 4 | import { getDb, SupportedDialect } from "../adapters/index.ts"; 5 | import { metadataPg, metadataSqlite } from "../models/metadata.ts"; 6 | import { withRetry } from "../helpers/retry.ts"; 7 | import { get_env } from "../../config/env.ts"; 8 | import { getTimestamp } from "../../utils/common.ts"; 9 | 10 | 11 | const TABLE_MAP = { 12 | postgres: metadataPg, 13 | sqlite: metadataSqlite, 14 | } as const; 15 | 16 | function currentDialect(): SupportedDialect { 17 | return (get_env("DB_CLIENT", "postgres") as SupportedDialect); 18 | } 19 | 20 | export async function createMetadataRepository( 21 | dialect: SupportedDialect = currentDialect(), 22 | ): Promise { 23 | const db = await getDb(dialect); 24 | const table = TABLE_MAP[dialect]; 25 | return new MetadataRepository(db, table); 26 | } 27 | 28 | class MetadataRepository implements IMetadataRepository { 29 | constructor(private db: any, private t: any) {} 30 | 31 | private run(fn: () => Promise) { 32 | return withRetry(fn); 33 | } 34 | 35 | async create(data: Metadata) { 36 | const { fkey: _omit, ...updateSet } = data as any; 37 | const query = await this.run(() => 38 | this.db 39 | .insert(this.t) 40 | .values(data) 41 | .onConflictDoUpdate({ 42 | target: this.t.fkey, 43 | set: updateSet, 44 | }) 45 | .execute(), 46 | ); 47 | return (query.rowCount || query.rowsAffected) > 0; 48 | } 49 | 50 | async getByFkey(fkey: string) { 51 | const r = await this.run(() => 52 | this.db.select().from(this.t).where(eq(this.t.fkey, fkey)).limit(1) 53 | .execute()); 54 | return r.length ? (r[0] as Metadata) : null; 55 | } 56 | 57 | async list(limit = 10, offset = 0) { 58 | return await this.run(() => 59 | this.db.select().from(this.t) 60 | .orderBy(dsql`${this.t.time} DESC`).limit(limit).offset(offset) 61 | .execute()) as Metadata[]; 62 | } 63 | 64 | async update(fkey: string, patch: Partial) { 65 | if (!Object.keys(patch).length) return false; 66 | const upsertRow = { ...(patch as Metadata), fkey }; 67 | const { fkey: _omit, ...updateSet } = upsertRow as any; 68 | 69 | const query = await this.run(() => 70 | this.db 71 | .insert(this.t) 72 | .values(upsertRow) 73 | .onConflictDoUpdate({ 74 | target: this.t.fkey, 75 | set: updateSet, 76 | }) 77 | .execute(), 78 | ); 79 | return (query.rowCount || query.rowsAffected) > 0; 80 | } 81 | 82 | async delete(fkey: string) { 83 | const query = await this.run(() => 84 | this.db.delete(this.t).where(eq(this.t.fkey, fkey)).execute()); 85 | return (query.rowCount || query.rowsAffected) > 0; 86 | } 87 | 88 | async findByMime(mime: string) { 89 | return await this.run(() => 90 | this.db.select().from(this.t).where(eq(this.t.mime, mime)) 91 | .orderBy(dsql`${this.t.time} DESC`).execute()) as Metadata[]; 92 | } 93 | 94 | async getActiveMetas(): Promise { 95 | const now = getTimestamp(); 96 | return await this.run(() => 97 | this.db.select({ 98 | fkey: this.t.fkey, 99 | email: this.t.email, 100 | title: this.t.title, 101 | uname: this.t.uname, 102 | ip: this.t.ip, 103 | len: this.t.len, 104 | expire: this.t.expire, 105 | hash: this.t.hash, 106 | pwd: this.t.pwd, 107 | }) 108 | .from(this.t) 109 | .where( 110 | or(isNull(this.t.expire), gt(this.t.expire, now)), 111 | ) 112 | .execute() 113 | ); 114 | } 115 | 116 | /** 获取邮箱全部 fkey */ 117 | async getByEmailAllFkeys(email: string) { 118 | const rows = await this.run(() => 119 | this.db.select({ fkey: this.t.fkey }).from(this.t) 120 | .where(eq(this.t.email, email)).execute()); 121 | return rows.map((r: { fkey: string }) => r.fkey); 122 | } 123 | 124 | /** 普通用户分页查询 */ 125 | async paginateByEmail(email: string, limit = 10, offset = 0) { 126 | const now = getTimestamp(); 127 | 128 | const [{ total }] = await this.run(() => 129 | this.db.select({ total: count() }).from(this.t) 130 | .where(and(eq(this.t.email, email), gt(this.t.expire, now))) 131 | .execute()) as [{ total: bigint | number }]; 132 | const totalNumber = Number(total ?? 0); 133 | if (offset >= totalNumber) return { items: [], total: totalNumber }; 134 | 135 | const items = await this.run(() => 136 | this.db.select({ 137 | fkey: this.t.fkey, 138 | title: this.t.title, 139 | time: this.t.time, 140 | expire: this.t.expire, 141 | ip: this.t.ip, 142 | mime: this.t.mime, 143 | len: this.t.len, 144 | pwd: this.t.pwd, 145 | email: this.t.email, 146 | uname: this.t.uname, 147 | hash: this.t.hash 148 | }).from(this.t) 149 | .where(and(eq(this.t.email, email), gt(this.t.expire, now))) 150 | .orderBy(dsql`${this.t.time} DESC`).limit(limit).offset(offset) 151 | .execute()) as Omit[]; 152 | 153 | return { items, total: totalNumber }; 154 | } 155 | 156 | /** 管理员查看全部未过期数据 */ 157 | async listAlive(limit = 10, offset = 0) { 158 | const now = getTimestamp(); 159 | 160 | const [{ total }] = await this.run(() => 161 | this.db.select({ total: count() }).from(this.t) 162 | .where(gt(this.t.expire, now)).execute()) as [{ total: bigint | number }]; 163 | const totalNumber = Number(total ?? 0); 164 | if (offset >= totalNumber) return { items: [], total: totalNumber }; 165 | 166 | const items = await this.run(() => 167 | this.db.select({ 168 | fkey: this.t.fkey, 169 | title: this.t.title, 170 | time: this.t.time, 171 | expire: this.t.expire, 172 | ip: this.t.ip, 173 | mime: this.t.mime, 174 | len: this.t.len, 175 | pwd: this.t.pwd, 176 | email: this.t.email, 177 | uname: this.t.uname, 178 | hash: this.t.hash 179 | }).from(this.t) 180 | .where(gt(this.t.expire, now)) 181 | .orderBy(dsql`${this.t.time} DESC`).limit(limit).offset(offset) 182 | .execute()) as Omit[]; 183 | 184 | return { items, total: totalNumber }; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |
2 |

QBin · Quick Storage

3 | 4 | QBin LOGO 5 | 6 | > ✨ A lightweight Cloud Note & PasteBin alternative. Save text, code, images, videos, and any content with just one click for easier sharing! 7 | 8 | [English] · [**简体中文**](README.md) · [Demo Website](https://qbin.me) · [Documentation](Docs/document.md) · [Self-hosting Guide](Docs/self-host.md) · [REST API](Docs/REST%20API.md) 9 |
10 | 11 | 12 | ## 🖼️ Feature Preview 13 | Mobile 14 | --- 15 | ![Mobile photos](https://s3.tebi.io/lite/mobile-preview.jpg) 16 | 17 | Windows 18 | ---- 19 | 20 | ![Windows photos](https://s3.tebi.io/lite/windows-preview.jpg) 21 | 22 | 23 | ## 📝 Project Introduction 24 | 25 | QBin focuses on "fast, secure, and lightweight" online editing and content sharing, suitable for personal notes, temporary storage, team collaboration, cross-platform sharing, and many other scenarios. 26 | - Frontend uses pure HTML+JS+CSS without heavy frameworks, featuring Monaco code editor, Cherry Markdown renderer, and a universal editor for various content types. 27 | - Backend uses Deno Oak framework + PostgreSQL database, combined with Deno KV and Edge Cache for multi-level caching, providing excellent performance for both reading and writing. 28 | - Built-in PWA and IndexedDB support lets you edit, save, and preview even when offline. 29 | - Freely set access paths, passwords, and expiration dates for flexible sharing while protecting privacy. 30 | - Compared to traditional PasteBin services, QBin offers richer editing capabilities, multi-layered security, and higher extensibility. 31 | 32 | ## ✨ Project Features 33 | 34 | - 🚀 **Simple Storage**: Easily save text, code, images, audio/video, and other content with one-click sharing 35 | - 🔒 **Secure Control**: Support for custom access paths and password protection 36 | - ⏱️ **Flexible Expiration**: Set storage validity periods with automatic deletion of expired data 37 | - 🌓 **Light/Dark Mode**: Support for dark/light/system theme for comfortable viewing day or night 38 | - 📱 **PWA Offline**: Edit and access local cache without internet, take notes anytime, anywhere 39 | - 🔄 **Real-time Saving**: Automatic periodic saving to local and remote storage to prevent data loss 40 | - 🔑 **Multiple Logins**: Support for username/password and OAuth2 (Google, GitHub, Microsoft, custom) 41 | - ♻️ **Multi-level Cache**: Combining Deno KV, PostgreSQL, Edge Cache, and ETag for faster access 42 | - ⚡ **One-click Deploy**: Support for Docker Compose, Deno Deploy, and more for easy self-hosting 43 | 44 | ## 🚀 Quick Start Guide 45 | 46 | 1. Visit a deployed QBin link (or local environment) 47 | 2. Enter the default admin username and password 48 | 3. After logging in, enter content or paste/drag-and-drop files in any editor (General/Code/Markdown) 49 | 4. Set link path, expiration time, password protection (optional) 50 | 5. Content is automatically saved and sharing links or QR codes are generated 51 | 6. Visit the link to view or download content (password required if set) 52 | 53 | For more detailed usage, please refer to the [User Guide](Docs/document.md). 54 | 55 | ## 🔧 Technology Stack 56 | Frontend: 57 | - Pure HTML + JS + CSS (no third-party frameworks) 58 | - Monaco code editor + Cherry Markdown + Universal editor 59 | 60 | Backend: 61 | - Deno Oak framework 62 | - PostgreSQL database 63 | - Deno KV & Edge Cache multi-level caching + ETag cache validation 64 | 65 | Security and Authentication: 66 | - JWT + username/password 67 | - OAuth2 login (Google, GitHub, Microsoft, Custom) 68 | 69 | ## ⚡ Self-hosting Deployment 70 | Several deployment methods are provided below. 71 | 72 | ### Docker Compose (Recommended) 73 | 74 | ```bash 75 | git clone https://github.com/Quick-Bin/qbin.git 76 | cd qbin 77 | docker-compose up -d 78 | ``` 79 | 80 | After running, visit http://localhost:8000 to start using. 81 | (Default admin account and password can be modified in docker-compose.yml) 82 | 83 | ### Using Docker Directly 84 | 85 | Suitable for environments with existing PostgreSQL: 86 | ```bash 87 | # Pull the latest image 88 | docker pull naiher/qbin:latest 89 | 90 | # Start the container 91 | docker run -it -p 8000:8000 \ 92 | -e DATABASE_URL="postgresql://user:password@host:5432/dbname" \ 93 | -e JWT_SECRET="your_jwt_secret" \ 94 | -e ADMIN_PASSWORD="qbin" \ 95 | -e ADMIN_EMAIL="admin@qbin.github" \ 96 | naiher/qbin 97 | ``` 98 | 99 | Then visit http://localhost:8000. 100 | > Tip: You can use free PostgreSQL from [Neon](https://neon.tech/), [Aiven](https://aiven.io/), or [Render](https://render.com/docs/deploy-mysql). 101 | 102 | ### Other Deployment Methods 103 | 104 | QBin can run on Deno Deploy, local Deno environments, and other platforms. See [Self-hosting Guide](Docs/self-host.md) for details. 105 | 106 | ## 🚀 TODO 107 | - [ ] Implement end-to-end encryption 108 | - [x] Markdown, audio/video, image preview 109 | - [x] Personal dashboard 110 | - [x] Docker deployment support 111 | - [x] Third-party OAuth2 login (Google / GitHub / Microsoft / Custom) 112 | - [x] Multi-level hot-cold storage 113 | - [x] Mobile + light/dark/system theme adaptation 114 | - [x] ETag cache + IndexedDB local storage 115 | - [x] Custom storage path, password, and expiration 116 | - [x] Automatic local data backup 117 | 118 | ## 🤝 How to Contribute 119 | 120 | 1. Fork this project 121 | 2. Create a new branch: `git checkout -b feature/amazing-feature` 122 | 3. Commit your changes: `git commit -m "Add amazing feature"` 123 | 4. Push to the branch: `git push origin feature/amazing-feature` 124 | 5. Create a Pull Request and wait for it to be merged 125 | 126 | ## ❤ Sponsorship Support 127 | 128 | If QBin has helped you or your team, please consider sponsoring through [Afdian](https://afdian.com/a/naihe) to help the project continue to update and improve! 129 | 130 | 131 | QBin Sponsor 132 | 133 | 134 | ## 😘 Acknowledgments 135 | Special thanks to the projects that provided support and inspiration: 136 | 137 | - [Cherry Markdown](https://github.com/Tencent/cherry-markdown) 138 | - [Monaco Editor](https://github.com/microsoft/monaco-editor) 139 | - [deno_docker](https://github.com/denoland/deno_docker) 140 | - [bin](https://github.com/wantguns/bin) 141 | - [excalidraw](https://github.com/excalidraw/excalidraw) 142 | 143 | ## License 144 | 145 | This project is open-source under the [GPL-3.0](LICENSE) license. Feel free to use and develop it further. 146 | Let's build an open and efficient cloud storage and sharing ecosystem together! 147 | -------------------------------------------------------------------------------- /Docs/self-host.md: -------------------------------------------------------------------------------- 1 | ## QBin 自托管部署教程 2 | 3 | 本文将帮助您快速部署 QBin 服务,分别提供三种灵活的部署方式,适合不同的使用场景和技术偏好。 4 | 5 | ## ⚡ 部署方式选择 6 | 7 | | 部署方式 | 适用场景 | 难度 | 稳定性 | 8 | |:--------------:|:---------------:|:---:|:---:| 9 | | Docker Compose | 本地或服务器部署,适合生产环境 | 很简单 | 高 | 10 | | Docker | 快速测试或简单部署 | 很简单 | 高 | 11 | | Deno Deploy | 无需服务器,快速云端部署 | 很简单 | 高 | 12 | | Deno CIL | 本地开发环境和调试测试 | 简单 | 中 | 13 | 14 | ## 🐳 Docker Compose 一键部署(推荐) 15 | 16 | 最简单的部署方式,一键完成环境配置和应用启动: 17 | 18 | ```bash 19 | # 克隆项目仓库 20 | git clone https://github.com/Quick-Bin/qbin.git 21 | 22 | # 进入项目目录 23 | cd qbin 24 | 25 | # 启动服务 26 | docker-compose up -d 27 | ``` 28 | 29 | 完成后,访问 `http://localhost:8000` 即可使用 QBin 服务,所有配置已在 docker-compose.yml 中预设好。 30 | 31 | ## 🐋 Docker 部署 32 | 33 | ```bash 34 | # 拉取最新镜像 35 | docker pull naiher/qbin:latest 36 | 37 | # 启动容器 38 | docker run -it -p 8000:8000 \ 39 | -e JWT_SECRET="your_jwt_secret" \ 40 | -e ADMIN_PASSWORD="qbin" \ 41 | -e ADMIN_EMAIL="admin@qbin.github" \ 42 | -e DB_CLIENT="sqlite" \ 43 | -e ENABLE_ANONYMOUS_ACCESS="1" \ 44 | -v ~/qbin-data:/app/data \ 45 | naiher/qbin 46 | ``` 47 | 48 | 启动后,访问 `http://localhost:8000` 即可使用 QBin 服务。 49 | 50 | ## ☁️ Deno Deploy 云端部署 51 | 52 | 无需服务器,快速部署到 Deno 云平台: 53 | 54 | 1. 准备一个 PostgreSQL 数据库 55 | 2. [Fork QBin](https://github.com/Quick-Bin/Qbin/fork) 项目仓库 56 | 3. 登录/注册 [Deno Deploy](https://dash.deno.com) 57 | 4. 创建新项目:https://dash.deno.com/new_project 58 | 5. 选择您 Fork 的项目,填写项目名称(关系到自动分配的域名) 59 | 6. Entrypoint 填写 `index.ts`,其他字段留空 60 | 7. 配置环境变量(详见下方环境变量说明) 61 | 8. 点击 Deploy Project 62 | 9. 部署成功后,点击生成的域名即可使用 63 | 10. 部署完成后配置环境变量: 64 | - 在 Project 的 Settings 中找到 Environment Variables 65 | - 点击 Add Variable 添加必要的环境变量 66 | 环境变量设置 67 | 11. 自定义域名(可选): 68 | - 在 Project 的 Settings 中设置自定义二级域名或绑定自己的域名 69 | 自定义域名 70 | 71 | > **注意**:使用 Deno 部署需要提前准备好 PostgreSQL 数据库。 72 | 73 | ## 🛢 数据库准备 74 | 75 | Deno Deploy部署方式需要手动创建 PostgreSQL 数据库。如果你没有,那么可以使用以下几个提供免费方案的服务商: 76 | 77 | | 服务商 | 免费方案 | 特点 | 78 | |:-----:|:-------:|:----:| 79 | | [Render](https://render.com/docs/deploy-mysql) | 免费 10 GB 空间 | 与 Render 应用集成方便 | 80 | | [Aiven](https://aiven.io/) | 免费 5 GB 空间 | 稳定可靠,简单易用 | 81 | | [Neon](https://neon.tech/) | 免费 0.5 GB 空间 | 弹性扩展,零停机时间,开发者友好 | 82 | 83 | 84 | ## 🖥️ Deno CIL 本地部署 85 | 86 | 适合开发环境和本地测试,快速启动和调试: 87 | 88 | **Windows PowerShell 安装 Deno:** 89 | ```bash 90 | irm https://deno.land/install.ps1 | iex 91 | ``` 92 | 93 | **Mac/Linux 安装 Deno:** 94 | ```bash 95 | curl -fsSL https://deno.land/install.sh | sh 96 | ``` 97 | 98 | **克隆项目:** 99 | ```bash 100 | # 克隆项目仓库 101 | git clone https://github.com/Quick-Bin/qbin.git 102 | 103 | # 进入项目目录 104 | cd qbin 105 | ``` 106 | 107 | **启动项目:** 108 | ```bash 109 | deno run -NER --allow-ffi --allow-sys --unstable-kv --unstable-broadcast-channel index.ts 110 | ``` 111 | 112 | ### 环境变量配置 113 | 114 | 在项目根目录 将`.env.template` 重命名为 `.env` 文件,并设置必要的环境变量(参考环境变量配置说明): 115 | 116 | ``` 117 | ENABLE_ANONYMOUS_ACCESS=1 # 匿名访问,默认开启,0表示关闭, 支持共享编辑 118 | ADMIN_EMAIL=admin@qbin.github # 管理员邮箱(必选) 119 | ADMIN_PASSWORD=qbin # 管理员密码(必选) 120 | DB_CLIENT=postgres # 选择数据库,支持 postgres、sqlite 121 | DATABASE_URL=postgresql://user:password@host:5432/database 122 | JWT_SECRET=your_jwt_secret # JWT密钥,用于加密验证(建议修改) 123 | ``` 124 | 125 | 完成部署后,访问 `http://localhost:8000` 即可使用 QBin 服务。 126 | 127 | ## ⚙️ 环境变量配置说明 128 | 129 | ### 基础配置 130 | 131 | | 环境变量 | 类型 | 描述 | 示例 | 132 | |:-------------------------:|:----:|:----:|:-----------------------------------------------:| 133 | | `ADMIN_PASSWORD` | 必选 | 管理员访问密码 | `qbin` | 134 | | `ADMIN_EMAIL` | 必选 | 管理员邮箱地址 | `admin@qbin.github` | 135 | | `DATABASE_URL` | 必选 | PostgreSQL 数据库连接 URL | `postgresql://user:password@host:5432/database` | 136 | | `JWT_SECRET` | 必选 | JWT 签名密钥(建议使用随机字符串) | `XTV0STZzYFxxxxxxxxxx5ecm50W04v` | 137 | | `PORT` | 可选 | 服务访问端口,默认 8000 | `8000` | 138 | | `ENABLE_ANONYMOUS_ACCESS` | 可选 | 匿名访问, 支持共享编辑 | `1` | 139 | | `TOKEN_EXPIRE` | 可选 | 令牌有效期(秒),默认一年 | `31536000` | 140 | | `MAX_UPLOAD_FILE_SIZE` | 可选 | 最大上传文件大小(字节),默认 50MB | `52428800` | 141 | | `DENO_KV_PROJECT_ID` | 可选 | Deno KV 项目 ID,默认为空 | `xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | 142 | | `DENO_KV_ACCESS_TOKEN` | 可选 | Deno KV 项目访问令牌,默认为空 | `` | 143 | 144 | ### 社交登录配置(可选) 145 | 146 | #### GitHub OAuth 配置 147 | 148 | | 环境变量 | 类型 | 描述 | 示例 | 149 | |:-------:|:----:|:----:|:----:| 150 | | `GITHUB_CLIENT_ID` | 可选 | GitHub OAuth 应用客户端 ID | `Ovxxxxxxxxxfle8Oyi` | 151 | | `GITHUB_CLIENT_SECRET` | 可选 | GitHub OAuth 应用客户端密钥 | `56ab9xxxxxxxxxxxxxx4012184b426` | 152 | | `GITHUB_CALLBACK_URL` | 可选 | GitHub OAuth 回调地址 | `http://localhost:8000/api/login/oauth2/callback/github` | 153 | 154 | #### Google OAuth 配置 155 | 156 | | 环境变量 | 类型 | 描述 | 示例 | 157 | |:-------:|:----:|:----:|:----:| 158 | | `GOOGLE_CLIENT_ID` | 可选 | Google OAuth 应用客户端 ID | `84932xxxxx-gbxxxxxxxxxxxxjg8s3v.apps.googleusercontent.com` | 159 | | `GOOGLE_CLIENT_SECRET` | 可选 | Google OAuth 应用客户端密钥 | `GOCSPX-xxxxxxxxxxxxxxxxxxxx` | 160 | | `GOOGLE_CALLBACK_URL` | 可选 | Google OAuth 回调地址 | `http://localhost:8000/api/login/oauth2/callback/google` | 161 | 162 | #### Microsoft OAuth 配置 163 | 164 | | 环境变量 | 类型 | 描述 | 示例 | 165 | |:-------:|:----:|:----:|:----:| 166 | | `MICROSOFT_CLIENT_ID` | 可选 | Microsoft OAuth 应用客户端 ID | `a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6` | 167 | | `MICROSOFT_CLIENT_SECRET` | 可选 | Microsoft OAuth 应用客户端密钥 | `abC8Q~xxxxxxxxxxxxxxxxxxxxxxxxxxxx` | 168 | | `MICROSOFT_CALLBACK_URL` | 可选 | Microsoft OAuth 回调地址 | `http://localhost:8000/api/login/oauth2/callback/microsoft` | 169 | 170 | #### Custom OAuth 配置 171 | 172 | | 环境变量 | 类型 | 描述 | 示例 | 173 | |:-------:|:----:|:----:|:----:| 174 | | `OAUTH_CLIENT_ID` | 可选 | OAuth 应用的客户端标识符 | `V4HmbQixxxxxxxxxxxxCJ2CVypqL` | 175 | | `OAUTH_CLIENT_SECRET` | 可选 | OAuth 应用的客户端密钥 | `hZtE3cxxxxxxxxxxxxxkZ0al01Hi` | 176 | | `OAUTH_AUTH_URL` | 可选 | 授权端点 URL | `https://provider.example.com/oauth2/authorize` | 177 | | `OAUTH_TOKEN_URL` | 可选 | 令牌端点 URL | `https://provider.example.com/oauth2/token` | 178 | | `OAUTH_CALLBACK_URL` | 可选 | 认证成功后的回调地址 | `http://localhost:8000/api/login/oauth2/callback/custom` | 179 | | `OAUTH_SCOPES` | 可选 | 请求的权限范围,以空格分隔 | `user:profile` | 180 | | `OAUTH_USER_INFO_URL` | 可选 | 获取用户信息的 API 端点 | `https://provider.example.com/api/user` | 181 | 182 | ## 🔧 常见问题与故障排除 183 | 184 | 1. **数据库连接失败** 185 | - 检查 DATABASE_URL 格式是否正确 186 | - 确认数据库服务是否已启动 187 | - 验证用户名密码是否正确 188 | - 检查防火墙是否允许连接 189 | 190 | 2. **部署成功但无法访问** 191 | - 检查端口是否被其他程序占用 192 | - 确认防火墙是否允许该端口访问 193 | - 检查 Deno Deploy 日志查看错误信息 194 | 195 | 3. **社交登录无法使用** 196 | - 确认 OAuth 配置信息是否正确 197 | - 检查回调 URL 是否与应用配置一致 198 | - 确认已在对应平台启用了正确的 OAuth 权限 199 | 200 | ## 📚 更多信息 201 | 202 | 有关本项目的更多详细说明、API 文档和高级配置,请参考 [完整文档](https://github.com/Quick-Bin/Qbin/blob/main/README.md)。 203 | 204 | 如有任何问题,欢迎 [提交 Issue](https://github.com/Quick-Bin/Qbin/issues) 。 -------------------------------------------------------------------------------- /static/templates/render.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | QBin - 内容预览 5 | 6 | 7 | 8 | 9 | 10 | 11 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 |
63 | 69 | 84 | 89 |
90 |
91 | 92 |

访问内容有密码保护

93 |
94 | 101 | 107 |
108 |
109 |
110 |
111 | 112 | -------------------------------------------------------------------------------- /src/utils/render.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 模板渲染 / 读取工具 3 | */ 4 | import {join} from "https://deno.land/std/path/mod.ts"; 5 | import {basePath} from "../config/constants.ts"; 6 | import {getMime} from "../utils/types.ts"; 7 | import {cyrb53_str} from "./common.ts"; 8 | 9 | 10 | export async function getJS(ctx, pathname, status = 200): Promise { 11 | try { 12 | ctx.response.body = await Deno.readTextFile(join(basePath, `/static/js/${pathname}`)); 13 | ctx.response.status = status; 14 | ctx.response.headers.set("Content-Type", "application/javascript"); 15 | // ctx.response.headers.set("Cache-Control", "public, max-age=86400, immutable"); 16 | const hash = cyrb53_str(`${pathname}-${ctx.response.body.length}`); 17 | ctx.state.metadata = {etag: hash}; 18 | } catch (error) { 19 | } 20 | } 21 | 22 | export async function getCSS(ctx, pathname, status = 200): Promise { 23 | try { 24 | ctx.response.body = await Deno.readTextFile(join(basePath, `/static/css/${pathname}`)); 25 | ctx.response.status = status; 26 | ctx.response.headers.set("Content-Type", "text/css"); 27 | // ctx.response.headers.set("Cache-Control", "public, max-age=86400, immutable"); 28 | const hash = cyrb53_str(`${pathname}-${ctx.response.body.length}`); 29 | ctx.state.metadata = {etag: hash}; 30 | } catch (error) { 31 | } 32 | } 33 | 34 | export async function getIMG(ctx, pathname, status = 200): Promise { 35 | try { 36 | const extension = pathname.split('.').pop()?.toLowerCase() || ''; 37 | const contentType = getMime(extension) || 'application/octet-stream'; 38 | ctx.response.body = await Deno.readFile(join(basePath, `/static/img/${pathname}`)); 39 | ctx.response.status = status; 40 | 41 | ctx.response.headers.set("Content-Type", contentType); 42 | ctx.response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); 43 | const hash = cyrb53_str(`${pathname}-${ctx.response.body.length}`); 44 | ctx.state.metadata = {etag: hash}; 45 | } catch (error) { 46 | } 47 | } 48 | 49 | export async function getFONTS(ctx, pathname, status = 200): Promise { 50 | try { 51 | const extension = pathname.split('.').pop()?.toLowerCase() || ''; 52 | const contentType = getMime(extension) || 'application/octet-stream'; 53 | ctx.response.body = await Deno.readFile(join(basePath, `/static/css/fonts/${pathname}`)); 54 | ctx.response.status = status; 55 | 56 | ctx.response.headers.set("Content-Type", contentType); 57 | ctx.response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); 58 | const hash = cyrb53_str(`${pathname}-${ctx.response.body.length}`); 59 | ctx.state.metadata = {etag: hash}; 60 | } catch (error) { 61 | } 62 | } 63 | 64 | export async function getRenderHtml(ctx, status = 200): Promise { 65 | ctx.response.status = status; 66 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 67 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); // public, max-age=3600 68 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/render.html')); 69 | const hash = cyrb53_str('render.html' + ctx.response.body.length); 70 | ctx.state.metadata = {etag: hash}; 71 | } 72 | 73 | export async function getEditHtml(ctx, status = 200): Promise { 74 | ctx.response.status = status; 75 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 76 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 77 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/multi-editor.html')); 78 | const hash = cyrb53_str("multi-editor.html" + ctx.response.body.length); 79 | ctx.state.metadata = {etag: hash}; 80 | } 81 | 82 | export async function getCodeEditHtml(ctx, status = 200): Promise { 83 | ctx.response.status = status; 84 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 85 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 86 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/code-editor.html')); 87 | const hash = cyrb53_str("code-editor.html" + ctx.response.body.length); 88 | ctx.state.metadata = {etag: hash}; 89 | } 90 | 91 | export async function getMDEditHtml(ctx, status = 200): Promise { 92 | ctx.response.status = status; 93 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 94 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 95 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/md-editor.html')); 96 | const hash = cyrb53_str("md-editor.html" + ctx.response.body.length); 97 | ctx.state.metadata = {etag: hash}; 98 | } 99 | 100 | export async function getLoginPageHtml(ctx, status = 200): Promise { 101 | ctx.response.status = status; 102 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 103 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 104 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/login.html')); 105 | const hash = cyrb53_str('login.html' + ctx.response.body.length); 106 | ctx.state.metadata = {etag: hash}; 107 | } 108 | 109 | export async function getDocumentHtml(ctx, status = 200): Promise { 110 | ctx.response.status = status; 111 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 112 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 113 | ctx.response.body = await Deno.readTextFile(join(basePath, './Docs/document.md')); 114 | const hash = cyrb53_str("document.md" + ctx.response.body.length); 115 | ctx.state.metadata = {etag: hash}; 116 | } 117 | 118 | export async function getFavicon(ctx, status = 200): Promise { 119 | ctx.response.status = status; 120 | ctx.response.headers.set("Content-Type", "image/svg+xml"); 121 | ctx.response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); 122 | ctx.response.body = await Deno.readFile(join(basePath, './static/img/favicon.svg')); 123 | } 124 | 125 | export async function getHomeHtml(ctx, status = 200): Promise { 126 | ctx.response.status = status; 127 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 128 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 129 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/home.html')); 130 | const hash = cyrb53_str("home.html" + ctx.response.body.length); 131 | ctx.state.metadata = {etag: hash}; 132 | } 133 | 134 | export async function getPWALoaderHtml(ctx, status = 200): Promise { 135 | ctx.response.status = status; 136 | ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 137 | // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 138 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/pwa-loader.html')); 139 | const hash = cyrb53_str('pwa-loader.html' + ctx.response.body.length); 140 | ctx.state.metadata = {etag: hash}; 141 | } 142 | 143 | // PWA - Service Worker 144 | export async function getServiceWorker(ctx, status = 200): Promise { 145 | ctx.response.status = status; 146 | ctx.response.headers.set("Content-Type", "application/javascript"); 147 | ctx.response.headers.set("Cache-Control", "no-cache, must-revalidate"); 148 | ctx.response.headers.set("Service-Worker-Allowed", "/"); 149 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/js/service-worker.js')); 150 | const hash = cyrb53_str('service-worker.js' + ctx.response.body.length); 151 | ctx.state.metadata = {etag: hash}; 152 | } 153 | 154 | // PWA - Manifest 155 | export async function getManifest(ctx, status = 200): Promise { 156 | ctx.response.status = status; 157 | ctx.response.headers.set("Content-Type", "application/json"); 158 | ctx.response.headers.set("Cache-Control", "public, max-age=86400"); 159 | ctx.response.body = await Deno.readTextFile(join(basePath, './static/manifest.json')); 160 | const hash = cyrb53_str('manifest.json' + ctx.response.body.length); 161 | ctx.state.metadata = {etag: hash}; 162 | } 163 | 164 | 165 | // // 路径格式错误网页 166 | // export async function getPathErrorHtml(ctx, status=200): Promise { 167 | // ctx.response.status = status; 168 | // ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 169 | // // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 170 | // ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/error.html')); 171 | // const hash = cyrb53_str('error.html' + ctx.response.body.length); 172 | // ctx.state.metadata = { etag: hash }; 173 | // } 174 | // 175 | // // 密码保存内容网页 176 | // export async function getPassWordHtml(ctx, status=200): Promise { 177 | // ctx.response.status = status; 178 | // ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); 179 | // // ctx.response.headers.set("Cache-Control", "public, max-age=300"); 180 | // ctx.response.body = await Deno.readTextFile(join(basePath, './static/templates/password.html')); 181 | // const hash = cyrb53_str('password.html' + ctx.response.body.length); 182 | // ctx.state.metadata = { etag: hash }; 183 | // } 184 | -------------------------------------------------------------------------------- /src/controllers/paste.controller.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "https://deno.land/x/oak/mod.ts"; 2 | import { 3 | getCachedContent, 4 | isCached, 5 | updateCache, 6 | kv, 7 | cacheBroadcast, 8 | deleteCache, 9 | } from "../utils/cache.ts"; 10 | import { 11 | getTimestamp, 12 | cyrb53, 13 | } from "../utils/common.ts"; 14 | import { Response } from "../utils/response.ts"; 15 | import { ResponseMessages } from "../utils/messages.ts"; 16 | import { checkPassword, parsePathParams } from "../utils/validator.ts"; 17 | import { 18 | PASTE_STORE, 19 | MAX_UPLOAD_FILE_SIZE, 20 | mimeTypeRegex, 21 | reservedPaths, 22 | } from "../config/constants.ts"; 23 | import { createMetadataRepository } from "../db/db.ts"; 24 | import { Metadata, AppState } from "../utils/types.ts"; 25 | 26 | /** GET /r/:key/:pwd? 原始内容输出 */ 27 | export async function getRaw(ctx: Context) { 28 | const { key, pwd } = parsePathParams(ctx.params); 29 | const repo = await createMetadataRepository(); 30 | 31 | // 只查 meta,作用:无权限时提前返回 403/404 32 | const meta = await isCached(key, pwd, repo); 33 | if (meta?.email === undefined || (meta.expire ?? 0) < getTimestamp()) { 34 | throw new Response(ctx, 404, ResponseMessages.CONTENT_NOT_FOUND); 35 | } 36 | if (meta.pwd && !checkPassword(meta.pwd, pwd)) { 37 | throw new Response(ctx, 403, ResponseMessages.PASSWORD_INCORRECT); 38 | } 39 | 40 | const full = await getCachedContent(key, pwd, repo); 41 | if (!(full && "content" in full)) { 42 | throw new Response(ctx, 404, ResponseMessages.CONTENT_NOT_FOUND); 43 | } 44 | 45 | ctx.state.metadata = { etag: full.hash, time: full.time }; 46 | ctx.response.headers.set("Pragma", "no-cache"); 47 | ctx.response.headers.set("Cache-Control", "no-cache, must-revalidate"); // private , must-revalidate | , max-age=3600 48 | ctx.response.headers.set("Content-Type", full.mime); 49 | ctx.response.headers.set("Content-Length", full.len.toString()); 50 | if(full.title) ctx.response.headers.set("Content-Disposition", `inline; filename="${full.title}"`); 51 | ctx.response.body = full.content; 52 | } 53 | 54 | export async function queryRaw(ctx: Context) { 55 | const { key, pwd } = parsePathParams(ctx.params); 56 | const repo = await createMetadataRepository(); 57 | 58 | // 只查 meta,作用:无权限时提前返回 403/404 59 | const meta = await isCached(key, pwd, repo); 60 | if (meta?.email === undefined || (meta.expire ?? 0) < getTimestamp()) { 61 | throw new Response(ctx, 404, ResponseMessages.CONTENT_NOT_FOUND); 62 | } 63 | if (meta.pwd && !checkPassword(meta.pwd, pwd)) { 64 | throw new Response(ctx, 403, ResponseMessages.PASSWORD_INCORRECT); 65 | } 66 | 67 | ctx.response.status = 200; 68 | ctx.response.headers.set("Content-Type", meta.mime); 69 | ctx.response.headers.set("Content-Length", meta.len.toString()); 70 | if(meta.title) ctx.response.headers.set("Content-Disposition", `inline; filename="${meta.title}"`); 71 | } 72 | 73 | /** POST/PUT /save/:key/:pwd? 统一入口:若 key 已存在则更新,否则创建 */ 74 | export async function save(ctx: Context) { 75 | const { key, pwd } = parsePathParams(ctx.params, 'save'); 76 | if (reservedPaths.has(key)) throw new Response(ctx, 403, ResponseMessages.PATH_RESERVED); 77 | 78 | const repo = await createMetadataRepository(); 79 | const meta = await isCached(key, pwd, repo); 80 | 81 | return meta && "email" in meta 82 | ? await updateExisting(ctx, key, pwd, meta, repo) 83 | : await createNew(ctx, key, pwd, repo); 84 | } 85 | 86 | /** DELETE /delete/:key/:pwd? */ 87 | export async function remove(ctx: Context) { 88 | const { key, pwd } = parsePathParams(ctx.params); 89 | if (reservedPaths.has(key)) throw new Response(ctx, 403, ResponseMessages.PATH_RESERVED); 90 | const repo = await createMetadataRepository(); 91 | const meta = await isCached(key, pwd, repo); 92 | if (!meta || (meta.expire ?? 0) < getTimestamp()) throw new Response(ctx, 404, ResponseMessages.CONTENT_NOT_FOUND); 93 | if (!checkPassword(meta.pwd, pwd)) throw new Response(ctx, 403, ResponseMessages.PASSWORD_INCORRECT); 94 | const email = ctx.state.session?.get("user")?.email; 95 | if (email !== meta.email) throw new Response(ctx, 403, ResponseMessages.PERMISSION_DENIED); 96 | 97 | delete meta.content; 98 | await deleteCache(key, meta); 99 | queueMicrotask(async () => { 100 | await kv.delete([PASTE_STORE, key]) 101 | await repo.delete(key); 102 | }); 103 | cacheBroadcast.postMessage({ type: "delete", key, metadata: meta }); 104 | 105 | // TODO 回收站 定时清理 106 | // if (meta.expire > 0) meta.expire = -meta.expire; 107 | // await updateCache(key, meta) 108 | // cacheBroadcast.postMessage({ type: "update", key, metadata: meta }); 109 | // queueMicrotask(async () => { 110 | // await kv.set([PASTE_STORE, key], meta) 111 | // await repo.update(key, {expire: meta.expire}); 112 | // }); 113 | return new Response(ctx, 200, ResponseMessages.SUCCESS); 114 | } 115 | 116 | /* ─────────── 辅助私有函数 ─────────── */ 117 | async function createNew( 118 | ctx: Context, 119 | key: string, 120 | pwd: string, 121 | repo, 122 | ) { 123 | const metadata = await assembleMetadata(ctx, key, pwd); 124 | // 原子性检查 + 占位 125 | const kvRes = await kv.atomic() 126 | .check({ key: [PASTE_STORE, key], versionstamp: null }) 127 | .set([PASTE_STORE, key], { 128 | email: metadata.email, 129 | title: metadata.title, 130 | name: metadata.uname, 131 | ip: metadata.ip, 132 | len: metadata.len, 133 | expire: metadata.expire, 134 | hash: metadata.hash, 135 | pwd, 136 | }, { expireIn: null }) 137 | .commit(); 138 | if (!kvRes.ok) { 139 | return new Response(ctx, 409, ResponseMessages.KEY_EXISTS); 140 | } 141 | 142 | // 写入缓存 143 | await updateCache(key, metadata); 144 | 145 | queueMicrotask(async () => { 146 | try { 147 | const result = await repo.create(metadata); 148 | if (!result) { 149 | console.warn('Failed to create in repo, metadata:', key); 150 | await deleteCache(key, metadata); 151 | await kv.delete([PASTE_STORE, key]); 152 | } 153 | } catch (err) { 154 | console.error(err); 155 | await deleteCache(key, metadata); 156 | await kv.delete([PASTE_STORE, key]); 157 | } 158 | }); 159 | 160 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 161 | key, 162 | pwd, 163 | url: `${ctx.request.url.origin}/r/${key}/${pwd}`, 164 | }); 165 | } 166 | 167 | async function updateExisting( 168 | ctx: Context, 169 | key: string, 170 | pwd: string, 171 | oldMeta, 172 | repo, 173 | ) { 174 | const email = ctx.state.session?.get("user")?.email; 175 | if (email !== oldMeta.email) { 176 | throw new Response(ctx, 403, ResponseMessages.PERMISSION_DENIED); 177 | } 178 | 179 | const metadata = await assembleMetadata(ctx, key, pwd); 180 | 181 | await kv.set([PASTE_STORE, key], { 182 | email: metadata.email, 183 | title: metadata.title, 184 | name: metadata.uname, 185 | ip: metadata.ip, 186 | len: metadata.len, 187 | expire: metadata.expire, 188 | hash: metadata.hash, 189 | pwd, 190 | }); 191 | await updateCache(key, metadata); 192 | 193 | queueMicrotask(async () => { 194 | try { 195 | const result = await repo.update(key, metadata) 196 | if (!result) { 197 | console.warn('Failed to update in repo, metadata:', key); 198 | await updateCache(key, oldMeta); 199 | await kv.set([PASTE_STORE, key], { 200 | email: oldMeta.email, 201 | title: oldMeta.title, 202 | name: oldMeta.uname, 203 | ip: oldMeta.ip, 204 | len: oldMeta.len, 205 | expire: oldMeta.expire, 206 | hash: oldMeta.hash, 207 | pwd, 208 | }); 209 | }else { 210 | delete metadata.content; 211 | cacheBroadcast.postMessage({ type: "update", key, metadata }); 212 | } 213 | } catch (err) { 214 | console.error(err); 215 | await updateCache(key, oldMeta); // 回滚 216 | await kv.set([PASTE_STORE, key], { 217 | email: oldMeta.email, 218 | title: oldMeta.title, 219 | name: oldMeta.uname, 220 | ip: oldMeta.ip, 221 | len: oldMeta.len, 222 | expire: oldMeta.expire, 223 | hash: oldMeta.hash, 224 | pwd, 225 | }); 226 | } 227 | }); 228 | 229 | return new Response(ctx, 200, ResponseMessages.SUCCESS, { 230 | key, 231 | pwd, 232 | url: `${ctx.request.url.origin}/r/${key}/${pwd}`, 233 | }); 234 | } 235 | 236 | /** 把“读取请求体 → 构造 Metadata”封装,新增/更新共用 */ 237 | async function assembleMetadata( 238 | ctx: Context, 239 | key: string, 240 | pwd: string, 241 | ): Promise { 242 | const req = ctx.request; 243 | const headers = req.headers; 244 | // const title = decodeURIComponent(headers.get("x-title") || ""); 245 | const title = headers.get("x-title") || ""; 246 | const len = +headers.get("Content-Length")!; 247 | if (len > MAX_UPLOAD_FILE_SIZE) { 248 | throw new Response(ctx, 413, ResponseMessages.CONTENT_TOO_LARGE); 249 | } 250 | const mime = headers.get("Content-Type") || "application/octet-stream"; 251 | if (!mimeTypeRegex.test(mime)) { 252 | throw new Response(ctx, 415, ResponseMessages.INVALID_CONTENT_TYPE); 253 | } 254 | 255 | const content = await req.body.arrayBuffer(); 256 | if (content.byteLength !== len) { 257 | throw new Response(ctx, 413, ResponseMessages.CONTENT_TOO_LARGE); 258 | } 259 | 260 | const payload = ctx.state.session?.get("user"); 261 | const clientIp = headers.get("cf-connecting-ip") || req.ip; 262 | // const clientIp = req.ip; 263 | return { 264 | fkey: key, 265 | title: title, 266 | time: getTimestamp(), 267 | expire: getTimestamp() + 268 | ~~(headers.get("x-expire") ?? "315360000"), 269 | ip: clientIp, 270 | content, 271 | mime, 272 | len, 273 | pwd, 274 | email: payload?.email ?? "", 275 | uname: payload?.name ?? "", 276 | hash: cyrb53(content), 277 | }; 278 | } 279 | -------------------------------------------------------------------------------- /static/js/code-editor.js: -------------------------------------------------------------------------------- 1 | class QBinCodeEditor extends QBinEditorBase { 2 | constructor() { 3 | super(); 4 | this.currentEditor = "code"; 5 | this.contentType = "text/html; charset=UTF-8"; 6 | this.editorBuffer = { 7 | content: "", 8 | isReady: false 9 | }; 10 | this.currentTheme = this.getThemePreference(); 11 | this.initialize(); 12 | } 13 | 14 | getThemePreference() { 15 | const savedTheme = localStorage.getItem('qbin-theme') || 'system'; 16 | if (savedTheme === 'dark') return 'dark-theme'; 17 | if (savedTheme === 'light') return 'light-theme'; 18 | // System preference: 19 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 20 | 'dark-theme' : 'light-theme'; 21 | } 22 | 23 | async initEditor() { 24 | // Add theme class to editor container before Monaco loads 25 | const editorEl = document.getElementById('editor'); 26 | if (editorEl) { 27 | editorEl.classList.add(this.currentTheme === 'dark-theme' ? 'monaco-dark' : 'monaco-light'); 28 | } 29 | await this.initMonacoEditor(); 30 | } 31 | 32 | getEditorContent() { 33 | if (!this.editorBuffer.isReady) { 34 | return this.editorBuffer.content; 35 | } 36 | return this.editor.getValue(); 37 | } 38 | 39 | setEditorContent(content) { 40 | if (!this.editorBuffer.isReady) { 41 | this.editorBuffer.content = content; 42 | } else { 43 | this.editor.setValue(content); 44 | } 45 | } 46 | 47 | setupEditorThemes() { 48 | monaco.editor.defineTheme('light-theme', { 49 | base: 'vs', 50 | inherit: true, 51 | rules: [], 52 | colors: { 53 | 'editor.background': '#ffffff', 54 | 'editor.foreground': '#2c2c2c', 55 | 'editor.lineHighlightBackground': '#f5f5f5', 56 | 'editorLineNumber.foreground': '#999999', 57 | 'editor.selectionBackground': '#b3d4fc', 58 | 'editor.inactiveSelectionBackground': '#d4d4d4' 59 | } 60 | }); 61 | 62 | monaco.editor.defineTheme('dark-theme', { 63 | base: 'vs-dark', 64 | inherit: true, 65 | rules: [], 66 | colors: { 67 | 'editor.background': '#242424', 68 | 'editor.foreground': '#e0e0e0', 69 | 'editor.lineHighlightBackground': '#2a2a2a', 70 | 'editorLineNumber.foreground': '#666666', 71 | 'editor.selectionBackground': '#264f78', 72 | 'editor.inactiveSelectionBackground': '#3a3a3a' 73 | } 74 | }); 75 | 76 | // Apply initial theme based on user preference 77 | this.applyEditorTheme(); 78 | } 79 | 80 | applyEditorTheme() { 81 | // Get user preference 82 | const savedTheme = localStorage.getItem('qbin-theme') || 'system'; 83 | 84 | // First, update document class to ensure panel styling changes 85 | document.documentElement.classList.remove('light-theme', 'dark-theme'); 86 | 87 | let monacoTheme; 88 | if (savedTheme === 'dark') { 89 | // User explicitly chose dark 90 | monacoTheme = 'dark-theme'; 91 | document.documentElement.classList.add('dark-theme'); 92 | } else if (savedTheme === 'light') { 93 | // User explicitly chose light 94 | monacoTheme = 'light-theme'; 95 | document.documentElement.classList.add('light-theme'); 96 | } else { 97 | // System preference 98 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 99 | monacoTheme = prefersDark ? 'dark-theme' : 'light-theme'; 100 | document.documentElement.classList.add(prefersDark ? 'dark-theme' : 'light-theme'); 101 | } 102 | 103 | // Then update Monaco theme 104 | if (monaco && monaco.editor) { 105 | monaco.editor.setTheme(monacoTheme); 106 | } 107 | 108 | this.currentTheme = monacoTheme; 109 | } 110 | 111 | setupEditorThemeListener() { 112 | // Listen for system preference changes 113 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 114 | mediaQuery.addEventListener('change', () => { 115 | // Only react to system changes if the user preference is 'system' 116 | if (localStorage.getItem('qbin-theme') === 'system' || !localStorage.getItem('qbin-theme')) { 117 | this.applyEditorTheme(); 118 | } 119 | }); 120 | 121 | // Listen for explicit theme changes from other tabs/windows 122 | window.addEventListener('storage', (event) => { 123 | if (event.key === 'qbin-theme') { 124 | this.applyEditorTheme(); 125 | } 126 | }); 127 | 128 | // Add custom event listener for theme changes from UI 129 | window.addEventListener('themeChange', () => { 130 | this.applyEditorTheme(); 131 | }); 132 | 133 | // Create a global theme utility function for UI elements to use 134 | window.qbinToggleTheme = (theme) => { 135 | localStorage.setItem('qbin-theme', theme); 136 | // Dispatch event to update all listeners 137 | window.dispatchEvent(new CustomEvent('themeChange')); 138 | }; 139 | } 140 | 141 | setupEditorChangeListener() { 142 | let saveTimeout; 143 | this.editor.getModel().onDidChangeContent(() => { 144 | clearTimeout(saveTimeout); 145 | saveTimeout = setTimeout(() => { 146 | this.saveToLocalCache(); 147 | }, 1000); 148 | 149 | clearTimeout(this.autoUploadTimer); 150 | this.autoUploadTimer = setTimeout(() => { 151 | const content = this.getEditorContent(); 152 | if (content && cyrb53(content) !== this.lastUploadedHash) { 153 | this.handleUpload(content, this.contentType); 154 | } 155 | }, 2000); 156 | }); 157 | } 158 | 159 | getMimeTypeFromLang(lang) { 160 | const extension = lang.toLowerCase(); 161 | const mimeTypes = { 162 | 'html': 'text/html; charset=UTF-8', 163 | 'css': 'text/css', 164 | 'javascript': 'text/javascript', 165 | 'typescript': 'text/x-typescript', 166 | 'python': 'text/x-python', 167 | 'java': 'text/x-java-source', 168 | 'csharp': 'text/x-csharp', 169 | 'cpp': 'text/x-c++src', 170 | 'php': 'text/x-php', 171 | 'ruby': 'text/x-ruby', 172 | 'go': 'text/x-go', 173 | 'rust': 'text/x-rust', 174 | 'markdown': 'text/markdown', 175 | 'yaml': 'text/yaml', 176 | }; 177 | return mimeTypes[extension] || 'text/plain; charset=UTF-8'; 178 | }; 179 | 180 | async initMonacoEditor() { 181 | return new Promise((resolve) => { 182 | require.config({paths: {'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.36.1/min/vs'}}); 183 | // Configure Monaco loader with correct theme before editor loads 184 | window.MonacoEnvironment = { 185 | getTheme: () => this.currentTheme 186 | }; 187 | require(['vs/editor/editor.main'], () => { 188 | this.setupEditorThemes(); 189 | this.editor = monaco.editor.create(document.getElementById('editor'), { 190 | value: this.editorBuffer.content, 191 | language: 'html', 192 | automaticLayout: true, 193 | theme: this.currentTheme, // Use preloaded theme 194 | minimap: {enabled: window.innerWidth > 768}, 195 | scrollBeyondLastLine: false, 196 | fontSize: isMobile() ? 16 : 14, 197 | lineNumbers: 'on', 198 | wordWrap: 'on', 199 | padding: {top: 20, bottom: 20}, 200 | renderLineHighlight: 'all', 201 | smoothScrolling: true, 202 | cursorBlinking: 'smooth', 203 | cursorSmoothCaretAnimation: true, 204 | fixedOverflowWidgets: true, 205 | contextmenu: false, 206 | matchBrackets: "always", 207 | mouseWheelZoom: true, 208 | }); 209 | this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.KeyL, function () { 210 | monaco.editor.getEditors()[0].getAction('editor.action.formatDocument').run(); 211 | }); 212 | // 折叠/展开当前代码块 213 | this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Minus, () => { 214 | monaco.editor.getEditors()[0].getAction('editor.fold').run(); 215 | }); 216 | this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Equal, () => { 217 | monaco.editor.getEditors()[0].getAction('editor.unfold').run(); 218 | }); 219 | // 折叠/展开所有代码块 220 | this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Minus, () => { 221 | monaco.editor.getEditors()[0].getAction('editor.foldAll').run(); 222 | }); 223 | this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Equal, () => { 224 | monaco.editor.getEditors()[0].getAction('editor.unfoldAll').run(); 225 | }); 226 | this.editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter,() => { 227 | // 使用 Monaco 的内置操作将光标移动到行尾 228 | this.editor.trigger('keyboard', 'cursorEnd', null); 229 | // 插入换行符 230 | this.editor.trigger('keyboard', 'type', { text: '\n' }); 231 | }); 232 | // Remove the temporary class now that Monaco has loaded 233 | const editorEl = document.getElementById('editor'); 234 | if (editorEl) { 235 | editorEl.classList.remove('monaco-dark', 'monaco-light'); 236 | } 237 | // Mark editor as ready 238 | this.editorBuffer.isReady = true; 239 | this.initLanguageSelector(); 240 | this.setupEditorThemeListener(); 241 | this.setupEditorChangeListener(); 242 | resolve(); 243 | }); 244 | }); 245 | } 246 | 247 | initLanguageSelector() { 248 | const languageSelect = document.getElementById('language-select'); 249 | languageSelect.value = this.editor.getModel().getLanguageId(); 250 | 251 | languageSelect.addEventListener('change', () => { 252 | const newLanguage = languageSelect.value; 253 | monaco.editor.setModelLanguage(this.editor.getModel(), newLanguage); 254 | localStorage.setItem('qbin_language_preference', newLanguage); 255 | this.contentType = this.getMimeTypeFromLang(newLanguage); 256 | }); 257 | 258 | const savedLanguage = localStorage.getItem('qbin_language_preference'); 259 | if (savedLanguage) { 260 | languageSelect.value = savedLanguage; 261 | this.contentType = this.getMimeTypeFromLang(savedLanguage); 262 | monaco.editor.setModelLanguage(this.editor.getModel(), savedLanguage); 263 | } 264 | } 265 | } 266 | 267 | new QBinCodeEditor(); 268 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import {Context} from "https://deno.land/x/oak/mod.ts"; 2 | import {OAuth2Client} from "jsr:@cmd-johnson/oauth2-client"; 3 | import {generateJwtToken, verifyJwtToken, decodeJwtToken} from "../utils/crypto.ts"; 4 | import {kv} from "../utils/cache.ts"; 5 | import {PasteError, Response} from "../utils/response.ts"; 6 | import { 7 | PASSWORD, 8 | exactPaths, 9 | HEADERS, 10 | prefixPaths, 11 | TOKEN_EXPIRE, 12 | EMAIL, 13 | ENABLE_ANONYMOUS_ACCESS 14 | } from "../config/constants.ts"; 15 | import {get_env} from "../config/env.ts"; 16 | import {ResponseMessages} from "../utils/messages.ts"; 17 | 18 | 19 | // 定义 OAuth2 提供商配置 20 | // https://github.com/cmd-johnson/deno-oauth2-client 21 | const oauthProviders = { 22 | // Google OAuth2 配置 23 | google: { 24 | client: new OAuth2Client({ 25 | clientId: get_env("GOOGLE_CLIENT_ID") || "", 26 | clientSecret: get_env("GOOGLE_CLIENT_SECRET") || "", 27 | authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth", 28 | tokenUri: "https://oauth2.googleapis.com/token", 29 | redirectUri: get_env("GOOGLE_CALLBACK_URL") || "http://localhost:8000/api/login/oauth2/callback/google", 30 | defaults: { 31 | scope: ["profile", "email"], 32 | }, 33 | }), 34 | userInfoUrl: "https://www.googleapis.com/oauth2/v3/userinfo", 35 | userDataTransformer: (userData: any): UserData => ({ 36 | id: userData.sub, 37 | name: userData.name || userData.email?.split('@')[0] || "User", 38 | email: userData.email, 39 | provider: "google", 40 | }), 41 | validateUser: (userData: any): boolean => userData.email_verified === true, 42 | }, 43 | // GitHub OAuth2 配置 44 | github: { 45 | client: new OAuth2Client({ 46 | clientId: get_env("GITHUB_CLIENT_ID") || "", 47 | clientSecret: get_env("GITHUB_CLIENT_SECRET") || "", 48 | authorizationEndpointUri: "https://github.com/login/oauth/authorize", 49 | tokenUri: "https://github.com/login/oauth/access_token", 50 | redirectUri: get_env("GITHUB_CALLBACK_URL") || "http://localhost:8000/api/login/oauth2/callback/github", 51 | defaults: { 52 | scope: ["read:user", "user:email"], 53 | }, 54 | }), 55 | userInfoUrl: "https://api.github.com/user", 56 | userDataTransformer: (userData: any): UserData => ({ 57 | id: userData.id.toString(), 58 | name: userData.login, 59 | email: userData.email, 60 | provider: "github", 61 | }), 62 | validateUser: (userData: any): boolean => userData.id !== undefined, 63 | // GitHub 可能需要额外的请求来获取邮箱信息 64 | getAdditionalData: async (accessToken: string): Promise => { 65 | if (!accessToken) return {}; 66 | 67 | try { 68 | const emailsResponse = await fetch("https://api.github.com/user/emails", { 69 | headers: { 70 | Authorization: `Bearer ${accessToken}`, 71 | Accept: "application/json" 72 | }, 73 | }); 74 | 75 | if (emailsResponse.ok) { 76 | const emails = await emailsResponse.json(); 77 | const primaryEmail = emails.find((email: any) => email.primary && email.verified); 78 | 79 | if (primaryEmail) { 80 | return { email: primaryEmail.email }; 81 | } 82 | } 83 | 84 | return {}; 85 | } catch (error) { 86 | console.error("Error fetching GitHub emails:", error); 87 | return {}; 88 | } 89 | } 90 | }, 91 | // Microsoft OAuth2 配置 92 | microsoft: { 93 | client: new OAuth2Client({ 94 | clientId: get_env("MICROSOFT_CLIENT_ID") || "", 95 | clientSecret: get_env("MICROSOFT_CLIENT_SECRET") || "", 96 | authorizationEndpointUri: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", 97 | tokenUri: "https://login.microsoftonline.com/common/oauth2/v2.0/token", 98 | redirectUri: get_env("MICROSOFT_CALLBACK_URL") || "http://localhost:8000/api/login/oauth2/callback/microsoft", 99 | defaults: { 100 | scope: ["user.read", "profile", "email", "openid"], 101 | }, 102 | }), 103 | userInfoUrl: "https://graph.microsoft.com/v1.0/me", 104 | userDataTransformer: (userData: any): UserData => ({ 105 | id: userData.id, 106 | name: userData.displayName || userData.userPrincipalName?.split('@')[0] || "User", 107 | email: userData.mail || userData.userPrincipalName, 108 | provider: "microsoft", 109 | }), 110 | validateUser: (userData: any): boolean => userData.id !== undefined, 111 | }, 112 | // 自定义 OAuth2 配置 113 | custom: { 114 | client: new OAuth2Client({ 115 | clientId: get_env("OAUTH_CLIENT_ID") || "", 116 | clientSecret: get_env("OAUTH_CLIENT_SECRET") || "", 117 | authorizationEndpointUri: get_env("OAUTH_AUTH_URL") || "", 118 | tokenUri: get_env("OAUTH_TOKEN_URL") || "", 119 | redirectUri: get_env("OAUTH_CALLBACK_URL") || "http://localhost:8000/api/login/oauth2/callback/custom", 120 | defaults: { 121 | scope: (get_env("OAUTH_SCOPES") || "").split(",").filter(Boolean), 122 | }, 123 | }), 124 | userInfoUrl: get_env("OAUTH_USER_INFO_URL") || "", 125 | userDataTransformer: (userData: any): UserData => ({ 126 | id: userData.id || userData.user_id, 127 | name: userData.username || userData.name || userData.display_name, 128 | email: userData.email, 129 | provider: "custom oauth", 130 | }), 131 | validateUser: (userData: any): boolean => userData.active === true, 132 | }, 133 | }; 134 | 135 | /** 136 | * 认证中间件 137 | * - 从 Cookie 中读取 token 并验证 138 | * - 无 token 时尝试做 OAuth2 登录流程 139 | * - 若未登录且访问受限路由,则显示登录页面 140 | */ 141 | export async function authMiddleware(ctx: Context, next: () => Promise) { 142 | const session = ctx.state.session; 143 | let token = await ctx.cookies.get("token"); // 从 cookie 中取出 Token 144 | const currentPath = ctx.request.url.pathname; 145 | const method = ctx.request.method; 146 | const isExactPathAuth = method === "GET" && exactPaths.includes(currentPath); 147 | const isPrefixPathAuth = prefixPaths.some(prefix => currentPath.startsWith(prefix)); 148 | 149 | if (ENABLE_ANONYMOUS_ACCESS === 1 && !token) { 150 | if (!session.has("user") && !["/login", "/api/login/admin", "/favicon.ico", "/r/"].some(prefix => currentPath.startsWith(prefix))) { 151 | const demoToken = await generateJwtToken({ 152 | id: 1, 153 | email: "demo@qbin.me", 154 | name: "Anonymous User", 155 | provider: "anonymous", 156 | }, 43200); 157 | await ctx.cookies.set("token", demoToken, { 158 | maxAge: 43200000, 159 | httpOnly: true, 160 | sameSite: "lax", 161 | path: "/", 162 | }); 163 | token = demoToken; 164 | if(currentPath === "/") return ctx.response.redirect("/home"); 165 | } 166 | } 167 | 168 | if (token) { 169 | try { 170 | // 若 Session 中没有用户信息,但 JWT 有,则写回 Session 171 | const userFromJwt = await verifyJwtToken(token); 172 | if(userFromJwt){ 173 | session.set("user", { 174 | id: userFromJwt.id, 175 | name: userFromJwt.name, 176 | email: userFromJwt.email, 177 | }); 178 | } 179 | } 180 | catch (e) { 181 | await ctx.cookies.delete("token", { 182 | path: "/", 183 | httpOnly: true, 184 | sameSite: "lax" 185 | }); 186 | if (!(isPrefixPathAuth || isExactPathAuth || currentPath === "/home")) { 187 | const { header, payload } = decodeJwtToken(token); 188 | if (ENABLE_ANONYMOUS_ACCESS === 1 && payload.provider === "anonymous") { 189 | const demoToken = await generateJwtToken({ 190 | id: 1, 191 | email: "demo@qbin.me", 192 | name: "Anonymous User", 193 | provider: "anonymous", 194 | }, 43200); 195 | await ctx.cookies.set("token", demoToken, { 196 | maxAge: 43200000, 197 | httpOnly: true, 198 | sameSite: "lax", 199 | path: "/", 200 | }); 201 | session.set("user", { 202 | id: 1, 203 | name: "Anonymous User", 204 | email: "demo@qbin.me", 205 | }); 206 | } else { 207 | return new Response(ctx, 401, "Cookie expired"); 208 | } 209 | } 210 | } 211 | } 212 | 213 | if (session?.has("user")) { 214 | Object.entries(HEADERS.HTML).forEach(([k, v]) => { 215 | ctx.response.headers.set(k, v); 216 | }); 217 | await next(); 218 | return; 219 | } 220 | 221 | if (isPrefixPathAuth || isExactPathAuth){ 222 | // 公开路径,无需认证 223 | await next(); 224 | return; 225 | } 226 | 227 | return ctx.response.redirect("/login"); 228 | } 229 | 230 | export const handleAdminLogin = async (ctx: Context) => { 231 | try { 232 | const body = await ctx.request.body.json(); 233 | const { email, password } = body; 234 | if (!email || !password) return new Response(ctx, 400, "Email and password are required"); 235 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 236 | if (!emailRegex.test(email)) return new Response(ctx, 400, "Invalid email format"); 237 | if (email !== EMAIL || password !== PASSWORD) { 238 | return new Response(ctx, 403, "Invalid email or password"); 239 | } 240 | 241 | const jwtToken = await generateJwtToken({ 242 | id: 0, 243 | email: EMAIL, 244 | name: EMAIL.includes('@') ? EMAIL.split('@')[0] : EMAIL, 245 | }, TOKEN_EXPIRE); 246 | await ctx.cookies.set("token", jwtToken, { 247 | maxAge: TOKEN_EXPIRE * 1000, 248 | httpOnly: true, 249 | sameSite: "lax", 250 | path: "/", 251 | }); 252 | return new Response(ctx, 200, ResponseMessages.SUCCESS); 253 | } catch (error) { 254 | console.error("Admin login error:", error); 255 | return new Response(ctx, 400, "Invalid request format"); 256 | } 257 | } 258 | 259 | // 处理 OAuth2 登录重定向 260 | export const handleLogin = async (ctx: Context) => { 261 | try { 262 | const provider = ctx.params.provider; 263 | if (!oauthProviders[provider]) { 264 | return new Response(ctx, 400, "无效的 OAuth 提供商"); 265 | } 266 | 267 | const oauth2Client = oauthProviders[provider].client; 268 | const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri(); 269 | const sessionId = crypto.randomUUID(); 270 | 271 | await kv.set(["oauth_sessions", sessionId], { provider, codeVerifier }, { expireIn: 60000 }); 272 | ctx.cookies.set("session_id", sessionId, { 273 | maxAge: 60000, // 60秒过期 274 | httpOnly: true, 275 | sameSite: "lax" 276 | }); 277 | ctx.response.redirect(uri); 278 | } catch (error) { 279 | console.error("OAuth 登录错误:", error); 280 | throw new PasteError(500, "OAuth2 认证错误"); 281 | } 282 | } 283 | 284 | // 处理 OAuth2 回调请求 285 | export const handleOAuthCallback = async (ctx: Context) => { 286 | try { 287 | const providerParam = ctx.params.provider; 288 | if (!oauthProviders[providerParam]) throw new PasteError(400, "无效的 OAuth 提供商"); 289 | const sessionId = await ctx.cookies.get("session_id"); 290 | if (!sessionId) throw new PasteError(400, "未找到会话"); 291 | const sessionDataResult = await kv.get(["oauth_sessions", sessionId]); 292 | if (!sessionDataResult) throw new PasteError(400, "无效的会话"); 293 | 294 | const { provider, codeVerifier } = sessionDataResult.value; 295 | if (provider !== providerParam) throw new PasteError(400, "提供商不匹配"); 296 | await kv.delete(["oauth_sessions", sessionId]); 297 | 298 | const oauth2Client = oauthProviders[provider].client; 299 | const tokens = await oauth2Client.code.getToken(ctx.request.url, { 300 | codeVerifier: codeVerifier 301 | }); 302 | 303 | const userResponse = await fetch(oauthProviders[provider].userInfoUrl, { 304 | headers: { 305 | Authorization: `Bearer ${tokens.accessToken}`, 306 | Accept: "application/json" 307 | }, 308 | }); 309 | if (!userResponse.ok) throw new PasteError(500, `获取用户数据失败: ${userResponse.statusText}`); 310 | const userData = await userResponse.json(); 311 | 312 | let additionalData = {}; 313 | if (provider === "github" && oauthProviders.github.getAdditionalData) { 314 | additionalData = await oauthProviders.github.getAdditionalData(tokens.accessToken); 315 | } 316 | const mergedUserData = { ...userData, ...additionalData }; 317 | if (!oauthProviders[provider].validateUser(mergedUserData)) { 318 | throw new PasteError(403, "OAuth2 用户验证失败"); 319 | } 320 | const transformedUserData = oauthProviders[provider].userDataTransformer(mergedUserData); 321 | const jwtToken = await generateJwtToken({ 322 | id: transformedUserData.id, 323 | name: transformedUserData.name, 324 | email: transformedUserData.email, 325 | provider: transformedUserData.provider, 326 | }, TOKEN_EXPIRE); 327 | await ctx.cookies.set("token", jwtToken, { 328 | maxAge: TOKEN_EXPIRE * 1000, 329 | httpOnly: true, 330 | sameSite: "lax", 331 | path: "/", 332 | }); 333 | await ctx.cookies.delete("session_id", { 334 | path: "/", 335 | httpOnly: true, 336 | sameSite: "lax" 337 | }); 338 | ctx.response.redirect("/home"); 339 | } catch (error) { 340 | console.error("OAuth 回调错误:", error); 341 | if (error instanceof PasteError) { 342 | return new Response(ctx, error.status, error.message); 343 | } else { 344 | throw new PasteError(500, "认证回调错误"); 345 | } 346 | } 347 | }; 348 | -------------------------------------------------------------------------------- /static/templates/code-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | QBin - Code编辑器 5 | 6 | 7 | 8 | 9 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 |
56 |
57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 |
84 |
85 |
86 | 87 |
88 | 90 | 93 |
94 |
95 |
96 | 97 |
98 | 100 | 103 |
104 |
105 |
106 | 107 | 116 |
117 |
118 | 119 | 140 |
141 | 142 | 143 | 144 | 145 | 146 |
147 |
148 | 149 | 150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 | 163 | 164 |
165 |
166 | 167 | 168 | -------------------------------------------------------------------------------- /static/templates/multi-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | QBin - 通用编辑器 5 | 6 | 7 | 8 | 9 | 10 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 |
70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
98 |
99 |
100 |
101 | 102 |
103 | 105 | 108 |
109 |
110 |
111 | 112 |
113 | 115 | 118 |
119 |
120 |
121 | 122 | 131 |
132 |
133 | 134 | 端到端加密 (开发中) 135 | 136 |
137 |
138 |
139 | 140 | 141 |
142 |
143 |
144 |
145 |
146 | 147 |
148 | 155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 | 163 | 164 |
165 |
166 | 167 | -------------------------------------------------------------------------------- /static/templates/pwa-loader.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | QBin - 安装应用 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 249 | 250 | 251 |
252 | QBin Logo 253 |

安装 QBin 应用

254 |

离线访问,无需下载,不占用额外存储空间

255 | 261 |
262 |
263 |
264 |
265 |

iOS 安装指南

266 | 267 |
268 |
269 |
270 |
1
271 |
272 |
点击浏览器底部的分享图标
273 | 274 | 275 | 276 |
277 |
278 |
279 |
2
280 |
281 |
向下滑动找到添加到主屏幕
282 | 283 |
284 |
285 |
286 |
3
287 |
288 |
点击添加确认安装
289 | 290 | 291 | 292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |

Android 安装指南

301 | 302 |
303 |
304 |
305 |
1
306 |
307 |
点击浏览器右下角菜单按钮
308 | 309 |
310 |
311 |
312 |
2
313 |
314 |
向左滑动找到添加至手机
315 | 316 |
317 |
318 |
319 |
3
320 |
321 |
点击添加确认安装
322 | 323 | 324 | 325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |

桌面端安装指南

334 | 335 |
336 |
337 |
338 |
1
339 |
340 |
点击浏览器地址栏右侧的安装图标
341 | 342 | 343 | 344 |
345 |
346 |
347 |
2
348 |
349 |
在弹出的对话框中点击安装
350 | 351 | 352 | 353 |
354 |
355 |
356 |
357 |
358 | 430 | 431 | -------------------------------------------------------------------------------- /static/css/code-editor.css: -------------------------------------------------------------------------------- 1 | /* Add CSS variables to support panel-common.css */ 2 | :root { 3 | /* Light theme default colors */ 4 | --background-color: #f5f6f7; 5 | --text-color: #2c3e50; 6 | --editor-bg: #ffffff; 7 | --editor-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 8 | 0 2px 8px rgba(0, 0, 0, 0.06), 9 | 0 0 0 1px rgba(0, 0, 0, 0.02), 10 | inset 0 0 20px rgba(0, 0, 0, 0.005), 11 | 0 4px 16px rgba(0, 0, 0, 0.02); 12 | --editor-focus-shadow: 0 2px 6px rgba(0, 0, 0, 0.05), 13 | 0 4px 12px rgba(0, 0, 0, 0.08), 14 | 0 0 0 1px rgba(24, 144, 255, 0.08), 15 | inset 0 0 30px rgba(0, 0, 0, 0.002), 16 | 0 8px 24px rgba(0, 0, 0, 0.03); 17 | --scrollbar-thumb: rgba(0, 0, 0, 0.2); 18 | --scrollbar-thumb-hover: rgba(0, 0, 0, 0.4); 19 | 20 | /* Panel & Form elements */ 21 | --panel-bg: rgba(252, 252, 252, 0.92); 22 | --panel-gradient: radial-gradient(circle at top right, 23 | rgba(240, 240, 240, 0.8) 0%, 24 | rgba(252, 252, 252, 0.3) 60%, 25 | rgba(252, 252, 252, 0.92) 100%); 26 | --panel-shadow: 0 3px 16px rgba(0, 0, 0, 0.06), 27 | 0 8px 30px rgba(0, 0, 0, 0.08), 28 | 0 0 0 1px rgba(0, 0, 0, 0.02), 29 | inset 0 1px 0 rgba(255, 255, 255, 0.9), 30 | 0 14px 45px rgba(0, 0, 0, 0.05), 31 | 0 1px 3px rgba(0, 0, 0, 0.1); 32 | --section-bg: rgba(255, 255, 255, 0.5); 33 | --section-hover-bg: rgba(255, 255, 255, 0.65); 34 | --section-title-color: #444; 35 | --section-border: rgba(0, 0, 0, 0.06); 36 | --section-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 37 | inset 0 1px 0 rgba(255, 255, 255, 0.9), 38 | 0 1px 2px rgba(0, 0, 0, 0.03); 39 | --section-hover-shadow: 0 2px 5px rgba(0, 0, 0, 0.03), 40 | inset 0 1px 0 rgba(255, 255, 255, 1), 41 | 0 1px 3px rgba(0, 0, 0, 0.04); 42 | --input-bg: rgba(255, 255, 255, 0.7); 43 | --input-color: #333; 44 | --input-border: rgba(0, 0, 0, 0.08); 45 | --input-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 46 | inset 0 0 0 1px rgba(255, 255, 255, 0.6); 47 | --input-focus-border: rgba(24, 144, 255, 0.4); 48 | --input-focus-bg: rgba(255, 255, 255, 0.95); 49 | --input-focus-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1), 50 | inset 0 0 0 1px rgba(255, 255, 255, 0.8); 51 | --label-color: #555; 52 | --label-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); 53 | --label-dot-bg: rgba(0, 0, 0, 0.3); 54 | --label-dot-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); 55 | 56 | /* Social & UI elements */ 57 | --social-link-bg: rgba(255, 255, 255, 0.6); 58 | --social-link-color: #666; 59 | --social-link-hover-bg: rgba(255, 255, 255, 0.9); 60 | --social-link-hover-color: #1890ff; 61 | --social-link-border: rgba(0, 0, 0, 0.04); 62 | --social-link-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 63 | inset 0 1px 0 rgba(255, 255, 255, 0.7); 64 | --social-link-hover-shadow: 0 4px 8px rgba(0, 0, 0, 0.04), 65 | 0 2px 4px rgba(24, 144, 255, 0.08), 66 | inset 0 1px 0 rgba(255, 255, 255, 0.9); 67 | --bookmark-bg: rgba(252, 252, 252, 0.92); 68 | --bookmark-hover-bg: rgba(255, 255, 255, 0.97); 69 | --bookmark-shadow: 0 2px 6px rgba(0, 0, 0, 0.08), 70 | 0 4px 10px rgba(0, 0, 0, 0.05), 71 | 0 0 0 1px rgba(0, 0, 0, 0.04), 72 | inset 0 0 16px rgba(0, 0, 0, 0.01), 73 | 0 6px 16px rgba(0, 0, 0, 0.03); 74 | --bookmark-hover-shadow: 0 3px 10px rgba(24, 144, 255, 0.12), 75 | 0 4px 14px rgba(0, 0, 0, 0.08), 76 | 0 0 0 1px rgba(24, 144, 255, 0.15), 77 | inset 0 0 0 1px rgba(255, 255, 255, 0.7), 78 | 0 8px 20px rgba(0, 0, 0, 0.06); 79 | 80 | /* Status messages */ 81 | --status-bg: rgba(255, 255, 255, 0.95); 82 | --status-success-bg: rgba(246, 255, 237, 0.95); 83 | --status-success-color: #52c41a; 84 | --status-error-bg: rgba(255, 241, 240, 0.95); 85 | --status-error-color: #ff4d4f; 86 | --status-info-color: #666; 87 | 88 | /* Checkbox */ 89 | --checkbox-bg: rgba(255, 255, 255, 0.7); 90 | --checkbox-border: rgba(0, 0, 0, 0.15); 91 | --checkbox-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 92 | inset 0 1px 0 rgba(255, 255, 255, 0.8); 93 | --checkbox-checked-bg: rgba(24, 144, 255, 0.2); 94 | --checkbox-checked-border: rgba(24, 144, 255, 0.4); 95 | --checkbox-check-color: #1890ff; 96 | --option-text-color: #555; 97 | 98 | /* Dialog variables */ 99 | --dialog-bg: rgba(252, 252, 252, 0.98); 100 | --dialog-text: #333; 101 | --dialog-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); 102 | --overlay-bg: rgba(0, 0, 0, 0.4); 103 | --button-primary-bg: #1890ff; 104 | --button-primary-color: #fff; 105 | --button-secondary-bg: rgba(0, 0, 0, 0.05); 106 | --button-secondary-color: #666; 107 | --button-hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 108 | --button-primary-hover-bg: #40a9ff; 109 | --button-secondary-hover-bg: rgba(0, 0, 0, 0.08); 110 | } 111 | 112 | /* Dark theme variables */ 113 | .dark-theme { 114 | --background-color: #1a1a1a; 115 | --text-color: #e0e0e0; 116 | --editor-bg: #242424; 117 | --editor-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 118 | 0 0 0 1px rgba(255, 255, 255, 0.05); 119 | --editor-focus-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 120 | 0 6px 16px rgba(0, 0, 0, 0.2), 121 | 0 0 0 1px rgba(24, 144, 255, 0.15), 122 | inset 0 0 20px rgba(0, 0, 0, 0.15), 123 | 0 8px 24px rgba(0, 0, 0, 0.18); 124 | --scrollbar-thumb: rgba(255, 255, 255, 0.2); 125 | --scrollbar-thumb-hover: rgba(255, 255, 255, 0.4); 126 | 127 | /* Panel & Form elements */ 128 | --panel-bg: rgba(36, 36, 36, 0.92); 129 | --panel-gradient: radial-gradient(circle at top right, 130 | rgba(50, 50, 50, 0.8) 0%, 131 | rgba(36, 36, 36, 0.3) 60%, 132 | rgba(36, 36, 36, 0.92) 100%); 133 | --panel-shadow: 0 2px 16px rgba(0, 0, 0, 0.25), 134 | 0 10px 40px rgba(0, 0, 0, 0.3), 135 | 0 0 0 1px rgba(255, 255, 255, 0.08), 136 | inset 0 1px 0 rgba(255, 255, 255, 0.05), 137 | 0 1px 3px rgba(0, 0, 0, 0.3); 138 | --section-bg: rgba(50, 50, 50, 0.3); 139 | --section-hover-bg: rgba(55, 55, 55, 0.4); 140 | --section-title-color: rgba(255, 255, 255, 0.85); 141 | --section-border: rgba(255, 255, 255, 0.08); 142 | --section-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 143 | inset 0 1px 0 rgba(255, 255, 255, 0.03), 144 | 0 1px 2px rgba(0, 0, 0, 0.15); 145 | --section-hover-shadow: 0 2px 5px rgba(0, 0, 0, 0.15), 146 | inset 0 1px 0 rgba(255, 255, 255, 0.05), 147 | 0 1px 3px rgba(0, 0, 0, 0.2); 148 | --input-bg: rgba(48, 48, 48, 0.7); 149 | --input-color: rgba(255, 255, 255, 0.92); 150 | --input-border: rgba(255, 255, 255, 0.12); 151 | --input-shadow: 0 1px 2px rgba(0, 0, 0, 0.15), 152 | inset 0 0 0 1px rgba(255, 255, 255, 0.03); 153 | --input-focus-border: rgba(24, 144, 255, 0.6); 154 | --input-focus-bg: rgba(52, 52, 52, 0.95); 155 | --input-focus-shadow: 0 0 0 3px rgba(24, 144, 255, 0.15), 156 | inset 0 0 0 1px rgba(255, 255, 255, 0.05); 157 | --label-color: rgba(255, 255, 255, 0.75); 158 | --label-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); 159 | --label-dot-bg: rgba(255, 255, 255, 0.4); 160 | --label-dot-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); 161 | 162 | /* Social & UI elements */ 163 | --social-link-bg: rgba(255, 255, 255, 0.05); 164 | --social-link-color: rgba(255, 255, 255, 0.7); 165 | --social-link-hover-bg: rgba(255, 255, 255, 0.1); 166 | --social-link-hover-color: #40a9ff; 167 | --social-link-border: rgba(255, 255, 255, 0.08); 168 | --social-link-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 169 | inset 0 1px 0 rgba(255, 255, 255, 0.03); 170 | --social-link-hover-shadow: 0 4px 10px rgba(0, 0, 0, 0.15), 171 | 0 2px 5px rgba(24, 144, 255, 0.1), 172 | inset 0 1px 0 rgba(255, 255, 255, 0.05); 173 | --bookmark-bg: rgba(36, 36, 36, 0.9); 174 | --bookmark-hover-bg: rgba(42, 42, 42, 0.95); 175 | --bookmark-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), 176 | 0 2px 6px rgba(0, 0, 0, 0.2), 177 | 0 0 0 1px rgba(255, 255, 255, 0.06), 178 | inset 0 0 16px rgba(0, 0, 0, 0.15), 179 | 0 4px 10px rgba(0, 0, 0, 0.2); 180 | --bookmark-hover-shadow: 0 3px 8px rgba(0, 0, 0, 0.3), 181 | 0 2px 10px rgba(24, 144, 255, 0.18), 182 | 0 0 0 1px rgba(24, 144, 255, 0.25), 183 | inset 0 0 0 1px rgba(255, 255, 255, 0.05), 184 | 0 6px 16px rgba(0, 0, 0, 0.25); 185 | 186 | /* Status messages */ 187 | --status-bg: rgba(40, 40, 40, 0.85); 188 | --status-success-bg: rgba(82, 196, 26, 0.12); 189 | --status-success-color: #52c41a; 190 | --status-error-bg: rgba(255, 77, 79, 0.12); 191 | --status-error-color: #ff4d4f; 192 | --status-info-color: rgba(255, 255, 255, 0.7); 193 | 194 | /* Checkbox */ 195 | --checkbox-bg: rgba(60, 60, 60, 0.5); 196 | --checkbox-border: rgba(255, 255, 255, 0.2); 197 | --checkbox-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 198 | inset 0 1px 0 rgba(255, 255, 255, 0.03); 199 | --checkbox-checked-bg: rgba(24, 144, 255, 0.2); 200 | --checkbox-checked-border: rgba(24, 144, 255, 0.4); 201 | --checkbox-check-color: #1890ff; 202 | --option-text-color: rgba(255, 255, 255, 0.75); 203 | 204 | /* Dialog variables for dark theme */ 205 | --dialog-bg: rgba(36, 36, 36, 0.98); 206 | --dialog-text: rgba(255, 255, 255, 0.9); 207 | --dialog-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); 208 | --overlay-bg: rgba(0, 0, 0, 0.6); 209 | --button-primary-bg: #1890ff; 210 | --button-primary-color: #fff; 211 | --button-secondary-bg: rgba(255, 255, 255, 0.08); 212 | --button-secondary-color: rgba(255, 255, 255, 0.9); 213 | --button-hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 214 | --button-primary-hover-bg: #40a9ff; 215 | --button-secondary-hover-bg: rgba(255, 255, 255, 0.12); 216 | } 217 | 218 | /* Light theme variables - explicitly set to ensure theme switching works */ 219 | .light-theme { 220 | /* All variables remain as defined in :root */ 221 | } 222 | 223 | /* Add theme-based animation for transitions */ 224 | .light-theme .password-panel, 225 | .dark-theme .password-panel { 226 | transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); 227 | } 228 | 229 | /* Enhanced theme-specific overrides for panel elements */ 230 | .dark-theme #editor { 231 | color: #e0e0e0 !important; 232 | background-color: #242424 !important; 233 | /*color: var(--text-color);*/ 234 | /*background-color: var(--editor-bg);*/ 235 | box-shadow: var(--editor-shadow); 236 | } 237 | 238 | .dark-theme #editor:focus { 239 | box-shadow: var(--editor-focus-shadow); 240 | } 241 | 242 | .dark-theme .content::-webkit-scrollbar-thumb { 243 | background-color: var(--scrollbar-thumb); 244 | } 245 | 246 | .dark-theme .content::-webkit-scrollbar-thumb:hover { 247 | background-color: var(--scrollbar-thumb-hover); 248 | } 249 | 250 | .header { 251 | position: absolute; 252 | top: 0; 253 | left: 0; 254 | right: 0; 255 | height: 0; 256 | background: none; 257 | z-index: 100; 258 | } 259 | 260 | body { 261 | margin: 0; 262 | padding: 0; 263 | height: 100vh; 264 | display: flex; 265 | flex-direction: column; 266 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 267 | background-color: #f5f6f7; 268 | overflow: hidden; 269 | scrollbar-width: none; 270 | -ms-overflow-style: none; 271 | } 272 | 273 | body::-webkit-scrollbar { 274 | display: none; 275 | } 276 | 277 | .content { 278 | margin: 0; 279 | flex: 1; 280 | display: flex; 281 | flex-direction: column; 282 | align-items: center; 283 | justify-content: center; 284 | height: 100vh; 285 | width: 100%; 286 | box-sizing: border-box; 287 | position: relative; 288 | overflow: hidden; 289 | padding: 0; 290 | } 291 | 292 | .content::-webkit-scrollbar { 293 | width: 8px; 294 | height: 8px; 295 | } 296 | 297 | .content::-webkit-scrollbar-track { 298 | background: transparent; 299 | } 300 | 301 | .content::-webkit-scrollbar-thumb { 302 | background-color: rgba(0, 0, 0, 0.2); 303 | border-radius: 4px; 304 | border: 2px solid transparent; 305 | background-clip: content-box; 306 | transition: background-color 0.3s ease; 307 | } 308 | 309 | .content::-webkit-scrollbar-thumb:hover { 310 | background-color: rgba(0, 0, 0, 0.4); 311 | } 312 | 313 | @media (prefers-color-scheme: dark) { 314 | body { 315 | background-color: #1a1a1a; 316 | } 317 | 318 | #editor { 319 | background-color: #242424 !important; 320 | color: #e0e0e0 !important; 321 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.05) !important; 322 | transition: background-color 0.1s ease, box-shadow 0.3s ease !important; 323 | } 324 | 325 | #editor:focus, #editor:active, #editor:hover, #editor:-webkit-autofill { 326 | background-color: #242424 !important; 327 | color: #e0e0e0 !important; 328 | -webkit-text-fill-color: #e0e0e0 !important; 329 | } 330 | 331 | @media (max-width: 768px) { 332 | body { 333 | background-color: #1a1a1a !important; 334 | } 335 | 336 | .content { 337 | background-color: transparent !important; 338 | } 339 | 340 | #editor, #editor:focus, #editor:active, #editor:hover, #editor:focus { 341 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15), 0 6px 16px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(24, 144, 255, 0.15), inset 0 0 20px rgba(0, 0, 0, 0.15), 0 8px 24px rgba(0, 0, 0, 0.18) !important; 342 | } 343 | } 344 | 345 | .content { 346 | scrollbar-color: rgba(255, 255, 255, 0.3) transparent; 347 | } 348 | 349 | .content::-webkit-scrollbar-thumb { 350 | background-color: rgba(255, 255, 255, 0.2); 351 | } 352 | 353 | .content::-webkit-scrollbar-thumb:hover { 354 | background-color: rgba(255, 255, 255, 0.4); 355 | } 356 | } 357 | 358 | /* Editor-specific styles */ 359 | #editor { 360 | height: 100% !important; 361 | width: 100% !important; 362 | max-width: 100%; 363 | position: relative; 364 | border: none !important; 365 | box-shadow: none !important; 366 | background: transparent !important; 367 | } 368 | 369 | #editor:focus { 370 | outline: none; 371 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(24, 144, 255, 0.06), inset 0 0 30px rgba(0, 0, 0, 0.002), 0 8px 24px rgba(0, 0, 0, 0.02); 372 | } 373 | 374 | @media (max-width: 768px) { 375 | .content::-webkit-scrollbar { 376 | width: 6px; 377 | height: 6px; 378 | } 379 | 380 | .content::-webkit-scrollbar-thumb { 381 | background-color: rgba(0, 0, 0, 0.2); 382 | border-radius: 3px; 383 | border: none; 384 | background-clip: content-box; 385 | transition: background-color 0.3s ease; 386 | } 387 | 388 | .content::-webkit-scrollbar-thumb:hover { 389 | background-color: rgba(0, 0, 0, 0.3); 390 | } 391 | 392 | body { 393 | background-color: #f8f9fa; 394 | } 395 | 396 | .content { 397 | overflow-y: auto; 398 | scrollbar-width: thin; 399 | scrollbar-color: rgba(0, 0, 0, 0.3) transparent; 400 | } 401 | 402 | #editor { 403 | padding: 12px; 404 | font-size: 16px; 405 | line-height: 1.5; 406 | background: rgba(255, 255, 255, 0.98); 407 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03), 0 2px 8px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.01), inset 0 0 16px rgba(0, 0, 0, 0.003); 408 | } 409 | } 410 | 411 | @media (prefers-color-scheme: dark) { 412 | .content { 413 | scrollbar-color: rgba(255, 255, 255, 0.3) transparent; 414 | } 415 | 416 | .content::-webkit-scrollbar-thumb { 417 | background-color: rgba(255, 255, 255, 0.2); 418 | } 419 | 420 | .content::-webkit-scrollbar-thumb:hover { 421 | background-color: rgba(255, 255, 255, 0.4); 422 | } 423 | 424 | @media (max-width: 768px) { 425 | .content::-webkit-scrollbar { 426 | width: 6px; 427 | height: 6px; 428 | } 429 | 430 | .content::-webkit-scrollbar-thumb { 431 | background-color: rgba(255, 255, 255, 0.2); 432 | border-radius: 3px; 433 | border: none; 434 | background-clip: content-box; 435 | transition: background-color 0.3s ease; 436 | } 437 | 438 | .content::-webkit-scrollbar-thumb:hover { 439 | background-color: rgba(255, 255, 255, 0.3); 440 | } 441 | 442 | .content { 443 | scrollbar-width: thin; 444 | scrollbar-color: rgba(255, 255, 255, 0.3) transparent; 445 | } 446 | } 447 | } 448 | 449 | @supports (-webkit-touch-callout:none) { 450 | @media (max-width: 768px) { 451 | /*.content {*/ 452 | /* margin-top: max(env(safe-area-inset-top, 12px), 20px);*/ 453 | /*}*/ 454 | #editor { 455 | box-shadow: 0 1px 5px rgba(0, 0, 0, 0.02), 0 2px 10px rgba(0, 0, 0, 0.01); 456 | } 457 | } 458 | } 459 | --------------------------------------------------------------------------------