feat: set up public inbox api server (#52)
* feat: set up public inbox api server * feat: add zod validation for raw inbox item * chore: update encrypted item type && raw inbox item schema * feat: use symmetric & asymmetric combination for inbox encryption * chore: improve error handling * chore: update encrypted item type * feat: add Dockerfile for Notesnook.Inbox.Api
This commit is contained in:
173
Notesnook.Inbox.API/src/index.ts
Normal file
173
Notesnook.Inbox.API/src/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import express from "express";
|
||||
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
|
||||
import { z } from "zod";
|
||||
|
||||
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
|
||||
if (!NOTESNOOK_API_SERVER_URL) {
|
||||
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
|
||||
}
|
||||
|
||||
let sodium: typeof _sodium;
|
||||
|
||||
const RawInboxItemSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
pinned: z.boolean().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
readonly: z.boolean().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
notebookIds: z.array(z.string()).optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
type: z.enum(["note"]),
|
||||
source: z.string(),
|
||||
version: z.literal(1),
|
||||
content: z
|
||||
.object({
|
||||
type: z.enum(["html"]),
|
||||
data: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface EncryptedInboxItem {
|
||||
v: 1;
|
||||
key: Omit<EncryptedInboxItem, "key" | "iv" | "v">;
|
||||
iv: string;
|
||||
alg: string;
|
||||
cipher: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem {
|
||||
try {
|
||||
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
);
|
||||
const data = sodium.from_string(rawData);
|
||||
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
data,
|
||||
null,
|
||||
null,
|
||||
nonce,
|
||||
password
|
||||
);
|
||||
const inboxPublicKey = sodium.from_base64(
|
||||
publicKey,
|
||||
base64_variants.URLSAFE_NO_PADDING
|
||||
);
|
||||
const encryptedPassword = sodium.crypto_box_seal(password, inboxPublicKey);
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
key: {
|
||||
cipher: sodium.to_base64(
|
||||
encryptedPassword,
|
||||
base64_variants.URLSAFE_NO_PADDING
|
||||
),
|
||||
alg: `xsal-x25519-${base64_variants.URLSAFE_NO_PADDING}`,
|
||||
length: password.length,
|
||||
},
|
||||
iv: sodium.to_base64(nonce, base64_variants.URLSAFE_NO_PADDING),
|
||||
alg: `xcha-argon2i13-${base64_variants.URLSAFE_NO_PADDING}`,
|
||||
cipher: sodium.to_base64(cipher, base64_variants.URLSAFE_NO_PADDING),
|
||||
length: data.length,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`encryption failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getInboxPublicEncryptionKey(apiKey: string) {
|
||||
const response = await fetch(
|
||||
`${NOTESNOOK_API_SERVER_URL}inbox/public-encryption-key`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`failed to fetch inbox public encryption key: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown as any;
|
||||
return (data?.key as string) || null;
|
||||
}
|
||||
|
||||
async function postEncryptedInboxItem(
|
||||
apiKey: string,
|
||||
item: EncryptedInboxItem
|
||||
) {
|
||||
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}inbox/items`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: apiKey,
|
||||
},
|
||||
body: JSON.stringify({ ...item }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to post inbox item: ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.post("/inbox", async (req, res) => {
|
||||
try {
|
||||
const apiKey = req.headers["authorization"];
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
if (!req.body.item) {
|
||||
return res.status(400).json({ error: "item is required" });
|
||||
}
|
||||
|
||||
const validationResult = RawInboxItemSchema.safeParse(req.body.item);
|
||||
if (!validationResult.success) {
|
||||
return res.status(400).json({
|
||||
error: "invalid item",
|
||||
details: validationResult.error.issues,
|
||||
});
|
||||
}
|
||||
|
||||
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
|
||||
if (!inboxPublicKey) {
|
||||
return res.status(403).json({ error: "inbox public key not found" });
|
||||
}
|
||||
console.log("[info] fetched inbox public key:", inboxPublicKey);
|
||||
|
||||
const item = validationResult.data;
|
||||
const encryptedItem = encrypt(JSON.stringify(item), inboxPublicKey);
|
||||
console.log("[info] encrypted item:", encryptedItem);
|
||||
await postEncryptedInboxItem(apiKey, encryptedItem);
|
||||
return res.status(200).json({ message: "inbox item posted" });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log("[error]", error.message);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "internal server error", description: error.message });
|
||||
} else {
|
||||
console.log("[error] unknown error occured:", error);
|
||||
return res.status(500).json({
|
||||
error: "internal server error",
|
||||
description: `unknown error occured: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await _sodium.ready;
|
||||
sodium = _sodium;
|
||||
|
||||
const PORT = Number(process.env.PORT || "5181");
|
||||
app.listen(PORT, () => {
|
||||
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
|
||||
});
|
||||
})();
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user