Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Calixte
cedb29949c chore: docker compose for coolify 2026-03-14 20:27:15 +01:00
Julien Calixte
4e54d51d14 fix: skip body and Content-Type for GET/HEAD webhooks 2026-03-14 18:32:01 +01:00
Julien Calixte
8c9ab34565 feat: add optional bearer token support for webhook subscriptions
Token is stored in the DB but never returned in API responses (write-only).
fireWebhooks() sends Authorization: Bearer <token> header when present.
2026-03-14 18:16:28 +01:00
Julien Calixte
06ac3142a8 feat: add POST /notes/feed endpoint for multi-DID filtering 2026-03-10 15:51:10 +01:00
Julien Calixte
425dd96872 feat: update content length 2026-03-08 09:00:03 +01:00
Julien Calixte
8ff1e4acaa feat: get language in server too 2026-03-01 18:49:43 +01:00
Julien Calixte
6374566316 feat: add language support 2026-03-01 18:34:17 +01:00
Julien Calixte
2b54c8dd00 feat: add language support for better filtering 2026-03-01 17:37:59 +01:00
Julien Calixte
29e8a63cb3 refactor: rename listed → discoverable on note
Pure rename — no behavioral change. "discoverable" more clearly
communicates that the field controls whether a note can be found
by others in public listings.
2026-02-25 23:20:37 +01:00
Julien Calixte
f39f62f1c7 feat: add listed field to note for public listing visibility
Notes with listed=false are filtered out from all GET /notes queries.
The field defaults to true so existing and new notes without it remain visible.
Migration handles existing databases with ALTER TABLE ADD COLUMN.
2026-02-25 23:03:00 +01:00
7 changed files with 157 additions and 27 deletions

View File

@@ -1,21 +1,17 @@
services:
jetstream:
image: docker.li212.fr/litenote:latest
build: .
restart: unless-stopped
command: ["sh", "-c", "deno task migrate && deno task jetstream:prod"]
env_file:
- .env
volumes:
- ${DATA_VOLUME:-data}:/data
api:
image: docker.li212.fr/litenote:latest
build: .
restart: unless-stopped
command: ["deno", "task", "server:prod"]
ports:
- "${PORT}:8080"
env_file:
- .env
expose:
- "8080"
volumes:
- ${DATA_VOLUME:-data}:/data

View File

