Files
markone/packages/server/src/bookmark/handlers.ts
2025-05-29 00:11:17 +01:00

276 lines
7.1 KiB
TypeScript

import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core"
import { DEMO_USER } from "@markone/core/user"
import { type } from "arktype"
import { ulid } from "ulid"
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import type { User } from "~/user/user.ts"
import {
LinkUnreachable,
UnsupportedLink,
assignTagsToBookmark,
cacheContent,
deleteBookmark,
findBookmark,
findBookmarkCachedContent,
findBookmarkTags,
findBookmarks,
insertBookmark,
updateBookmarkTags,
updateBookmarkTitle,
} from "./bookmark.ts"
import { fuzzySearchBookmarks } from "~/bookmark/search.js"
import { insertTags } from "~/tag/tag.js"
const BOOKMARK_PAGINATION_LIMIT = 100
const ListUserBookmarksParams = type({
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
skip: ["number", "=", 0],
"tags?": "string",
"q?": "string",
})
const AddBookmarkRequestBody = type({
"title?": "string",
url: "string",
tags: "string[]",
"force?": "boolean",
})
const UpdateBookmarkRequestBody = type({
"title?": "string",
"tags?": "string[]",
})
const AddTagRequestBody = type({
name: "string",
})
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
const { searchParams } = new URL(request.url)
const queryParams = ListUserBookmarksParams(Object.fromEntries(searchParams))
if (queryParams instanceof type.errors) {
throw new HttpError(400, "", queryParams.summary)
}
let results: Bookmark[]
if (queryParams.q || queryParams.tags) {
let tagIds: TagId[] = []
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(",")})`,
)
tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
}
if (queryParams.q) {
results = fuzzySearchBookmarks({ searchTerm: queryParams.q, tagIds }, user)
} else {
results = findBookmarks({ ids: [], tagIds }, user)
}
} else {
results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user)
}
return Response.json(results, { status: 200 })
}
async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
if (user.id !== DEMO_USER.id) {
deleteBookmark(request.params.id, user)
}
return Response.json(undefined, { status: 204 })
}
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) {
throw new HttpError(400)
}
const cachedContent = await cacheContent(body.url).catch((error) => {
if (error instanceof LinkUnreachable) {
if (body.force) {
return null
}
throw new HttpError(400, "LinkUnreachable")
}
if (error instanceof UnsupportedLink) {
throw new HttpError(400, "UnsupportedLink")
}
console.error(error)
throw new HttpError(500)
})
const tagNames = new Set(body.tags)
for (const tag of tagNames) {
if (tag.length === 0 || /[\s#]/g.test(tag)) {
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
const bookmark: TaggedBookmark = {
id: ulid(),
title: "",
url: body.url,
tags: [],
}
if (body.title) {
bookmark.title = body.title
} else if (cachedContent?.title) {
bookmark.title = cachedContent.title
}
insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) {
const tagQuery = db.query<Partial<Tag>, string[]>(`
SELECT id, name FROM tags
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
`)
const tags = tagQuery.all(user.id, ...tagNames)
for (const tag of tags) {
if (tag.id && tag.name) {
bookmark.tags.push(tag as Tag)
tagNames.delete(tag.name)
}
}
if (tagNames.size > 0) {
const createdTags = insertTags([...tagNames], user)
bookmark.tags.push(...createdTags)
}
assignTagsToBookmark(bookmark.tags, bookmark)
}
return Response.json(bookmark, { status: 200 })
}
return Response.json(undefined, { status: 204 })
}
async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
switch (request.headers.get("Accept")) {
case "text/html": {
const html = findBookmarkCachedContent(request.params.id, user)
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 })
}
default:
throw new HttpError(400, "UnsupportedAcceptHeader")
}
}
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
const query = db.query<Tag, { 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: Tag = {
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 })
}
async function updateBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
const bodyJson = await request.json().catch(() => {
throw new HttpError(400)
})
const body = UpdateBookmarkRequestBody(bodyJson)
if (body instanceof type.errors) {
throw new HttpError(400)
}
if (!body.title || !body.tags) {
return Response.json(undefined, { status: 204 })
}
const bookmark = findBookmark(request.params.id, user)
if (!bookmark) {
throw new HttpError(404)
}
if (body.title) {
updateBookmarkTitle(bookmark, body.title, user)
bookmark.title = body.title
}
if (body.tags) {
const taggedBookmark = bookmark as TaggedBookmark
for (const tag of body.tags) {
if (tag.length === 0 || /[\s#]/g.test(tag)) {
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
taggedBookmark.tags = updateBookmarkTags(bookmark, body.tags, user)
return Response.json(taggedBookmark, { status: 200 })
}
return Response.json(bookmark, { status: 200 })
}
export {
addBookmark,
fetchBookmark,
listUserBookmarks,
deleteUserBookmark,
listUserTags,
createUserTag,
listBookmarkTags,
updateBookmark,
}