Files
markone/packages/server/src/bookmark/handlers.ts

242 lines
6.3 KiB
TypeScript
Raw Normal View History

2025-05-21 13:18:16 +01:00
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark"
2025-05-07 15:47:08 +01:00
import { DEMO_USER } from "@markone/core/user"
2025-05-06 11:00:35 +01:00
import { type } from "arktype"
2025-05-07 23:09:14 +01:00
import { ulid } from "ulid"
2025-05-06 11:00:35 +01:00
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import type { User } from "~/user/user.ts"
2025-05-21 13:18:16 +01:00
import {
LinkUnreachable,
UnsupportedLink,
assignTagsToBookmark,
cacheContent,
2025-05-21 23:27:17 +01:00
deleteBookmark,
2025-05-21 13:18:16 +01:00
findBookmark,
findBookmarkCachedContent,
findBookmarkTags,
insertBookmark,
} from "./bookmark.ts"
import { insertTags } from "./tag.ts"
2025-05-06 11:00:35 +01:00
const BOOKMARK_PAGINATION_LIMIT = 100
const ListUserBookmarksParams = type({
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
2025-05-08 15:51:17 +01:00
skip: ["number", "=", 0],
2025-05-21 23:27:17 +01:00
"tags?": "string",
2025-05-06 11:00:35 +01:00
})
2025-05-07 23:09:14 +01:00
const AddBookmarkRequestBody = type({
"title?": "string",
url: "string",
tags: "string[]",
"force?": "boolean",
})
2025-05-21 13:18:16 +01:00
const AddTagRequestBody = type({
name: "string",
})
2025-05-06 11:00:35 +01:00
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
2025-05-21 23:27:17 +01:00
const { searchParams } = new URL(request.url)
const queryParams = ListUserBookmarksParams(Object.fromEntries(searchParams))
2025-05-06 11:00:35 +01:00
if (queryParams instanceof type.errors) {
2025-05-07 23:09:14 +01:00
throw new HttpError(400, "", queryParams.summary)
2025-05-06 11:00:35 +01:00
}
2025-05-21 23:27:17 +01:00
let results: Bookmark[]
if (queryParams.tags) {
const tagNames = queryParams.tags.split(",")
const tagIdsQuery = db.query<{ id: string }, string[]>(
`SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`,
)
const tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
const query = db.query(`
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
INNER JOIN bookmark_tags
ON bookmark_tags.bookmark_id = bookmarks.id
WHERE bookmarks.user_id = ? AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")})
ORDER BY bookmarks.id DESC
LIMIT ? OFFSET ?
`)
results = query.all(...[user.id, ...tagIds, queryParams.limit, queryParams.skip]) as Bookmark[]
} else {
const query = db.query(`
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
WHERE bookmarks.user_id = $userId
ORDER BY bookmarks.id DESC
LIMIT $limit OFFSET $skip
`)
results = query.all({
userId: user.id,
limit: queryParams.limit,
skip: queryParams.skip,
}) as Bookmark[]
}
2025-05-06 11:00:35 +01:00
return Response.json(results, { status: 200 })
}
async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
2025-05-07 15:47:08 +01:00
if (user.id !== DEMO_USER.id) {
2025-05-21 23:27:17 +01:00
deleteBookmark(request.params.id, user)
2025-05-07 15:47:08 +01:00
}
return Response.json(undefined, { status: 204 })
}
2025-05-07 23:09:14 +01:00
async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
if (user.id !== DEMO_USER.id) {
const body = AddBookmarkRequestBody(await request.json())
if (body instanceof type.errors) {
2025-05-21 13:18:16 +01:00
console.log(body)
2025-05-07 23:09:14 +01:00
throw new HttpError(400)
}
2025-05-21 13:18:16 +01:00
const cachedContent = await cacheContent(body.url).catch((error) => {
2025-05-13 11:55:50 +01:00
if (error instanceof LinkUnreachable) {
if (body.force) {
return null
}
throw new HttpError(400, "LinkUnreachable")
2025-05-08 15:51:17 +01:00
}
2025-05-13 11:55:50 +01:00
if (error instanceof UnsupportedLink) {
throw new HttpError(400, "UnsupportedLink")
}
console.error(error)
throw new HttpError(500)
2025-05-07 23:09:14 +01:00
})
2025-05-21 13:18:16 +01:00
const tagNames = new Set(body.tags)
for (const tag of tagNames) {
2025-05-21 23:27:17 +01:00
if (tag.length === 0 || /[\s#]/g.test(tag)) {
2025-05-21 13:18:16 +01:00
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
const bookmark: Bookmark = {
2025-05-07 23:09:14 +01:00
id: ulid(),
2025-05-08 15:51:17 +01:00
title: "",
2025-05-07 23:09:14 +01:00
url: body.url,
2025-05-21 13:18:16 +01:00
tags: [],
2025-05-07 23:09:14 +01:00
}
2025-05-08 15:51:17 +01:00
if (body.title) {
bookmark.title = body.title
2025-05-21 13:18:16 +01:00
} else if (cachedContent?.title) {
bookmark.title = cachedContent.title
2025-05-08 15:51:17 +01:00
}
2025-05-21 13:18:16 +01:00
insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) {
const tagQuery = db.query<Partial<BookmarkTag>, string[]>(`
2025-05-21 23:27:17 +01:00
SELECT id, name FROM tags
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
`)
2025-05-21 13:18:16 +01:00
const tags = tagQuery.all(user.id, ...tagNames)
2025-05-07 23:09:14 +01:00
for (const tag of tags) {
2025-05-21 13:18:16 +01:00
if (tag.id && tag.name) {
bookmark.tags.push(tag as BookmarkTag)
tagNames.delete(tag.name)
}
2025-05-07 23:09:14 +01:00
}
2025-05-21 23:27:17 +01:00
if (tagNames.size > 0) {
const createdTags = insertTags([...tagNames], user)
bookmark.tags.push(...createdTags)
}
assignTagsToBookmark(bookmark.tags, bookmark)
2025-05-21 13:18:16 +01:00
}
2025-05-07 23:09:14 +01:00
return Response.json(bookmark, { status: 200 })
}
return Response.json(undefined, { status: 204 })
}
async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
2025-05-08 15:51:17 +01:00
switch (request.headers.get("Accept")) {
case "text/html": {
2025-05-21 13:18:16 +01:00
const html = findBookmarkCachedContent(request.params.id, user)
2025-05-08 15:51:17 +01:00
if (html === null) {
throw new HttpError(404)
}
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html",
},
})
}
case "application/json": {
const bookmark = findBookmark(request.params.id, user)
if (bookmark === null) {
throw new HttpError(404)
}
return Response.json(bookmark, { status: 200 })
}
2025-05-08 15:51:17 +01:00
default:
throw new HttpError(400, "UnsupportedAcceptHeader")
}
}
2025-05-21 13:18:16 +01:00
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
const query = db.query<BookmarkTag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id")
const tags = query.all({ id: user.id })
return Response.json(tags, { status: 200 })
}
async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) {
if (user.id !== DEMO_USER.id) {
const query = db.query<void, { id: string; name: string; userId: string }>(
"INSERT INTO tags (id, name, user_id) VALUES ($id, $name, $userId)",
)
const json = await request.json().catch(() => {
throw new HttpError(400)
})
const body = AddTagRequestBody(json)
if (body instanceof type.errors) {
throw new HttpError(400)
}
const tag: BookmarkTag = {
id: ulid(),
name: body.name,
}
query.run({ id: tag.id, name: tag.name, userId: user.id })
return Response.json(tag, { status: 200 })
}
return Response.json(undefined, { status: 204 })
}
async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags">, user: User) {
const bookmark = findBookmark(request.params.id, user)
if (!bookmark) {
throw new HttpError(404)
}
const tags = findBookmarkTags(bookmark)
return Response.json(tags, { status: 200 })
}
export {
addBookmark,
fetchBookmark,
listUserBookmarks,
deleteUserBookmark,
listUserTags,
createUserTag,
listBookmarkTags,
}