chore: Inital commit

This commit is contained in:
florian 2024-03-21 20:05:26 +01:00
commit 8649e6dbdf
5 changed files with 311 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules
dist
.wrangler
.dev.vars
# Change them to your taste:
package-lock.json
yarn.lock
pnpm-lock.yaml
bun.lockb

8
README.md Normal file
View File

@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
npm run deploy
```

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"scripts": {
"dev": "wrangler dev src/index.ts",
"deploy": "wrangler deploy --minify src/index.ts"
},
"dependencies": {
"dayjs": "^1.11.10",
"hono": "^4.1.3",
"nostr-tools": "^2.3.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240208.0",
"@types/node": "^20.11.30",
"wrangler": "^3.32.0"
}
}

250
src/index.ts Normal file
View File

@ -0,0 +1,250 @@
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";
type Bindings = {
BLOSSOM_BUCKET: R2Bucket;
KV_BLOSSOM: KVNamespace;
PUBLIC_URL: string;
};
type BlobData = {
created: number;
type?: string;
size: number;
};
type BlobDescriptor = BlobData & {
sha256: string;
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>);
});
}
});
const getAuth = (authHeader?: string) => {
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;
if (!authEvent) {
throw new HTTPException(400, { message: "Nostr-Auth Token is invalid." });
}
if (!validateEvent(authEvent)) {
throw new HTTPException(400, { message: "Auth event is not valid." });
}
if (!verifyEvent(authEvent)) {
throw new HTTPException(400, {
message: "Auth event signature is not valid.",
});
}
if (authEvent.kind !== 24242) {
throw new HTTPException(400, { message: "Unexpected auth kind" });
}
const type = authEvent.tags.find((t) => t[0] === "t")?.[1] as AuthType;
if (!type) {
throw new HTTPException(400, { message: "Auth missing type" });
}
const now = dayjs().unix();
const expiration = authEvent.tags.find((t) => t[0] === "expiration")?.[1];
if (!expiration) {
throw new HTTPException(400, { message: "Auth missing expiration" });
}
if (parseInt(expiration) < now) {
throw new HTTPException(400, { message: "Auth expired" });
}
return {
event: authEvent,
type,
expiration,
};
};
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
return usedBefore;
};
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." });
}
const uploadBuffer = await c.req.raw.arrayBuffer();
if (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("");
const contentType = c.req.header("content-type");
const pubkey = auth.event.pubkey;
const storedObject = await c.env.BLOSSOM_BUCKET.put(key, uploadBuffer, {
sha256: key,
httpMetadata: { contentType },
customMetadata: {
pubkey,
},
});
const blobData: BlobData = {
size: storedObject.size,
type: storedObject.httpMetadata?.contentType,
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));
c.status(200);
return c.json({
...blobData,
url: c.env.PUBLIC_URL + "/" + key,
sha256: key,
});
}
});
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 (await authTokenWasUsedBefore(auth, c.env.KV_BLOSSOM)) {
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" });
}
try {
await c.env.BLOSSOM_BUCKET.delete(hash);
await c.env.KV_BLOSSOM.delete(pubkey + ":" + hash);
return c.body("");
} catch (e) {
throw new HTTPException(404, { message: "Blob not found." });
}
});
export default app;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true
}
}