Compare commits

...

11 Commits

Author SHA1 Message Date
Henry
f85bbc4584 fix: increase maximum length for boardSlug from 24 to 60 characters 2026-03-23 16:29:58 +00:00
Henry
7b59e2ac07 feat(cloud): log to docker container 2026-03-22 07:21:30 +00:00
Henry
2a9b1fd2b5 refactor: replace @axiomhq/pino with @axiomhq/js 2026-03-20 13:56:52 +00:00
Henry
a2d4314e14 fix: update Dockerfile to include pino transport packages 2026-03-20 13:26:27 +00:00
Henry
f94e4d6da2 fix: include pino transports in outputFileTracingIncludes 2026-03-20 12:04:46 +00:00
Henry
97f4defc0e fix: add pino to serverExternalPackages 2026-03-20 11:46:08 +00:00
Henry
dd29dc89fd fix: remove broken dep 2026-03-19 21:33:44 +00:00
Henry
486cdf8313 feat(cloud): setup log drain (#447) 2026-03-19 21:16:47 +00:00
Henry
67cf523ee8 fix: API key authentication and REST handler errors (#446) 2026-03-19 12:20:19 +00:00
hjball
0b45ceff7f chore: update translations 2026-03-16 14:32:37 +00:00
Henry
76b2c58461 fix: security improvements to checklist input (#444)
* feat: sanitize input for checklists

* feat: add basic editor

* feat: use plain text editor for checklist items

* feat: remove key from user response
2026-03-16 14:31:13 +00:00
26 changed files with 376 additions and 160 deletions

View File

@@ -11,10 +11,10 @@ https://kan.bn/api/v1
## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
```
'x-api-key': kan_123456789
'Authorization': 'Bearer kan_123456789'
```
## Response codes

View File

@@ -28,6 +28,7 @@ const config = {
],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
@@ -65,6 +66,8 @@ const config = {
},
},
},
serverExternalPackages: ["pino"],
experimental: {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],

View File

@@ -0,0 +1,130 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Details hinzufügen..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Checklistenelement konnte nicht gelöscht werden"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Checklisten-Element konnte nicht aktualisiert werden"
},
"fYVH36": {

View File

@@ -471,7 +471,7 @@
"origin": [
[
"src/views/card/components/ChecklistItemRow.tsx",
200
169
]
],
"translation": "Add details..."
@@ -7332,11 +7332,11 @@
],
[
"src/views/card/components/ChecklistItemRow.tsx",
69
67
],
[
"src/views/card/components/ChecklistItemRow.tsx",
97
95
],
[
"src/views/card/components/ChecklistNameInput.tsx",
@@ -9571,7 +9571,7 @@
"origin": [
[
"src/views/card/components/ChecklistItemRow.tsx",
96
94
]
],
"translation": "Unable to delete checklist item"
@@ -9707,7 +9707,7 @@
"origin": [
[
"src/views/card/components/ChecklistItemRow.tsx",
68
66
]
],
"translation": "Unable to update checklist item"

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Añadir detalles..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "No se pudo eliminar el elemento de la lista de verificación"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "No se pudo actualizar el elemento de la lista de verificación"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Ajouter des détails..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Impossible de supprimer l'élément de liste de contrôle"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Impossible de mettre à jour l'élément de la checklist"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Aggiungi dettagli..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Impossibile eliminare l'elemento della checklist"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Impossibile aggiornare l'elemento della checklist"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Voeg details toe..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Kan checklistitem niet verwijderen"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Kan checklistitem niet bijwerken"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Dodaj szczegóły..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Nie można usunąć elementu listy kontrolnej"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Nie można zaktualizować elementu listy kontrolnej"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Adicionar detalhes..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Não foi possível excluir item da checklist"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Não foi possível atualizar o item da checklist"
},
"fYVH36": {

View File

@@ -280,7 +280,7 @@
"message": "Add details...",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 200]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 169]],
"translation": "Добавить детали..."
},
"lyqwgn": {
@@ -4336,8 +4336,8 @@
["src/views/boards/components/ImportBoardsForm.tsx", 243],
["src/views/boards/components/ImportBoardsForm.tsx", 395],
["src/views/card/components/AttachmentThumbnails.tsx", 74],
["src/views/card/components/ChecklistItemRow.tsx", 69],
["src/views/card/components/ChecklistItemRow.tsx", 97],
["src/views/card/components/ChecklistItemRow.tsx", 67],
["src/views/card/components/ChecklistItemRow.tsx", 95],
["src/views/card/components/ChecklistNameInput.tsx", 47],
["src/views/card/components/Checklists.tsx", 81],
["src/views/card/components/Comment.tsx", 93],
@@ -5634,7 +5634,7 @@
"message": "Unable to delete checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 96]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 94]],
"translation": "Не удалось удалить элемент контрольного списка"
},
"2QGEbi": {
@@ -5718,7 +5718,7 @@
"message": "Unable to update checklist item",
"placeholders": {},
"comments": [],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 68]],
"origin": [["src/views/card/components/ChecklistItemRow.tsx", 66]],
"translation": "Не удалось обновить элемент контрольного списка"
},
"fYVH36": {

View File

@@ -1,11 +1,11 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { useState } from "react";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";
import PlainTextEditor from "~/components/PlainTextEditor";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -33,9 +33,7 @@ export default function ChecklistItemRow({
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [title, setTitle] = useState("");
const [completed, setCompleted] = useState(false);
const [completed, setCompleted] = useState(item.completed);
const updateItem = api.checklist.updateItem.useMutation({
onMutate: async (vars) => {
@@ -103,21 +101,6 @@ export default function ChecklistItemRow({
},
});
// Only resync from props when switching items to avoid clobbering edits
useEffect(() => {
setTitle(item.title);
setCompleted(item.completed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev);
@@ -127,14 +110,8 @@ export default function ChecklistItemRow({
});
};
const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
return;
}
setTitle(plain);
const commitTitle = (plain: string) => {
if (!plain || plain === item.title) return;
updateItem.mutate({
checklistItemPublicId: item.publicId,
title: plain,
@@ -183,36 +160,26 @@ export default function ChecklistItemRow({
)}
/>
</label>
<div className="flex-1 pr-7">
<ContentEditable
html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => {
const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(innerHTML);
<PlainTextEditor
key={item.publicId}
content={item.title}
readOnly={viewOnly}
placeholder={t`Add details...`}
onBlur={commitTitle}
onEnter={(plain) => {
commitTitle(plain);
onCreateNewItem?.();
}}
onEscape={() => undefined}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950",
viewOnly && "cursor-default",
)}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/>
</div>
{!viewOnly && (
<button
type="button"

View File

@@ -39,6 +39,8 @@ services:
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
- AXIOM_TOKEN=${AXIOM_TOKEN}
- AXIOM_DATASET=${AXIOM_DATASET}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}

View File

@@ -227,7 +227,7 @@ export const boardRouter = createTRPCRouter({
boardSlug: z
.string()
.min(3)
.max(24)
.max(60)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
members: z.array(z.string().min(12)).optional(),
labels: z.array(z.string().min(12)).optional(),
@@ -657,7 +657,7 @@ export const boardRouter = createTRPCRouter({
boardSlug: z
.string()
.min(3)
.max(24)
.max(60)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardPublicId: z.string().min(12),
}),

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { stripHtml } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
@@ -223,7 +224,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistPublicId: z.string().length(12),
title: z.string().min(1).max(500),
title: z.string().min(1).max(500).transform(stripHtml),
}),
)
.output(checklistItemSchema)
@@ -288,7 +289,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).optional(),
title: z.string().min(1).max(500).transform(stripHtml).optional(),
completed: z.boolean().optional(),
index: z.number().int().min(0).optional(),
}),
@@ -322,30 +323,29 @@ export const checklistRouter = createTRPCRouter({
const previousTitle = item.title;
let updatedItem;
let updatedItem;
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
// Log completion toggle
if (input.completed !== undefined) {
@@ -371,7 +371,6 @@ export const checklistRouter = createTRPCRouter({
}
return updatedItem;
}),
deleteItem: protectedProcedure
.meta({

View File

@@ -19,6 +19,7 @@ import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
@@ -43,7 +44,9 @@ export const integrationRouter = createTRPCRouter({
return { success: true };
}),
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
disconnectGitHub: protectedProcedure
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
@@ -56,7 +59,9 @@ export const integrationRouter = createTRPCRouter({
return { success: true };
}),
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
getGitHubStatus: protectedProcedure
.output(z.object({ connected: z.boolean() }))
.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)

View File

@@ -31,7 +31,6 @@ export const userRouter = createTRPCRouter({
.object({
id: z.number(),
prefix: z.string().nullable(),
key: z.string(),
})
.nullable(),
}),
@@ -62,7 +61,7 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
apiKey: apiKey ?? null,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
};
}),
update: protectedProcedure

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest } from "next";
import type { OpenApiMeta } from "trpc-to-openapi";
@@ -11,7 +12,28 @@ import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("trpc");
const log = createLogger("api");
const TRPC_STATUS_MAP: Partial<Record<TRPCError["code"], number>> = {
PARSE_ERROR: 400,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_SUPPORTED: 405,
TIMEOUT: 408,
CONFLICT: 409,
PRECONDITION_FAILED: 412,
PAYLOAD_TOO_LARGE: 413,
UNPROCESSABLE_CONTENT: 422,
TOO_MANY_REQUESTS: 429,
CLIENT_CLOSED_REQUEST: 499,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
};
export interface User {
id: string;
@@ -50,6 +72,7 @@ interface CreateContextOptions {
db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
headers: Headers;
transport?: "trpc" | "rest";
}
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
@@ -58,6 +81,8 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
db: opts.db,
auth: opts.auth,
headers: opts.headers,
transport: opts.transport ?? "trpc",
requestId: randomUUID(),
};
};
@@ -69,7 +94,13 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createNextApiContext = async (req: NextApiRequest) => {
@@ -80,7 +111,13 @@ export const createNextApiContext = async (req: NextApiRequest) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "trpc",
});
};
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
@@ -93,11 +130,16 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
try {
session = await auth.api.getSession();
} catch (error) {
log.error({ err: error }, "Error getting session");
throw error;
log.warn({ err: error }, "Failed to get session, treating as unauthenticated");
}
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
return createInnerTRPCContext({
db,
user: session?.user,
auth,
headers,
transport: "rest",
});
};
const t = initTRPC
@@ -126,20 +168,39 @@ const loggingMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
const result = await next();
const duration = Date.now() - start;
const meta = { procedure: path, type, duration, userId: (ctx as { user?: { id: string } }).user?.id };
const { user, transport, requestId } = ctx as {
user?: { id: string; email: string };
transport?: string;
requestId?: string;
};
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
const meta = {
requestId,
procedure: path,
type,
transport,
duration,
userId: user?.id,
...(isCloud && { email: user?.email }),
};
const label = transport === "rest" ? "REST" : "tRPC";
if (result.ok) {
log.info(meta, "tRPC OK");
log.info({ ...meta, status: 200 }, `${label} OK`);
} else {
log.error({ ...meta, err: result.error }, "tRPC error");
const status = TRPC_STATUS_MAP[result.error.code] ?? 500;
const errorCode = result.error.code;
log.error(
{ ...meta, status, errorCode, err: result.error },
`${label} error`,
);
}
return result;
});
export const publicProcedure = t.procedure.use(loggingMiddleware).meta({
openapi: { method: "GET", path: "/public" },
});
export const publicProcedure = t.procedure.use(loggingMiddleware);
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
@@ -163,13 +224,7 @@ const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
export const protectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAuthed)
.meta({
openapi: {
method: "GET",
path: "/protected",
},
});
.use(enforceUserIsAuthed);
export const adminProtectedProcedure = t.procedure
.use(loggingMiddleware)