@@ -24,13 +24,17 @@ const fireWebhooks = async (
const webhooks = getWebhooksByDid(did);
if (webhooks.length === 0) return;
const results = await Promise.allSettled(
webhooks.map(({ method, url }) =>
fetch(url, {
webhooks.map(({ method, url, token }) => {
const hasBody = method !== "GET" && method !== "HEAD";
return fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
),
headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: hasBody ? JSON.stringify(payload) : undefined,
});
}),
);
for (const result of results) {
if (result.status === "rejected") {

View File

@@ -17,7 +17,64 @@
"content": {
"type": "string",
"description": "Markdown content. Local image paths are replaced with blob CIDs at publish time.",
"maxLength": 10000
"maxLength": 30000
},
"language": {
"type": "string",
"description": "Most used language in the note. In ISO 639-3 code.",
"maxLength": 10,
"knownValues": [
"afr",
"ara",
"aze",
"bel",
"ben",
"bul",
"cat",
"ces",
"ckb",
"cmn",
"dan",
"deu",
"ell",
"eng",
"est",
"eus",
"fin",
"fra",
"hau",
"heb",
"hin",
"hrv",
"hun",
"hye",
"ind",
"isl",
"ita",
"jpn",
"kat",
"kaz",
"kor",
"lit",
"mar",
"mkd",
"nld",
"nob",
"pes",
"pol",
"por",
"ron",
"run",
"rus",
"slk",
"spa",
"srp",
"swe",
"tgl",
"tur",
"ukr",
"vie"
]
},
"images": {
"type": "array",
@@ -46,6 +103,10 @@
"type": "string",
"description": "Display theme for the note.",
"knownValues": ["light", "dark"]
},
"discoverable": {
"type": "boolean",
"description": "Whether the note can be discovered by others in public listings. Defaults to true."
}
}
}

View File

@@ -4,6 +4,7 @@ import {
deleteWebhooksByDid,
getNotes,
getNotesByDid,
getNotesByDids,
} from "./src/data/db.ts";
import { log } from "./src/log.ts";
@@ -28,16 +29,27 @@ router.get("/:did/notes", (ctx) => {
ctx.response.body = getNotesByDid(did, cursor, limit);
});
router.post("/notes/feed", async (ctx) => {
const body = await ctx.request.body.json();
const { dids, cursor, limit } = body ?? {};
if (!Array.isArray(dids) || dids.length === 0) {
ctx.response.status = 400;
ctx.response.body = { error: "dids must be a non-empty array" };
return;
}
ctx.response.body = getNotesByDids(dids, cursor, Number(limit) || PAGINATION);
});
router.post("/:did/webhooks", async (ctx) => {
const { did } = ctx.params;
const body = await ctx.request.body.json();
const { method, url } = body ?? {};
const { method, url, token } = body ?? {};
if (!method || !url) {
ctx.response.status = 400;
ctx.response.body = { error: "method and url are required" };
return;
}
const subscription = addWebhookSubscription({ did, method, url });
const subscription = addWebhookSubscription({ did, method, url, token });
ctx.response.status = 201;
ctx.response.body = subscription;
});

View File

@@ -22,10 +22,10 @@ type NoteRow = {
export const getNotes = (cursor?: string, limit = 20) => {
const notes = cursor
? db.prepare(
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE rkey < ? ORDER BY rkey DESC LIMIT ?",
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND rkey < ? ORDER BY rkey DESC LIMIT ?",
).all<NoteRow>(cursor, limit)
: db.prepare(
"SELECT did, rkey, title, publishedAt, createdAt FROM note ORDER BY rkey DESC LIMIT ?",
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 ORDER BY rkey DESC LIMIT ?",
).all<NoteRow>(limit);
return {
@@ -37,10 +37,10 @@ export const getNotes = (cursor?: string, limit = 20) => {
export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
const notes = cursor
? db.prepare(
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
).all<NoteRow>(did, cursor, limit)
: db.prepare(
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE did = ? ORDER BY rkey DESC LIMIT ?",
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? ORDER BY rkey DESC LIMIT ?",
).all<NoteRow>(did, limit);
return {
@@ -49,6 +49,23 @@ export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
};
};
export const getNotesByDids = (dids: string[], cursor?: string, limit = 20) => {
if (dids.length === 0) return { notes: [] };
const placeholders = dids.map(() => "?").join(", ");
const notes = cursor
? db.prepare(
`SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did IN (${placeholders}) AND rkey < ? ORDER BY rkey DESC LIMIT ?`,
).all<NoteRow>(...dids, cursor, limit)
: db.prepare(
`SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did IN (${placeholders}) ORDER BY rkey DESC LIMIT ?`,
).all<NoteRow>(...dids, limit);
return {
notes,
cursor: notes.length === limit ? notes[notes.length - 1].rkey : undefined,
};
};
export const deleteNote = ({ did, rkey }: { did: string; rkey: string }) => {
db.exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey);
};
@@ -72,20 +89,23 @@ type WebhookSubscriptionRow = {
did: string;
method: string;
url: string;
token?: string;
};
export const addWebhookSubscription = (
{ did, method, url }: Omit<WebhookSubscriptionRow, "id">,
{ did, method, url, token }: Omit<WebhookSubscriptionRow, "id">,
): WebhookSubscriptionRow => {
db.exec(
"INSERT INTO webhook_subscription (did, method, url) VALUES (?, ?, ?)",
"INSERT INTO webhook_subscription (did, method, url, token) VALUES (?, ?, ?, ?)",
did,
method,
url,
token ?? null,
);
return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()",
).get<WebhookSubscriptionRow>()!;
// Note: token is intentionally excluded from the SELECT (write-only)
};
export const deleteWebhooksByDid = (did: string): void => {
@@ -94,7 +114,7 @@ export const deleteWebhooksByDid = (did: string): void => {
export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => {
return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
"SELECT id, did, method, url, token FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
).all<WebhookSubscriptionRow>(did);
};
@@ -107,18 +127,24 @@ export const upsertNote = (note: Note) => {
publishedAt,
createdAt,
did,
rkey
rkey,
discoverable,
language
)
VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, rkey)
DO UPDATE SET
title = excluded.title,
publishedAt = excluded.publishedAt
publishedAt = excluded.publishedAt,
discoverable = excluded.discoverable,
language = excluded.language
`,
note.title,
note.publishedAt ? new Date(note.publishedAt).toISOString() : now,
note.createdAt ? new Date(note.createdAt).toISOString() : now,
note.did,
note.rkey,
note.discoverable !== false ? 1 : 0,
note.language,
);
};

View File

@@ -4,4 +4,6 @@ export type Note = {
title: string;
publishedAt: string;
createdAt: string;
discoverable?: boolean;
language?: string
};

View File

@@ -7,10 +7,33 @@ db.exec(`
createdAt DATETIME NOT NULL,
did TEXT NOT NULL,
rkey TEXT NOT NULL,
discoverable INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (did, rkey)
);
`);
try {
db.exec(
`ALTER TABLE note ADD COLUMN listed INTEGER NOT NULL DEFAULT 1;`,
);
} catch {
// Column already exists — no-op
}
try {
db.exec(
`ALTER TABLE note ADD COLUMN language STRING;`,
);
} catch {
// Column already exists — no-op
}
try {
db.exec(`ALTER TABLE note RENAME COLUMN listed TO discoverable;`);
} catch {
// Column already renamed or doesn't exist — no-op
}
db.exec(`
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
@@ -32,4 +55,10 @@ db.exec(`
ON webhook_subscription(did);
`);
try {
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
} catch {
// Column already exists — no-op
}
db.close();