Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5f3f0864 | ||
|
|
052f8a43fb | ||
|
|
dee2d5e30b | ||
|
|
2bd8e48aba |
@@ -66,3 +66,6 @@ node_modules.nosync/
|
|||||||
# Kept on disk because it is how a session hands over to the next one.
|
# Kept on disk because it is how a session hands over to the next one.
|
||||||
HANDOFF.md
|
HANDOFF.md
|
||||||
NOTES-PLAN.md
|
NOTES-PLAN.md
|
||||||
|
|
||||||
|
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -353,7 +353,16 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
name := f.Name
|
name := f.Name
|
||||||
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
|
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
||||||
|
// a note that did not arrive, and the caller is told the same number either
|
||||||
|
// way unless it is said here — which is how one note went missing without
|
||||||
|
// anything looking wrong.
|
||||||
|
if skipFile(name) {
|
||||||
|
s.log.Warn("import skipped a note file", "file", name)
|
||||||
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,10 +418,34 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||||
// would double a whole library with each copy full of binary rubbish.
|
// would double a whole library with each copy full of binary rubbish.
|
||||||
func skipFile(name string) bool {
|
func skipFile(name string) bool {
|
||||||
return strings.HasPrefix(name, "__MACOSX/") ||
|
if strings.HasPrefix(name, "__MACOSX/") ||
|
||||||
strings.HasPrefix(path.Base(name), "._") ||
|
strings.HasPrefix(path.Base(name), "._") ||
|
||||||
strings.HasPrefix(path.Base(name), ".") ||
|
strings.HasPrefix(path.Base(name), ".") {
|
||||||
strings.Contains(name, "..")
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traversal, read as path segments rather than as a substring.
|
||||||
|
//
|
||||||
|
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
||||||
|
// wrong thing: it does not describe a path that climbs out of the archive, it
|
||||||
|
// describes any name with two dots next to each other anywhere in it. A note
|
||||||
|
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
||||||
|
// silently, and without even counting as skipped, so the number the import
|
||||||
|
// reported still looked right. One note in twelve hundred, found only by
|
||||||
|
// counting what came out.
|
||||||
|
//
|
||||||
|
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
||||||
|
// starts at the root, is not — and those are what a zip uses to write outside
|
||||||
|
// the folder it was unpacked into.
|
||||||
|
if strings.HasPrefix(name, "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(name, "/") {
|
||||||
|
if part == ".." {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachToNote records the files a note's text points at and rewrites the text to
|
// attachToNote records the files a note's text points at and rewrites the text to
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
|
||||||
|
//
|
||||||
|
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
|
||||||
|
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
|
||||||
|
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
|
||||||
|
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
|
||||||
|
// tælle efter, hvor mange der kom ud.
|
||||||
|
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
|
||||||
|
keep := []string{
|
||||||
|
"Så blev det endelig jul! Stay tuned... ☕️.md",
|
||||||
|
"Møde 12.8..md",
|
||||||
|
"vedhaeftninger/1-2-Pasted Graphic.png",
|
||||||
|
"noter/Referat....md",
|
||||||
|
}
|
||||||
|
for _, name := range keep {
|
||||||
|
if skipFile(name) {
|
||||||
|
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop := []string{
|
||||||
|
"../uden for.md",
|
||||||
|
"noter/../../etc/passwd",
|
||||||
|
"/etc/passwd",
|
||||||
|
"__MACOSX/._noget",
|
||||||
|
".skjult.md",
|
||||||
|
"noter/._AppleDouble.md",
|
||||||
|
}
|
||||||
|
for _, name := range drop {
|
||||||
|
if !skipFile(name) {
|
||||||
|
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-1
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
|||||||
|
|
||||||
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
await page.getByText('males inden fredag', { exact: true }).click();
|
// Titlen alene, ikke rækken med datomærket i.
|
||||||
|
//
|
||||||
|
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
|
||||||
|
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
|
||||||
|
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
|
||||||
|
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
|
||||||
|
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
|
||||||
|
// datoer; den skal ikke vide hvilken dag det er.
|
||||||
|
await page.getByText('males inden', { exact: true }).click();
|
||||||
|
|
||||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
||||||
@@ -2388,3 +2396,41 @@ test('lister overlever en gemning — også i et citat, og også med numre', asy
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
|
||||||
|
// og resten stod som markup: et img med en onerror på. En note deles gennem et
|
||||||
|
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
|
||||||
|
const id = await page.evaluate(async () => {
|
||||||
|
const r = await fetch('/api/v1/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: '# Ondsindet\n\n\n'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return (await r.json()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/noter?note=' + id);
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(ed.locator('img')).toHaveCount(1);
|
||||||
|
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
|
||||||
|
// den vej, en onerror ville blive kaldt ad.
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
|
||||||
|
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
|
||||||
|
const alt = await ed.locator('img').getAttribute('alt');
|
||||||
|
expect(alt).toContain('onerror');
|
||||||
|
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
|
||||||
|
|
||||||
|
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
|
||||||
|
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
|
||||||
|
// andet skal stadig være stille.
|
||||||
|
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -508,14 +508,22 @@
|
|||||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||||
stopped landing, because the row had claimed the gesture. A grip claims
|
stopped landing, because the row had claimed the gesture. A grip claims
|
||||||
nothing until it is grabbed. -->
|
nothing until it is grabbed. -->
|
||||||
|
<!-- Håndtaget har ingen prik i sig længere.
|
||||||
|
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
|
||||||
|
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
|
||||||
|
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
|
||||||
|
draggable, ville alt indeni også blive det — og en opgave trukket hen på
|
||||||
|
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
|
||||||
|
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
|
||||||
|
er hele forklaringen. -->
|
||||||
{#snippet grip(key)}
|
{#snippet grip(key)}
|
||||||
<span
|
<span
|
||||||
class="grip"
|
class="grip"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => onNavDragStart(e, key)}
|
ondragstart={(e) => onNavDragStart(e, key)}
|
||||||
ondragend={clearNavDrag}
|
ondragend={clearNavDrag}
|
||||||
aria-hidden="true">⠿</span
|
aria-hidden="true"
|
||||||
>
|
></span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet projectRow(project, sortable)}
|
{#snippet projectRow(project, sortable)}
|
||||||
@@ -1004,19 +1012,15 @@
|
|||||||
so nothing moves when it appears. */
|
so nothing moves when it appears. */
|
||||||
.grip {
|
.grip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -2px;
|
left: 0;
|
||||||
width: 12px;
|
top: 0;
|
||||||
text-align: center;
|
bottom: 0;
|
||||||
font-size: 10px;
|
width: 14px;
|
||||||
line-height: 1;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--fast) var(--ease);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navrow:hover .grip {
|
.grip:active {
|
||||||
opacity: 1;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navrow > :global(a) {
|
.navrow > :global(a) {
|
||||||
|
|||||||
+19
-1
@@ -37,6 +37,16 @@ const BLOCKS = [
|
|||||||
const escapeHtml = (s) =>
|
const escapeHtml = (s) =>
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En værdi, der skal stå inde i en attribut.
|
||||||
|
*
|
||||||
|
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
|
||||||
|
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
|
||||||
|
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
|
||||||
|
* forskellen mellem tekst og et onerror.
|
||||||
|
*/
|
||||||
|
const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
|
||||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
function inlineToHtml(text) {
|
function inlineToHtml(text) {
|
||||||
return (
|
return (
|
||||||
@@ -51,8 +61,16 @@ function inlineToHtml(text) {
|
|||||||
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
|
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
|
||||||
// den er blevet læst — og en note er det sidste sted, man vil have en
|
// den er blevet læst — og en note er det sidste sted, man vil have en
|
||||||
// sporingspixel. Alt andet står som den tekst, det er.
|
// sporingspixel. Alt andet står som den tekst, det er.
|
||||||
|
//
|
||||||
|
// `alt` skal citat-undslippes her, og det er ikke en formalitet.
|
||||||
|
// escapeHtml tager &, < og > — ikke anførselstegn, fordi den er skrevet
|
||||||
|
// til tekst mellem to tags, hvor et anførselstegn ikke betyder noget. Her
|
||||||
|
// står værdien inde i en attribut, og der lukker et anførselstegn den:
|
||||||
|
// `` blev til et img med
|
||||||
|
// en onerror på. En note er delt gennem et projekt, så det ville køre på
|
||||||
|
// vores eget domæne med sessionscookien, hos den, der åbnede noten.
|
||||||
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
||||||
(_, alt, src) => `<img src="${src}" alt="${alt}">`)
|
(_, alt, src) => `<img src="${src}" alt="${attr(alt)}">`)
|
||||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
|||||||
@@ -35,10 +35,29 @@
|
|||||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
||||||
* how anybody looking for something they just closed would describe it.
|
* how anybody looking for something they just closed would describe it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Dagen en opgave blev lukket, målt hvor personen står.
|
||||||
|
*
|
||||||
|
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
|
||||||
|
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
|
||||||
|
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
|
||||||
|
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
|
||||||
|
*
|
||||||
|
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
|
||||||
|
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
|
||||||
|
*/
|
||||||
|
const localDay = (iso) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
let days = $derived.by(() => {
|
let days = $derived.by(() => {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const task of done) {
|
for (const task of done) {
|
||||||
const key = (task.completed_at ?? '').slice(0, 10);
|
const key = localDay(task.completed_at);
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key).push(task);
|
groups.get(key).push(task);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,10 @@
|
|||||||
*/
|
*/
|
||||||
function plain(body) {
|
function plain(body) {
|
||||||
return (body ?? '')
|
return (body ?? '')
|
||||||
|
// Et billede har ingen ord i sig. Uden det her stod der
|
||||||
|
// "]*\)/g, '')
|
||||||
.replace(/^#{1,6}\s+/gm, '')
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
.replace(/^\s*[-*+]\s+/gm, '')
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
.replace(/^\s*>\s?/gm, '')
|
.replace(/^\s*>\s?/gm, '')
|
||||||
@@ -447,7 +451,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
max-width: 11ch;
|
max-width: 13ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user