View File

@@ -165,6 +165,13 @@ export function createPlugins(db: dbClient) {
: []),
apiKey({
enableSessionForAPIKeys: true,
customAPIKeyGetter: (ctx) => {
const authorization = ctx.headers?.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? undefined;
},
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute

View File

@@ -19,6 +19,7 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@axiomhq/js": "^1.4.0",
"pino": "^9.14.0",
"pino-pretty": "^13.1.3"
},

View File

@@ -1,20 +1,43 @@
import { Axiom } from "@axiomhq/js";
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
const level = process.env.LOG_LEVEL || (isDev ? "debug" : "info");
export const logger = pino({
level,
...(isDev && {
transport: {
target: "pino-pretty",
options: {
colorize: true,
ignore: "pid,hostname",
translateTime: "HH:MM:ss",
},
const axiomToken = process.env.AXIOM_TOKEN;
const axiomDataset = process.env.AXIOM_DATASET;
const useAxiom = isCloud && !!axiomToken && !!axiomDataset;
function createAxiomStream(token: string, dataset: string): pino.DestinationStream {
const client = new Axiom({ token });
return {
write(msg: string) {
try {
client.ingest(dataset, [JSON.parse(msg) as Record<string, unknown>]);
} catch {
// ignore malformed log lines
}
},
}),
});
};
}
export const logger = useAxiom
? pino(
{ level },
pino.multistream([
{ stream: process.stdout, level },
{ stream: createAxiomStream(axiomToken, axiomDataset), level },
]),
)
: pino({
level,
...(isDev && {
transport: {
target: "pino-pretty",
options: { colorize: true, ignore: "pid,hostname", translateTime: "HH:MM:ss" },
},
}),
});
export const createLogger = (module: string) => logger.child({ module });

View File

@@ -5,3 +5,4 @@ export * from "./email";
export * from "./dueDateFilters";
export * from "./s3";
export * from "./mentions";
export * from "./sanitize";

View File

@@ -0,0 +1,6 @@
/**
* Strips all HTML tags from a string, returning plain text.
* Use this on any user-supplied text field before storing or displaying.
*/
export const stripHtml = (value: string): string =>
value.replace(/<[^>]*>/g, "").trim();

16
pnpm-lock.yaml generated
View File

@@ -504,6 +504,9 @@ importers:
packages/logger:
dependencies:
'@axiomhq/js':
specifier: ^1.4.0
version: 1.4.0
pino:
specifier: ^9.14.0
version: 9.14.0
@@ -891,6 +894,10 @@ packages:
resolution: {integrity: sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==}
engines: {node: '>=18.0.0'}
'@axiomhq/js@1.4.0':
resolution: {integrity: sha512-wC5x1ud/QJMstrjpicATkyY8+ZVWEl4WlXMtA5EZf7hkj0+b191yv4yynLxLEfr/MveXora9m6CWdJq4DsbcAg==}
engines: {node: '>=20'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -5628,6 +5635,9 @@ packages:
picomatch:
optional: true
fetch-retry@6.0.0:
resolution: {integrity: sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
@@ -9384,6 +9394,10 @@ snapshots:
'@smithy/types': 4.3.2
tslib: 2.8.1
'@axiomhq/js@1.4.0':
dependencies:
fetch-retry: 6.0.0
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -14403,6 +14417,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fetch-retry@6.0.0: {}
fflate@0.4.8: {}
figures@3.2.0:

View File

@@ -130,7 +130,9 @@
"NOVU_API_KEY",
"EMAIL_UNSUBSCRIBE_SECRET",
"REDIS_URL",
"LOG_LEVEL"
"LOG_LEVEL",
"AXIOM_TOKEN",
"AXIOM_DATASET"
],
"globalPassThroughEnv": [
"NODE_ENV",