feat: slug and password
This commit is contained in:
1 parent
9b68c710ef
commit
1fcfed1100
14 files changed
+187
-28
No files matched your search
Generated
+20
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createStore } from "jotai";
|
||||
|
||||
|
||||
export const store = createStore();
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user