feat: slug and password

This commit is contained in:
lyonbot committed 2025-01-10 14:33:09 +08:00
1 parent 9b68c710ef
commit 1fcfed1100
14 files changed
+187 -28

No files matched your search

+20
View File
@@ -24,6 +24,9 @@ importers:
web:
dependencies:
jotai:
specifier: ^2.11.0
version: 2.11.0(@types/react@19.0.2)(react@19.0.0)
react:
specifier: ^19.0.0
version: 19.0.0
@@ -2126,6 +2129,18 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
jotai@2.11.0:
resolution: {integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -5270,6 +5285,11 @@ snapshots:
jiti@2.4.2: {}
jotai@2.11.0(@types/react@19.0.2)(react@19.0.0):
optionalDependencies:
'@types/react': 19.0.2
react: 19.0.0
js-tokens@4.0.0: {}
js-yaml@4.1.0:
+25 -5
View File
@@ -3,6 +3,7 @@ export async function migrateTables(db: D1Database) {
`
CREATE TABLE IF NOT EXISTS upload_record (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
uploader TEXT NOT NULL,
ctime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
size INTEGER DEFAULT 0,
@@ -15,6 +16,7 @@ export async function migrateTables(db: D1Database) {
export interface UploadRecord {
id: number;
slug: string;
uploader: string;
ctime: number;
size: number;
@@ -37,6 +39,7 @@ function fromDB(data: any): UploadRecord {
return {
id: data.id,
slug: data.slug,
uploader: data.uploader,
ctime: +new Date(data.ctime),
size: data.size,
@@ -48,6 +51,7 @@ function fromDB(data: any): UploadRecord {
function toDB(record: UploadRecord) {
return {
id: record.id,
slug: record.slug,
uploader: record.uploader,
ctime: new Date(+record.ctime),
size: record.size,
@@ -72,7 +76,7 @@ function toDB(record: UploadRecord) {
*
* to fetch older records, pass `beforeId` option (which is last id of prev page)
*/
export async function getUploadRecords(
export async function listUploadRecords(
db: D1Database,
opts: { beforeId?: number } = {}
) {
@@ -91,7 +95,7 @@ export async function getUploadRecords(
return records;
}
export async function getSingleUploadRecord(db: D1Database, id: number) {
export async function getUploadRecord(db: D1Database, id: number) {
const record = await db
.prepare(`SELECT * FROM upload_record WHERE id = ?`)
.bind(id)
@@ -100,16 +104,31 @@ export async function getSingleUploadRecord(db: D1Database, id: number) {
return fromDB(record);
}
export async function getUploadRecordBySlug(db: D1Database, slug: string) {
const record = await db
.prepare(`SELECT * FROM upload_record WHERE slug = ?`)
.bind(slug)
.first<UploadRecord>();
if (!record) return null;
return fromDB(record);
}
export function randomId() {
return Date.now().toString(36) + Math.random().toString(36).slice(-4);
}
export async function createUploadRecord(
db: D1Database,
record: Omit<UploadRecord, "id" | "ctime">
record: Omit<UploadRecord, "id" | "ctime" | "slug"> & { slug?: string }
) {
const inserting = toDB(record as UploadRecord);
const slug = inserting.slug || randomId();
const res = await db
.prepare(
"INSERT INTO upload_record (uploader, size, files, message) VALUES (?, ?, ?, ?)"
"INSERT INTO upload_record (slug, uploader, size, files, message) VALUES (?, ?, ?, ?, ?)"
)
.bind(
slug,
inserting.uploader,
inserting.size,
inserting.files,
@@ -121,6 +140,7 @@ export async function createUploadRecord(
...(record as UploadRecord),
ctime: +new Date(),
id,
slug,
};
return { id, inserted };
}
@@ -156,7 +176,7 @@ export async function deleteRecord(
deleteFiles: (path: string[]) => Promise<void>
) {
// 1. get record
const record = await getSingleUploadRecord(db, id);
const record = await getUploadRecord(db, id);
if (!record) return;
// 2. delete files
+21 -10
View File
@@ -2,25 +2,36 @@ import { Hono } from "hono";
import {
createUploadRecord,
deleteRecord,
getSingleUploadRecord,
getUploadRecords,
listUploadRecords,
getUploadRecordBySlug,
migrateTables,
purgeRecordsBeforeId,
} from "./database";
import { H } from "hono/types";
type Bindings = {
ASSETS: { fetch: typeof fetch };
DB: D1Database;
MY_BUCKET: R2Bucket;
PASSWORD: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get("/api/list", async (c) => {
const authWithPassword: H<{ Bindings: Bindings }> = async (c, next) => {
if (c.env.PASSWORD && c.req.header("x-password") !== c.env.PASSWORD) {
c.status(401);
return c.json({ error: "Password required" });
}
return await next();
};
app.get("/api/list", authWithPassword, async (c) => {
await migrateTables(c.env.DB);
const beforeId = +c.req.query("beforeId")!;
const list = await getUploadRecords(c.env.DB, { beforeId });
const list = await listUploadRecords(c.env.DB, { beforeId });
return c.json(list);
// const r = await createUploadRecord(c.env.DB, {
@@ -32,7 +43,7 @@ app.get("/api/list", async (c) => {
// return c.json(r)
});
app.post("/api/upload", async (c) => {
app.post("/api/upload", authWithPassword, async (c) => {
const uploader = c.req.header("x-uploader") || "unknown";
const body = await c.req.formData();
@@ -73,10 +84,10 @@ app.post("/api/upload", async (c) => {
return c.json({ record });
});
app.get("/api/download/:id/:index", async (c) => {
const id = +c.req.param("id");
app.get("/api/download/:slug/:index", async (c) => {
const slug = c.req.param("slug");
const index = c.req.param("index");
const record = await getSingleUploadRecord(c.env.DB, id);
const record = await getUploadRecordBySlug(c.env.DB, slug);
if (!record) {
return c.status(404);
}
@@ -113,14 +124,14 @@ app.get("/api/download/:id/:index", async (c) => {
return new Response(r.body, { headers });
});
app.post("/api/delete", async (c) => {
app.post("/api/delete", authWithPassword, async (c) => {
const body = await c.req.json();
const id = +body.id;
await deleteRecord(c.env.DB, id, (paths) => c.env.MY_BUCKET.delete(paths));
return c.json({ ok: true });
});
app.post("/api/purge", async (c) => {
app.post("/api/purge", authWithPassword, async (c) => {
const beforeId = 9999999;
await purgeRecordsBeforeId(c.env.DB, beforeId, (paths) =>
c.env.MY_BUCKET.delete(paths)
+1
View File
@@ -8,6 +8,7 @@
"preview": "rsbuild preview"
},
"dependencies": {
"jotai": "^2.11.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"swr": "^2.3.0",
+5
View File
@@ -15,6 +15,11 @@ textarea {
font-family: inherit;
font-size: inherit;
border: 0;
outline: 0px solid transparent;
&:focus {
@apply ring ring-brand-3 outline-1 outline-brand-6;
}
}
button {
+6 -1
View File
@@ -2,6 +2,9 @@ import { useState } from 'react';
import './App.scss';
import { ContentInput } from './components/ContentInput';
import { UploadRecords } from './components/UploadRecords';
import { $password } from './store/auth';
import { store } from './store/store';
import { PasswordInput } from './components/PasswordInput';
const App = () => {
const [progress, setProgress] = useState(0);
@@ -15,6 +18,7 @@ const App = () => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.setRequestHeader('x-uploader', 'yon');
xhr.setRequestHeader('x-password', store.get($password));
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
@@ -29,7 +33,7 @@ const App = () => {
if (xhr.status === 200) {
// const data = JSON.parse(xhr.responseText);
setProgress(0);
window.dispatchEvent(new Event('upload-complete'));
window.dispatchEvent(new Event('records-updated'));
resolve();
return;
}
@@ -53,6 +57,7 @@ const App = () => {
<div className="flex-1 overflow-auto">
<UploadRecords />
</div>
<PasswordInput />
</div>
);
};
+1 -1
View File
@@ -155,7 +155,7 @@ export const ContentInput = memo<Props>((props) => {
}
}}
>
<div className="flex mb-4 rounded-md shadow overflow-hidden outline-1 outline-solid outline-transparent focus-within:outline-brand-6">
<div className="flex mb-4 rounded-md shadow overflow-hidden outline-1 outline-solid outline-transparent focus-within:outline-brand-6 focus-within:ring focus-within:ring-brand-3">
<textarea
ref={textAreaRef}
value={text}
+49
View File
@@ -0,0 +1,49 @@
import { useAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { $password, $passwordInvalid, fetchAPI } from '../store/auth';
export const PasswordInput = () => {
const [visible, setVisible] = useAtom($passwordInvalid);
const [password, setPassword] = useAtom($password);
const [validating, setValidating] = useState(false);
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setValidating(true);
fetchAPI('/api/list')
.then(() => {
window.dispatchEvent(new Event('records-updated'));
setVisible(false);
setValidating(false);
})
.catch(() => {
setValidating(false);
});
}, []);
return (
!!visible && (
<div className="fixed z-50 inset-0 bg-gray/60 flex items-center justify-center p-8">
<form className="bg-white rounded-lg p-4 max-w-md w-full" onSubmit={handleSubmit}>
<h2 className="text-xl font-bold mb-4">Password required</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
className="block w-full rounded-md border-1 border-solid border-gray-300 shadow-sm focus:border-brand-6 p-2 sm:text-sm outline-0"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
<div className="flex items-center justify-between">
<button type="submit" disabled={validating}>
{validating ? 'Validating...' : 'OK'}
</button>
</div>
</form>
</div>
)
);
};
+9 -7
View File
@@ -1,6 +1,7 @@
import { memo, useEffect, useMemo } from 'react';
import useSWRInfinite from 'swr/infinite';
import type { UploadRecord } from '../../../src/database';
import { fetchAPI } from '../store/auth';
interface Props {}
@@ -8,15 +9,16 @@ export const UploadRecords = memo<Props>((props) => {
// all records. newest first
const { data, error, isLoading, isValidating, mutate, size, setSize } = useSWRInfinite(
(_, page?: UploadRecord[]) => (page ? String(page?.at(-1)?.id ?? '') : 'init'),
(beforeId) => fetch('/api/list?beforeId=' + beforeId).then((res) => res.json() as Promise<UploadRecord[]>),
(beforeId) =>
fetchAPI('/api/list?beforeId=' + beforeId).then((res) => res.json() as Promise<UploadRecord[]>),
);
useEffect(() => {
const refresh = () => {
mutate();
};
window.addEventListener('upload-complete', refresh);
return () => window.removeEventListener('upload-complete', refresh);
window.addEventListener('records-updated', refresh);
return () => window.removeEventListener('records-updated', refresh);
}, [mutate]);
return (
@@ -76,7 +78,7 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => {
className={`${actionLink} py-2`}
target="_blank"
rel="noreferrer"
href={`/api/download/${encodeURIComponent(props.record.id)}/message`}
href={`/api/download/${encodeURIComponent(props.record.slug)}/message`}
download={`${props.record.id}.txt`}
>
<i className="i-mdi-download mr-1"></i>
@@ -97,7 +99,7 @@ const UploadRecordItem = memo((props: { record: UploadRecord }) => {
{files.length > 0 && (
<div className="flex flex-wrap m--2">
{files.map((file, index) => {
const link = `/api/download/${props.record.id}/${index}`;
const link = `/api/download/${props.record.slug}/${index}`;
return (
<div key={file.path} className="flex gap-2 p-2 max-w-sm min-w-0">
<a
@@ -157,7 +159,7 @@ function copyToClipboard(text: string) {
function deleteRecord(id: number) {
if (!confirm('Are you sure you want to delete this record?')) return;
fetch('/api/delete', {
fetchAPI('/api/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -167,6 +169,6 @@ function deleteRecord(id: number) {
.then((res) => res.json())
.then((data) => {
console.log('deleted data', data);
window.dispatchEvent(new Event('upload-complete'));
window.dispatchEvent(new Event('records-updated'));
});
}
+5 -1
View File
@@ -1,5 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'jotai';
import { store } from './store/store';
import App from './App';
import 'uno.css';
import './sw-client';
@@ -9,7 +11,9 @@ if (rootEl) {
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
);
}
+38
View File
@@ -0,0 +1,38 @@
import { atom } from 'jotai';
import { store } from './store';
const passwordLocalKey = 'cf-drop-password';
const passwordStrAtom = atom(localStorage.getItem(passwordLocalKey) || '');
export const $password = atom(
(get) => get(passwordStrAtom),
(_, set, newValue: string) => {
localStorage.setItem(passwordLocalKey, newValue);
set(passwordStrAtom, newValue);
},
);
export const $passwordInvalid = atom(false);
export function fetchAPI(input: RequestInfo | URL, init?: RequestInit) {
const password = store.get($password);
if (password) {
init = {
...init,
headers: {
...init?.headers,
'x-password': password,
},
};
}
return fetch(input, init).then((res) => {
if (res.status === 401) {
store.set($passwordInvalid, true);
throw new Error('Password required');
}
store.set($passwordInvalid, false);
return res;
});
}
+4
View File
@@ -0,0 +1,4 @@
import { createStore } from "jotai";
export const store = createStore();
+1 -1
View File
@@ -5,7 +5,7 @@ if ('serviceWorker' in navigator) {
wb.addEventListener('installed', (event) => {
if (event.isUpdate) {
const toast = document.createElement('div');
toast.className = 'bg-brand-6 text-white text-sm p-2 rounded-md fixed bottom-0 left-0 right-0 z-50 animate-slide-in-up animate-duration-200';
toast.className = 'bg-brand-6 text-white text-sm p-2 fixed top-0 left-0 right-0 z-50 animate-slide-in-down animate-duration-200';
toast.innerText = 'New version available. click to refresh';
document.body.appendChild(toast);
toast.addEventListener('click', () => {
+2 -2
View File
@@ -14,8 +14,8 @@ database_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
binding = "MY_BUCKET"
bucket_name = "cf-drop"
# [vars]
# MY_VAR = "my-variable"
[vars]
PASSWORD = ""
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"