chore: reformat, logo
This commit is contained in:
parent
8649e6dbdf
commit
b5f737ca37
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true
|
||||
}
|
268
src/index.ts
268
src/index.ts
@ -1,16 +1,14 @@
|
||||
import type {
|
||||
KVNamespace,
|
||||
R2Bucket,
|
||||
ReadableStream,
|
||||
} from "@cloudflare/workers-types";
|
||||
import dayjs from "dayjs";
|
||||
import { Hono } from "hono";
|
||||
import { cache } from "hono/cache";
|
||||
import { cors } from "hono/cors";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { stream } from "hono/streaming";
|
||||
import { validateEvent, verifyEvent } from "nostr-tools";
|
||||
import type { Event } from "nostr-tools";
|
||||
import { cache } from 'hono/cache';
|
||||
import { cors } from 'hono/cors';
|
||||
import { Hono } from 'hono';
|
||||
import { html } from 'hono/html';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { logger } from 'hono/logger';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { validateEvent, verifyEvent } from 'nostr-tools';
|
||||
import dayjs from 'dayjs';
|
||||
import type { KVNamespace, R2Bucket, ReadableStream } from '@cloudflare/workers-types';
|
||||
import type { Event } from 'nostr-tools';
|
||||
|
||||
type Bindings = {
|
||||
BLOSSOM_BUCKET: R2Bucket;
|
||||
@ -29,128 +27,43 @@ type BlobDescriptor = BlobData & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type AuthType = "upload" | "delete";
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
const parseHashFromPath = (path: string) => {
|
||||
const match = path.toLowerCase().match(/\/([0-9a-f]{64})(\.[a-z]+)?/);
|
||||
const hash = match && match[1];
|
||||
const ext = match && match[2];
|
||||
return { hash, ext };
|
||||
};
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
app.get("/list/:pubkey", async (c) => {
|
||||
const pubkey = c.req.param("pubkey");
|
||||
|
||||
const keyList = await c.env.KV_BLOSSOM.list({ prefix: pubkey + ":" });
|
||||
|
||||
// TODO paging is needed for >1000 blobs
|
||||
const finalList: any[] = [];
|
||||
await Promise.all(
|
||||
keyList.keys.map(async (key) => {
|
||||
const descriptor = await c.env.KV_BLOSSOM.get(key.name);
|
||||
const [_, blobKey] = key.name.split(":");
|
||||
|
||||
if (descriptor) {
|
||||
const blobDescriptor: BlobDescriptor = {
|
||||
...(JSON.parse(descriptor) as BlobData),
|
||||
sha256: blobKey,
|
||||
url: c.env.PUBLIC_URL + "/" + blobKey,
|
||||
};
|
||||
blobKey;
|
||||
finalList.push(blobDescriptor);
|
||||
}
|
||||
})
|
||||
);
|
||||
return await c.json(finalList);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"*",
|
||||
cache({
|
||||
cacheName: "my-app",
|
||||
cacheControl: "max-age=3600",
|
||||
})
|
||||
);
|
||||
|
||||
app.get("*", async (c) => {
|
||||
const { hash } = parseHashFromPath(c.req.path);
|
||||
if (!hash) {
|
||||
throw new HTTPException(400, { message: "Invalid path, hash missing" });
|
||||
}
|
||||
|
||||
if (c.req.method == "HEAD") {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.head(hash);
|
||||
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: "Blob hash not found." });
|
||||
}
|
||||
|
||||
c.status(200);
|
||||
return c.body("");
|
||||
} else {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.get(hash);
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: "Blob hash not found." });
|
||||
}
|
||||
|
||||
return stream(c, async (stream) => {
|
||||
// Write a process to be executed when aborted.
|
||||
stream.onAbort(() => {
|
||||
console.warn("Aborted!");
|
||||
});
|
||||
c.header(
|
||||
"Content-Type",
|
||||
blob.httpMetadata?.contentType || "application/octet-stream"
|
||||
);
|
||||
|
||||
// TODO maybe redirect if file > xx MB
|
||||
await stream.pipe(blob.body as ReadableStream<any>);
|
||||
});
|
||||
}
|
||||
});
|
||||
type AuthType = 'upload' | 'delete';
|
||||
|
||||
const getAuth = (authHeader?: string) => {
|
||||
if (!authHeader?.startsWith("Nostr "))
|
||||
throw new HTTPException(400, { message: "Missing Nostr: Auth Scheme." });
|
||||
if (!authHeader?.startsWith('Nostr ')) throw new HTTPException(400, { message: 'Missing Nostr: Auth Scheme.' });
|
||||
|
||||
const authEvent = authHeader
|
||||
? (JSON.parse(atob(authHeader.replace(/^Nostr\s/i, ""))) as Event)
|
||||
: undefined;
|
||||
const authEvent = authHeader ? (JSON.parse(atob(authHeader.replace(/^Nostr\s/i, ''))) as Event) : undefined;
|
||||
|
||||
if (!authEvent) {
|
||||
throw new HTTPException(400, { message: "Nostr-Auth Token is invalid." });
|
||||
throw new HTTPException(400, { message: 'Nostr-Auth Token is invalid.' });
|
||||
}
|
||||
|
||||
if (!validateEvent(authEvent)) {
|
||||
throw new HTTPException(400, { message: "Auth event is not valid." });
|
||||
throw new HTTPException(400, { message: 'Auth event is not valid.' });
|
||||
}
|
||||
if (!verifyEvent(authEvent)) {
|
||||
throw new HTTPException(400, {
|
||||
message: "Auth event signature is not valid.",
|
||||
message: 'Auth event signature is not valid.',
|
||||
});
|
||||
}
|
||||
|
||||
if (authEvent.kind !== 24242) {
|
||||
throw new HTTPException(400, { message: "Unexpected auth kind" });
|
||||
throw new HTTPException(400, { message: 'Unexpected auth kind' });
|
||||
}
|
||||
|
||||
const type = authEvent.tags.find((t) => t[0] === "t")?.[1] as AuthType;
|
||||
const type = authEvent.tags.find((t) => t[0] === 't')?.[1] as AuthType;
|
||||
if (!type) {
|
||||
throw new HTTPException(400, { message: "Auth missing type" });
|
||||
throw new HTTPException(400, { message: 'Auth missing type' });
|
||||
}
|
||||
|
||||
const now = dayjs().unix();
|
||||
const expiration = authEvent.tags.find((t) => t[0] === "expiration")?.[1];
|
||||
const expiration = authEvent.tags.find((t) => t[0] === 'expiration')?.[1];
|
||||
if (!expiration) {
|
||||
throw new HTTPException(400, { message: "Auth missing expiration" });
|
||||
throw new HTTPException(400, { message: 'Auth missing expiration' });
|
||||
}
|
||||
|
||||
if (parseInt(expiration) < now) {
|
||||
throw new HTTPException(400, { message: "Auth expired" });
|
||||
throw new HTTPException(400, { message: 'Auth expired' });
|
||||
}
|
||||
|
||||
return {
|
||||
@ -160,38 +73,121 @@ const getAuth = (authHeader?: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const authTokenWasUsedBefore = async (
|
||||
auth: { event: Event; type: AuthType },
|
||||
kv: KVNamespace
|
||||
) => {
|
||||
const authTokenWasUsedBefore = async (auth: { event: Event; type: AuthType }, kv: KVNamespace) => {
|
||||
const key = `auth:${auth.type}:${auth.event.id}`;
|
||||
|
||||
const usedBefore = (await kv.get(key)) != null;
|
||||
|
||||
await kv.put(key, "used", { expirationTtl: 60 * 60 * 24 * 10 }); // 10 days
|
||||
await kv.put(key, 'used', { expirationTtl: 60 * 60 * 24 * 10 }); // 10 days
|
||||
|
||||
return usedBefore;
|
||||
};
|
||||
|
||||
app.put("/upload", async (c) => {
|
||||
const auth = getAuth(c.req.header("authorization") as string);
|
||||
const parseHashFromPath = (path: string) => {
|
||||
const match = path.toLowerCase().match(/\/([0-9a-f]{64})(\.[a-z]+)?/);
|
||||
const hash = match && match[1];
|
||||
const ext = match && match[2];
|
||||
return { hash, ext };
|
||||
};
|
||||
|
||||
if (auth.type !== "upload") {
|
||||
throw new HTTPException(400, { message: "Auth type not allowed." });
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.use(cors());
|
||||
app.use(logger());
|
||||
|
||||
app.get('/', (c) =>
|
||||
c.html(
|
||||
html`<!DOCTYPE html>
|
||||
<body style="font-size: 5vw;background-color: #111;position: fixed;top: 50%;left: 50%;translate: -50% -50%;">
|
||||
🌸
|
||||
</body> `
|
||||
)
|
||||
);
|
||||
|
||||
app.get(
|
||||
cache({
|
||||
cacheName: 'blossom',
|
||||
cacheControl: 'max-age=604800',
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/list/:pubkey', async (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
|
||||
const keyList = await c.env.KV_BLOSSOM.list({ prefix: pubkey + ':' });
|
||||
|
||||
// TODO paging is needed for >1000 blobs
|
||||
const finalList: any[] = [];
|
||||
await Promise.all(
|
||||
keyList.keys.map(async (key) => {
|
||||
const descriptor = await c.env.KV_BLOSSOM.get(key.name);
|
||||
const [_, blobKey] = key.name.split(':');
|
||||
|
||||
if (descriptor) {
|
||||
const blobDescriptor: BlobDescriptor = {
|
||||
...(JSON.parse(descriptor) as BlobData),
|
||||
sha256: blobKey,
|
||||
url: c.env.PUBLIC_URL + '/' + blobKey,
|
||||
};
|
||||
blobKey;
|
||||
finalList.push(blobDescriptor);
|
||||
}
|
||||
})
|
||||
);
|
||||
return await c.json(finalList);
|
||||
});
|
||||
|
||||
app.get('*', async (c) => {
|
||||
const { hash } = parseHashFromPath(c.req.path);
|
||||
if (!hash) {
|
||||
throw new HTTPException(400, { message: 'Invalid path, hash missing' });
|
||||
}
|
||||
|
||||
if (c.req.method == 'HEAD') {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.head(hash);
|
||||
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: 'Blob hash not found.' });
|
||||
}
|
||||
|
||||
return c.body(null, 204);
|
||||
} else {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.get(hash);
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: 'Blob hash not found.' });
|
||||
}
|
||||
|
||||
return stream(c, async (stream) => {
|
||||
// Write a process to be executed when aborted.
|
||||
stream.onAbort(() => {
|
||||
console.warn('Aborted!');
|
||||
});
|
||||
c.header('Content-Type', blob.httpMetadata?.contentType || 'application/octet-stream');
|
||||
|
||||
// TODO maybe redirect if file > xx MB
|
||||
await stream.pipe(blob.body as ReadableStream<any>);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/upload', async (c) => {
|
||||
const auth = getAuth(c.req.header('authorization') as string);
|
||||
|
||||
if (auth.type !== 'upload') {
|
||||
throw new HTTPException(400, { message: 'Auth type not allowed.' });
|
||||
}
|
||||
|
||||
if (await authTokenWasUsedBefore(auth, c.env.KV_BLOSSOM)) {
|
||||
throw new HTTPException(400, { message: "Token was used before." });
|
||||
throw new HTTPException(400, { message: 'Token was used before.' });
|
||||
}
|
||||
|
||||
const uploadBuffer = await c.req.raw.arrayBuffer();
|
||||
if (uploadBuffer) {
|
||||
const keyBuffer = await crypto.subtle.digest("SHA-256", uploadBuffer);
|
||||
const keyBuffer = await crypto.subtle.digest('SHA-256', uploadBuffer);
|
||||
const key = Array.from(new Uint8Array(keyBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const contentType = c.req.header("content-type");
|
||||
const contentType = c.req.header('content-type');
|
||||
const pubkey = auth.event.pubkey;
|
||||
|
||||
const storedObject = await c.env.BLOSSOM_BUCKET.put(key, uploadBuffer, {
|
||||
@ -208,42 +204,42 @@ app.put("/upload", async (c) => {
|
||||
created: new Date(storedObject.uploaded).getTime(),
|
||||
};
|
||||
|
||||
console.log(`writing to ${pubkey + ":" + key}`, JSON.stringify(blobData));
|
||||
await c.env.KV_BLOSSOM.put(pubkey + ":" + key, JSON.stringify(blobData));
|
||||
console.log(`writing to ${pubkey + ':' + key}`, JSON.stringify(blobData));
|
||||
await c.env.KV_BLOSSOM.put(pubkey + ':' + key, JSON.stringify(blobData));
|
||||
|
||||
c.status(200);
|
||||
return c.json({
|
||||
...blobData,
|
||||
url: c.env.PUBLIC_URL + "/" + key,
|
||||
url: c.env.PUBLIC_URL + '/' + key,
|
||||
sha256: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("*", async (c) => {
|
||||
const auth = getAuth(c.req.header("authorization") as string);
|
||||
app.delete('*', async (c) => {
|
||||
const auth = getAuth(c.req.header('authorization') as string);
|
||||
|
||||
if (auth.type !== "delete") {
|
||||
throw new HTTPException(400, { message: "Auth type not allowed." });
|
||||
if (auth.type !== 'delete') {
|
||||
throw new HTTPException(400, { message: 'Auth type not allowed.' });
|
||||
}
|
||||
|
||||
if (await authTokenWasUsedBefore(auth, c.env.KV_BLOSSOM)) {
|
||||
throw new HTTPException(400, { message: "Token was used before." });
|
||||
throw new HTTPException(400, { message: 'Token was used before.' });
|
||||
}
|
||||
|
||||
const pubkey = auth.event.pubkey;
|
||||
const { hash } = parseHashFromPath(c.req.path);
|
||||
|
||||
if (!hash) {
|
||||
throw new HTTPException(400, { message: "Invalid path, hash missing" });
|
||||
throw new HTTPException(400, { message: 'Invalid path, hash missing' });
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.BLOSSOM_BUCKET.delete(hash);
|
||||
await c.env.KV_BLOSSOM.delete(pubkey + ":" + hash);
|
||||
return c.body("");
|
||||
await c.env.KV_BLOSSOM.delete(pubkey + ':' + hash);
|
||||
c.body(null, 204);
|
||||
} catch (e) {
|
||||
throw new HTTPException(404, { message: "Blob not found." });
|
||||
throw new HTTPException(404, { message: 'Blob not found.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user