chore: init jotts rewrite b5c140bb
Steve · 2026-05-15 20:53 37 file(s) · +1892 −0
apps/jotts-go/.env.example (added) +8 −0
1 +
JOTTS_PASSWORD=changeme
2 +
JOTTS_DB_PATH=jotts.sqlite
3 +
COOKIE_SECURE=false
4 +
HOST=127.0.0.1
5 +
PORT=3000
6 +
# Optional. When set, enables the JSON API at /api/notes gated by x-api-key header.
7 +
# Leave unset to disable the API (returns 403).
8 +
JOTTS_API_KEY=
apps/jotts-go/.gitignore (added) +7 −0
1 +
.env
2 +
*.sqlite
3 +
*.sqlite-journal
4 +
*.sqlite-wal
5 +
*.sqlite-shm
6 +
jotts-go
7 +
/jotts-go
apps/jotts-go/Dockerfile (added) +14 −0
1 +
FROM golang:1.24-bookworm AS builder
2 +
WORKDIR /app
3 +
COPY apps/jotts-go/go.mod apps/jotts-go/go.sum ./
4 +
RUN go mod download
5 +
COPY apps/jotts-go/ ./
6 +
RUN CGO_ENABLED=0 go build -o /jotts-go .
7 +
8 +
FROM debian:bookworm-slim
9 +
COPY --from=builder /jotts-go /usr/local/bin/jotts-go
10 +
WORKDIR /data
11 +
ENV HOST=0.0.0.0
12 +
ENV PORT=3000
13 +
EXPOSE 3000
14 +
CMD ["jotts-go"]
apps/jotts-go/README.md (added) +75 −0
1 +
# jotts-go
2 +
3 +
Go port of [jotts](../jotts): minimal markdown notes app.
4 +
5 +
## Stack
6 +
7 +
- Go stdlib `net/http` + `html/template`
8 +
- `modernc.org/sqlite` (pure-Go SQLite, no CGO)
9 +
- `github.com/yuin/goldmark` (markdown rendering w/ strikethrough, tables, tasklists)
10 +
11 +
No other dependencies.
12 +
13 +
## Quickstart
14 +
15 +
```bash
16 +
cp .env.example .env
17 +
# edit .env with your password
18 +
go run .
19 +
```
20 +
21 +
## Environment variables
22 +
23 +
| Variable | Description | Default |
24 +
|---|---|---|
25 +
| `JOTTS_PASSWORD` | Login password | `changeme` |
26 +
| `JOTTS_DB_PATH` | SQLite file path | `jotts.sqlite` |
27 +
| `HOST` | Bind address | `127.0.0.1` |
28 +
| `PORT` | Server port | `3000` |
29 +
| `COOKIE_SECURE` | HTTPS-only cookies | `false` |
30 +
| `JOTTS_API_KEY` | API key for `/api/notes` (unset = API disabled) | _(unset)_ |
31 +
32 +
## Structure
33 +
34 +
```
35 +
jotts-go/
36 +
├── main.go           # entrypoint
37 +
├── app.go            # App struct + page data types
38 +
├── db.go             # SQLite schema + queries (notes, sessions)
39 +
├── routes.go         # http.ServeMux routes
40 +
├── middleware.go     # session + API key middleware, cookies
41 +
├── handlers_web.go   # HTML form handlers
42 +
├── handlers_api.go   # JSON API handlers
43 +
├── markdown.go       # goldmark rendering
44 +
├── web.go            # template render, JSON, embedded static
45 +
├── util.go           # env, dotenv, short IDs, session tokens
46 +
├── templates/        # html/template pages
47 +
├── static/           # favicons, styles, og image
48 +
├── assets/           # darkmatter.css + Commit Mono fonts
49 +
├── Dockerfile
50 +
└── docker-compose.yml
51 +
```
52 +
53 +
## API
54 +
55 +
All endpoints require `x-api-key: $JOTTS_API_KEY` header.
56 +
57 +
- `GET /api/notes` — list notes
58 +
- `POST /api/notes` — create `{title, content}`
59 +
- `GET /api/notes/{short_id}`
60 +
- `PUT /api/notes/{short_id}` — update `{title, content}`
61 +
- `DELETE /api/notes/{short_id}`
62 +
63 +
## Build
64 +
65 +
```bash
66 +
CGO_ENABLED=0 go build -o jotts-go .
67 +
```
68 +
69 +
Single ~10MB self-contained binary with all assets embedded.
70 +
71 +
## Docker
72 +
73 +
```bash
74 +
docker compose up -d
75 +
```
apps/jotts-go/app.go (added) +56 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
)
9 +
10 +
//go:embed templates/*.html static/* static/fonts/* assets/* assets/fonts/*
11 +
var appFS embed.FS
12 +
13 +
type App struct {
14 +
	DB           *sql.DB
15 +
	Log          *slog.Logger
16 +
	Templates    *template.Template
17 +
	Password     string
18 +
	APIKey       string
19 +
	CookieSecure bool
20 +
}
21 +
22 +
type Note struct {
23 +
	ID        int64  `json:"id"`
24 +
	ShortID   string `json:"short_id"`
25 +
	Title     string `json:"title"`
26 +
	Content   string `json:"content"`
27 +
	CreatedAt string `json:"created_at"`
28 +
	UpdatedAt string `json:"updated_at"`
29 +
}
30 +
31 +
type NoteInput struct {
32 +
	Title   string `json:"title"`
33 +
	Content string `json:"content"`
34 +
}
35 +
36 +
type indexPageData struct {
37 +
	Notes []Note
38 +
}
39 +
40 +
type loginPageData struct {
41 +
	Error string
42 +
}
43 +
44 +
type newPageData struct {
45 +
	Error string
46 +
}
47 +
48 +
type editPageData struct {
49 +
	Note  Note
50 +
	Error string
51 +
}
52 +
53 +
type viewPageData struct {
54 +
	Note     Note
55 +
	Rendered template.HTML
56 +
}
apps/jotts-go/assets/darkmatter.css (added) +648 −0
1 +
/* Darkmatter — canonical CSS for Andromeda apps.
2 +
 * Source of truth for reset, tokens, and shared components.
3 +
 * Docs: /darkmatter
4 +
 */
5 +
6 +
@font-face {
7 +
  font-family: "Commit Mono";
8 +
  src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype");
9 +
  font-weight: 400;
10 +
  font-style: normal;
11 +
  font-display: swap;
12 +
}
13 +
14 +
@font-face {
15 +
  font-family: "Commit Mono";
16 +
  src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype");
17 +
  font-weight: 700;
18 +
  font-style: normal;
19 +
  font-display: swap;
20 +
}
21 +
22 +
/* ── Reset + webkit hardening ─────────────────────────────────────── */
23 +
24 +
*,
25 +
*::before,
26 +
*::after {
27 +
  padding: 0;
28 +
  margin: 0;
29 +
  box-sizing: border-box;
30 +
  font-family: "Commit Mono", monospace, sans-serif;
31 +
  -webkit-tap-highlight-color: transparent;
32 +
}
33 +
34 +
* {
35 +
  scrollbar-width: none;
36 +
  -ms-overflow-style: none;
37 +
}
38 +
39 +
html {
40 +
  background: #121113;
41 +
  color: #ffffff;
42 +
  font-size: 14px;
43 +
  line-height: 1.6;
44 +
  -webkit-text-size-adjust: 100%;
45 +
  text-size-adjust: 100%;
46 +
}
47 +
48 +
html::-webkit-scrollbar {
49 +
  display: none;
50 +
}
51 +
52 +
body {
53 +
  display: flex;
54 +
  flex-direction: column;
55 +
  justify-content: start;
56 +
  align-items: start;
57 +
  gap: 1.5rem;
58 +
  min-height: 100vh;
59 +
  max-width: 700px;
60 +
  margin: auto;
61 +
  padding: 0 1rem 4rem;
62 +
}
63 +
64 +
@media (max-width: 480px) {
65 +
  body {
66 +
    padding: 1rem;
67 +
    gap: 1rem;
68 +
  }
69 +
}
70 +
71 +
/* ── Links ────────────────────────────────────────────────────────── */
72 +
73 +
a {
74 +
  color: #ffffff;
75 +
  text-decoration: none;
76 +
  touch-action: manipulation;
77 +
}
78 +
79 +
a:hover {
80 +
  opacity: 0.7;
81 +
}
82 +
83 +
/* ── Header / nav ─────────────────────────────────────────────────── */
84 +
85 +
.header {
86 +
  display: flex;
87 +
  flex-direction: column;
88 +
  gap: 0.5rem;
89 +
  width: 100%;
90 +
  margin-top: 2rem;
91 +
  border-bottom: 1px solid #333;
92 +
  padding-bottom: 1rem;
93 +
}
94 +
95 +
.logo {
96 +
  font-size: 28px;
97 +
  font-weight: 700;
98 +
  text-decoration: none;
99 +
  text-transform: uppercase;
100 +
}
101 +
102 +
.links {
103 +
  display: flex;
104 +
  align-items: center;
105 +
  gap: 0.75rem;
106 +
  font-size: 12px;
107 +
}
108 +
109 +
/* ── Main ─────────────────────────────────────────────────────────── */
110 +
111 +
main {
112 +
  width: 100%;
113 +
  display: flex;
114 +
  flex-direction: column;
115 +
  gap: 1rem;
116 +
}
117 +
118 +
/* ── Forms ────────────────────────────────────────────────────────── */
119 +
120 +
.form {
121 +
  display: flex;
122 +
  flex-direction: column;
123 +
  gap: 0.5rem;
124 +
  width: 100%;
125 +
}
126 +
127 +
.form-row {
128 +
  display: flex;
129 +
  gap: 0.5rem;
130 +
  width: 100%;
131 +
}
132 +
133 +
.form-row .form-field {
134 +
  flex: 1;
135 +
}
136 +
137 +
.form-field {
138 +
  display: flex;
139 +
  flex-direction: column;
140 +
  gap: 0.25rem;
141 +
}
142 +
143 +
.form-actions {
144 +
  display: flex;
145 +
  gap: 0.5rem;
146 +
}
147 +
148 +
@media (max-width: 480px) {
149 +
  .form-row {
150 +
    flex-direction: column;
151 +
  }
152 +
}
153 +
154 +
label {
155 +
  font-size: 12px;
156 +
  opacity: 0.7;
157 +
}
158 +
159 +
input,
160 +
textarea,
161 +
select {
162 +
  background: #121113;
163 +
  color: #ffffff;
164 +
  border: 1px solid #ffffff;
165 +
  padding: 0.4rem 0.75rem;
166 +
  font-size: 16px; /* 16px prevents iOS focus zoom */
167 +
  width: 100%;
168 +
  border-radius: 0;
169 +
  -webkit-appearance: none;
170 +
  appearance: none;
171 +
  outline: none;
172 +
}
173 +
174 +
input:focus,
175 +
textarea:focus,
176 +
select:focus {
177 +
  outline: none;
178 +
}
179 +
180 +
textarea {
181 +
  min-height: 400px;
182 +
  resize: vertical;
183 +
}
184 +
185 +
select {
186 +
  background-image: none;
187 +
  padding-right: 0.75rem;
188 +
}
189 +
190 +
input[type="file"] {
191 +
  cursor: pointer;
192 +
  font-size: 14px;
193 +
}
194 +
195 +
input[type="file"]::-webkit-file-upload-button,
196 +
input[type="file"]::file-selector-button {
197 +
  background: #121113;
198 +
  color: #ffffff;
199 +
  border: 1px solid #555;
200 +
  padding: 0.25rem 0.5rem;
201 +
  cursor: pointer;
202 +
  font-family: "Commit Mono", monospace;
203 +
  font-size: 12px;
204 +
  margin-right: 0.5rem;
205 +
  border-radius: 0;
206 +
}
207 +
208 +
input[type="search"]::-webkit-search-decoration,
209 +
input[type="search"]::-webkit-search-cancel-button,
210 +
input[type="search"]::-webkit-search-results-button,
211 +
input[type="search"]::-webkit-search-results-decoration {
212 +
  -webkit-appearance: none;
213 +
}
214 +
215 +
input[type="checkbox"],
216 +
input[type="radio"] {
217 +
  -webkit-appearance: none;
218 +
  appearance: none;
219 +
  width: 16px;
220 +
  height: 16px;
221 +
  background: transparent;
222 +
  border: 1px solid #ffffff;
223 +
  border-radius: 0;
224 +
  padding: 0;
225 +
  cursor: pointer;
226 +
  position: relative;
227 +
  flex-shrink: 0;
228 +
  touch-action: manipulation;
229 +
}
230 +
231 +
input[type="radio"] {
232 +
  border-radius: 50%;
233 +
}
234 +
235 +
input[type="checkbox"]:checked::after {
236 +
  content: '✔︎';
237 +
  position: absolute;
238 +
  top: 50%;
239 +
  left: 50%;
240 +
  transform: translate(-50%, -50%);
241 +
  font-size: 12px;
242 +
  color: #ffffff;
243 +
  line-height: 1;
244 +
}
245 +
246 +
input[type="radio"]:checked::after {
247 +
  content: '';
248 +
  position: absolute;
249 +
  top: 50%;
250 +
  left: 50%;
251 +
  width: 8px;
252 +
  height: 8px;
253 +
  border-radius: 50%;
254 +
  background: #ffffff;
255 +
  transform: translate(-50%, -50%);
256 +
}
257 +
258 +
.checkbox-field {
259 +
  justify-content: flex-end;
260 +
}
261 +
262 +
.checkbox-field label {
263 +
  display: flex;
264 +
  align-items: center;
265 +
  gap: 0.5rem;
266 +
  font-size: 14px;
267 +
  opacity: 1;
268 +
  cursor: pointer;
269 +
}
270 +
271 +
/* Switch */
272 +
273 +
.switch-row {
274 +
  display: flex;
275 +
  align-items: center;
276 +
  gap: 0.5rem;
277 +
}
278 +
279 +
.switch-label {
280 +
  font-size: 14px;
281 +
}
282 +
283 +
.switch {
284 +
  position: relative;
285 +
  display: inline-block;
286 +
  width: 36px;
287 +
  height: 20px;
288 +
  flex-shrink: 0;
289 +
}
290 +
291 +
.switch input {
292 +
  opacity: 0;
293 +
  width: 0;
294 +
  height: 0;
295 +
}
296 +
297 +
.switch-slider {
298 +
  position: absolute;
299 +
  cursor: pointer;
300 +
  top: 0;
301 +
  left: 0;
302 +
  right: 0;
303 +
  bottom: 0;
304 +
  background: #333;
305 +
  border-radius: 20px;
306 +
  transition: background 0.2s;
307 +
}
308 +
309 +
.switch-slider::before {
310 +
  content: "";
311 +
  position: absolute;
312 +
  height: 14px;
313 +
  width: 14px;
314 +
  left: 3px;
315 +
  bottom: 3px;
316 +
  background: #888;
317 +
  border-radius: 50%;
318 +
  transition: transform 0.2s, background 0.2s;
319 +
}
320 +
321 +
.switch input:checked + .switch-slider {
322 +
  background: #555;
323 +
}
324 +
325 +
.switch input:checked + .switch-slider::before {
326 +
  transform: translateX(16px);
327 +
  background: #ffffff;
328 +
}
329 +
330 +
/* ── Buttons ──────────────────────────────────────────────────────── */
331 +
332 +
button,
333 +
.btn {
334 +
  background: #121113;
335 +
  color: #ffffff;
336 +
  padding: 0.2rem 0.75rem;
337 +
  border: 1px solid #ffffff;
338 +
  cursor: pointer;
339 +
  width: fit-content;
340 +
  font-size: 14px;
341 +
  line-height: 1.4;
342 +
  border-radius: 0;
343 +
  -webkit-appearance: none;
344 +
  appearance: none;
345 +
  text-decoration: none;
346 +
  display: inline-block;
347 +
  touch-action: manipulation;
348 +
}
349 +
350 +
button:hover,
351 +
.btn:hover {
352 +
  opacity: 0.7;
353 +
}
354 +
355 +
button.loading {
356 +
  cursor: wait;
357 +
}
358 +
359 +
.link-button {
360 +
  background: none;
361 +
  border: none;
362 +
  color: #ffffff;
363 +
  cursor: pointer;
364 +
  font-size: 12px;
365 +
  padding: 0;
366 +
  font-family: inherit;
367 +
  -webkit-appearance: none;
368 +
  appearance: none;
369 +
}
370 +
371 +
.link-button:hover {
372 +
  opacity: 0.7;
373 +
}
374 +
375 +
.link-button.danger {
376 +
  opacity: 0.5;
377 +
}
378 +
379 +
.link-button.danger:hover {
380 +
  opacity: 0.3;
381 +
}
382 +
383 +
.inline-form {
384 +
  display: inline;
385 +
  margin: 0;
386 +
  padding: 0;
387 +
}
388 +
389 +
/* ── Feedback ─────────────────────────────────────────────────────── */
390 +
391 +
.error {
392 +
  color: #ffffff;
393 +
  border-left: 2px solid #ffffff;
394 +
  padding-left: 0.5rem;
395 +
  font-size: 13px;
396 +
  opacity: 0.8;
397 +
}
398 +
399 +
.success {
400 +
  color: #ffffff;
401 +
  border-left: 2px solid #555;
402 +
  padding-left: 0.5rem;
403 +
  font-size: 13px;
404 +
  opacity: 0.7;
405 +
}
406 +
407 +
.empty {
408 +
  opacity: 0.5;
409 +
  font-size: 12px;
410 +
}
411 +
412 +
/* ── Item list (generic stacked list pattern) ────────────────────── */
413 +
414 +
.item-list {
415 +
  display: flex;
416 +
  flex-direction: column;
417 +
  width: 100%;
418 +
}
419 +
420 +
.item {
421 +
  display: flex;
422 +
  flex-direction: column;
423 +
  gap: 0.25rem;
424 +
  padding: 0.75rem 0;
425 +
  border-bottom: 1px solid #333;
426 +
  min-width: 0;
427 +
}
428 +
429 +
.item:hover {
430 +
  opacity: 0.7;
431 +
}
432 +
433 +
.item-title {
434 +
  display: grid;
435 +
  grid-template-columns: auto minmax(0, 1fr);
436 +
  align-items: center;
437 +
  gap: 0.4rem;
438 +
  min-width: 0;
439 +
  max-width: 100%;
440 +
  font-size: 16px;
441 +
  overflow-wrap: anywhere;
442 +
}
443 +
444 +
.item-meta {
445 +
  max-width: 100%;
446 +
  font-size: 12px;
447 +
  opacity: 0.5;
448 +
  overflow-wrap: anywhere;
449 +
  word-break: break-word;
450 +
}
451 +
452 +
.favicon {
453 +
  flex-shrink: 0;
454 +
}
455 +
456 +
/* ── Admin list (horizontal row w/ actions) ──────────────────────── */
457 +
458 +
.admin-list {
459 +
  display: flex;
460 +
  flex-direction: column;
461 +
  width: 100%;
462 +
}
463 +
464 +
.admin-list-item {
465 +
  display: flex;
466 +
  justify-content: space-between;
467 +
  align-items: center;
468 +
  padding: 8px 0;
469 +
  border-bottom: 1px solid #333;
470 +
  gap: 1rem;
471 +
  min-width: 0;
472 +
}
473 +
474 +
.admin-list-info {
475 +
  display: flex;
476 +
  flex: 1;
477 +
  flex-direction: column;
478 +
  gap: 0.2rem;
479 +
  min-width: 0;
480 +
}
481 +
482 +
.admin-list-title {
483 +
  display: grid;
484 +
  grid-template-columns: auto minmax(0, 1fr);
485 +
  align-items: center;
486 +
  gap: 0.4rem;
487 +
  min-width: 0;
488 +
  max-width: 100%;
489 +
  font-size: 15px;
490 +
  white-space: normal;
491 +
  overflow: visible;
492 +
  text-overflow: clip;
493 +
  overflow-wrap: anywhere;
494 +
}
495 +
496 +
.admin-list-meta {
497 +
  display: flex;
498 +
  gap: 0.75rem;
499 +
  align-items: center;
500 +
}
501 +
502 +
.admin-list-date {
503 +
  font-size: 11px;
504 +
  opacity: 0.4;
505 +
}
506 +
507 +
.admin-list-actions {
508 +
  display: flex;
509 +
  gap: 1rem;
510 +
  font-size: 12px;
511 +
  flex-shrink: 0;
512 +
  flex-wrap: wrap;
513 +
}
514 +
515 +
.admin-toolbar {
516 +
  display: flex;
517 +
  justify-content: space-between;
518 +
  align-items: center;
519 +
  width: 100%;
520 +
}
521 +
522 +
.admin-toolbar h2 {
523 +
  font-size: 18px;
524 +
  font-weight: 700;
525 +
}
526 +
527 +
@media (max-width: 480px) {
528 +
  .admin-list-item {
529 +
    flex-direction: column;
530 +
    align-items: flex-start;
531 +
    gap: 0.5rem;
532 +
  }
533 +
}
534 +
535 +
/* ── Tags / badges ───────────────────────────────────────────────── */
536 +
537 +
.tag {
538 +
  font-size: 11px;
539 +
  opacity: 0.5;
540 +
  background: #1e1c1f;
541 +
  padding: 1px 6px;
542 +
  border: 1px solid #333;
543 +
}
544 +
545 +
.status-badge {
546 +
  font-size: 11px;
547 +
  padding: 1px 6px;
548 +
  border: 1px solid #333;
549 +
}
550 +
551 +
.status-published {
552 +
  opacity: 1;
553 +
  border-color: #555;
554 +
}
555 +
556 +
.status-draft {
557 +
  opacity: 0.4;
558 +
}
559 +
560 +
/* ── Tables ──────────────────────────────────────────────────────── */
561 +
562 +
table {
563 +
  width: 100%;
564 +
  border-collapse: collapse;
565 +
}
566 +
567 +
th {
568 +
  opacity: 0.5;
569 +
  font-weight: 400;
570 +
  font-size: 12px;
571 +
  text-transform: uppercase;
572 +
  text-align: left;
573 +
  padding: 6px;
574 +
  border-bottom: 1px solid #333;
575 +
}
576 +
577 +
td {
578 +
  padding: 6px;
579 +
  border-bottom: 1px solid #333;
580 +
}
581 +
582 +
/* ── Spinner (braille) ────────────────────────────────────────────── */
583 +
584 +
.spinner {
585 +
  margin-left: 0.6rem;
586 +
}
587 +
588 +
.spinner::after {
589 +
  content: "⠋";
590 +
  display: inline-block;
591 +
  animation: braille-spin 0.8s steps(10) infinite;
592 +
}
593 +
594 +
@keyframes braille-spin {
595 +
  0%   { content: "⠋"; }
596 +
  10%  { content: "⠙"; }
597 +
  20%  { content: "⠹"; }
598 +
  30%  { content: "⠸"; }
599 +
  40%  { content: "⠼"; }
600 +
  50%  { content: "⠴"; }
601 +
  60%  { content: "⠦"; }
602 +
  70%  { content: "⠧"; }
603 +
  80%  { content: "⠇"; }
604 +
  90%  { content: "⠏"; }
605 +
}
606 +
607 +
/* ── Inline code ─────────────────────────────────────────────────── */
608 +
609 +
code {
610 +
  background: #1e1c1f;
611 +
  padding: 2px 4px;
612 +
  font-size: 13px;
613 +
}
614 +
615 +
pre {
616 +
  background: #1e1c1f;
617 +
  padding: 12px;
618 +
  overflow-x: auto;
619 +
  border: 1px solid #333;
620 +
  -webkit-overflow-scrolling: touch;
621 +
}
622 +
623 +
pre code {
624 +
  background: none;
625 +
  padding: 0;
626 +
}
627 +
628 +
/* ── Footer ──────────────────────────────────────────────────────── */
629 +
630 +
.footer {
631 +
  width: 100%;
632 +
  border-top: 1px solid #333;
633 +
  padding-top: 1rem;
634 +
  margin-top: auto;
635 +
  display: flex;
636 +
  justify-content: center;
637 +
}
638 +
639 +
/* ── Utility ─────────────────────────────────────────────────────── */
640 +
641 +
.hidden {
642 +
  display: none;
643 +
}
644 +
645 +
.scroll-x {
646 +
  overflow-x: auto;
647 +
  -webkit-overflow-scrolling: touch;
648 +
}
apps/jotts-go/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts-go/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts-go/db.go (added) +137 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"time"
7 +
8 +
	_ "modernc.org/sqlite"
9 +
)
10 +
11 +
const noteColumns = `id, short_id, title, content, created_at, updated_at`
12 +
13 +
const schema = `
14 +
CREATE TABLE IF NOT EXISTS notes (
15 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
16 +
    short_id   TEXT NOT NULL UNIQUE,
17 +
    title      TEXT NOT NULL,
18 +
    content    TEXT NOT NULL,
19 +
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
20 +
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21 +
);
22 +
23 +
CREATE TABLE IF NOT EXISTS sessions (
24 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
25 +
    token      TEXT NOT NULL UNIQUE,
26 +
    expires_at TEXT NOT NULL
27 +
);
28 +
`
29 +
30 +
func openDB(path string) (*sql.DB, error) {
31 +
	db, err := sql.Open("sqlite", path)
32 +
	if err != nil {
33 +
		return nil, err
34 +
	}
35 +
	db.SetMaxOpenConns(1)
36 +
	db.SetMaxIdleConns(1)
37 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
38 +
		return nil, err
39 +
	}
40 +
	if _, err := db.Exec(schema); err != nil {
41 +
		return nil, err
42 +
	}
43 +
	return db, nil
44 +
}
45 +
46 +
func scanNote(scanner interface{ Scan(dest ...any) error }) (*Note, error) {
47 +
	var n Note
48 +
	err := scanner.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt)
49 +
	if errors.Is(err, sql.ErrNoRows) {
50 +
		return nil, nil
51 +
	}
52 +
	if err != nil {
53 +
		return nil, err
54 +
	}
55 +
	return &n, nil
56 +
}
57 +
58 +
func createNote(db *sql.DB, title, content string) (*Note, error) {
59 +
	shortID, err := generateShortID(10)
60 +
	if err != nil {
61 +
		return nil, err
62 +
	}
63 +
	res, err := db.Exec(`INSERT INTO notes (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
64 +
	if err != nil {
65 +
		return nil, err
66 +
	}
67 +
	id, err := res.LastInsertId()
68 +
	if err != nil {
69 +
		return nil, err
70 +
	}
71 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id = ?`, id))
72 +
}
73 +
74 +
func getNoteByShortID(db *sql.DB, shortID string) (*Note, error) {
75 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE short_id = ?`, shortID))
76 +
}
77 +
78 +
func listNotes(db *sql.DB) ([]Note, error) {
79 +
	rows, err := db.Query(`SELECT ` + noteColumns + ` FROM notes ORDER BY id DESC`)
80 +
	if err != nil {
81 +
		return nil, err
82 +
	}
83 +
	defer rows.Close()
84 +
	var out []Note
85 +
	for rows.Next() {
86 +
		var n Note
87 +
		if err := rows.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
88 +
			return nil, err
89 +
		}
90 +
		out = append(out, n)
91 +
	}
92 +
	return out, rows.Err()
93 +
}
94 +
95 +
func updateNoteByShortID(db *sql.DB, shortID, title, content string) (*Note, error) {
96 +
	res, err := db.Exec(`UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE short_id = ?`, title, content, shortID)
97 +
	if err != nil {
98 +
		return nil, err
99 +
	}
100 +
	n, _ := res.RowsAffected()
101 +
	if n == 0 {
102 +
		return nil, nil
103 +
	}
104 +
	return getNoteByShortID(db, shortID)
105 +
}
106 +
107 +
func deleteNoteByShortID(db *sql.DB, shortID string) (bool, error) {
108 +
	res, err := db.Exec(`DELETE FROM notes WHERE short_id = ?`, shortID)
109 +
	if err != nil {
110 +
		return false, err
111 +
	}
112 +
	n, _ := res.RowsAffected()
113 +
	return n > 0, nil
114 +
}
115 +
116 +
func createSession(db *sql.DB, token string, expiresAt time.Time) error {
117 +
	_, err := db.Exec(`INSERT INTO sessions (token, expires_at) VALUES (?, ?)`, token, expiresAt.UTC().Format("2006-01-02 15:04:05"))
118 +
	return err
119 +
}
120 +
121 +
func isValidSession(db *sql.DB, token string) bool {
122 +
	var expires string
123 +
	err := db.QueryRow(`SELECT expires_at FROM sessions WHERE token = ?`, token).Scan(&expires)
124 +
	if err != nil {
125 +
		return false
126 +
	}
127 +
	t, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.UTC)
128 +
	return err == nil && t.After(time.Now().UTC())
129 +
}
130 +
131 +
func deleteSession(db *sql.DB, token string) {
132 +
	_, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
133 +
}
134 +
135 +
func pruneExpiredSessions(db *sql.DB) {
136 +
	_, _ = db.Exec(`DELETE FROM sessions WHERE expires_at < datetime('now')`)
137 +
}
apps/jotts-go/docker-compose.yml (added) +20 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/jotts-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme}
10 +
      - JOTTS_DB_PATH=/data/jotts.sqlite
11 +
      - COOKIE_SECURE=false
12 +
      - HOST=0.0.0.0
13 +
      - PORT=${PORT:-3000}
14 +
      - JOTTS_API_KEY=${JOTTS_API_KEY:-}
15 +
    volumes:
16 +
      - jotts-go-data:/data
17 +
    restart: unless-stopped
18 +
19 +
volumes:
20 +
  jotts-go-data:
apps/jotts-go/go.mod (added) +21 −0
1 +
module github.com/stevedylandev/andromeda/apps/jotts-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/yuin/goldmark v1.7.8
7 +
	modernc.org/sqlite v1.37.1
8 +
)
9 +
10 +
require (
11 +
	github.com/dustin/go-humanize v1.0.1 // indirect
12 +
	github.com/google/uuid v1.6.0 // indirect
13 +
	github.com/mattn/go-isatty v0.0.20 // indirect
14 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
15 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
16 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
17 +
	golang.org/x/sys v0.33.0 // indirect
18 +
	modernc.org/libc v1.65.7 // indirect
19 +
	modernc.org/mathutil v1.7.1 // indirect
20 +
	modernc.org/memory v1.11.0 // indirect
21 +
)
apps/jotts-go/go.sum (added) +49 −0
1 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
4 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
5 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
9 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
10 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
13 +
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
14 +
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
15 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
16 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
17 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
18 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
19 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
20 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
21 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
22 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
23 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
24 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
25 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
26 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
27 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
28 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
29 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
30 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
31 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
32 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
33 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
34 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
35 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
36 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
37 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
38 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
39 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
40 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
41 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
42 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
43 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
44 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
45 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
46 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
47 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
48 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
49 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/jotts-go/handlers_api.go (added) +87 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
)
7 +
8 +
func (a *App) apiListNotes(w http.ResponseWriter, r *http.Request) {
9 +
	notes, err := listNotes(a.DB)
10 +
	if err != nil {
11 +
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
12 +
		return
13 +
	}
14 +
	if notes == nil {
15 +
		notes = []Note{}
16 +
	}
17 +
	writeJSON(w, http.StatusOK, notes)
18 +
}
19 +
20 +
func (a *App) apiGetNote(w http.ResponseWriter, r *http.Request) {
21 +
	shortID := r.PathValue("short_id")
22 +
	note, err := getNoteByShortID(a.DB, shortID)
23 +
	if err != nil {
24 +
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
25 +
		return
26 +
	}
27 +
	if note == nil {
28 +
		http.NotFound(w, r)
29 +
		return
30 +
	}
31 +
	writeJSON(w, http.StatusOK, note)
32 +
}
33 +
34 +
func (a *App) apiCreateNote(w http.ResponseWriter, r *http.Request) {
35 +
	var body NoteInput
36 +
	if !decodeJSON(w, r, &body) {
37 +
		return
38 +
	}
39 +
	title := strings.TrimSpace(body.Title)
40 +
	if title == "" {
41 +
		http.Error(w, "title required", http.StatusBadRequest)
42 +
		return
43 +
	}
44 +
	note, err := createNote(a.DB, title, body.Content)
45 +
	if err != nil {
46 +
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
47 +
		return
48 +
	}
49 +
	writeJSON(w, http.StatusCreated, note)
50 +
}
51 +
52 +
func (a *App) apiUpdateNote(w http.ResponseWriter, r *http.Request) {
53 +
	shortID := r.PathValue("short_id")
54 +
	var body NoteInput
55 +
	if !decodeJSON(w, r, &body) {
56 +
		return
57 +
	}
58 +
	title := strings.TrimSpace(body.Title)
59 +
	if title == "" {
60 +
		http.Error(w, "title required", http.StatusBadRequest)
61 +
		return
62 +
	}
63 +
	note, err := updateNoteByShortID(a.DB, shortID, title, body.Content)
64 +
	if err != nil {
65 +
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
66 +
		return
67 +
	}
68 +
	if note == nil {
69 +
		http.NotFound(w, r)
70 +
		return
71 +
	}
72 +
	writeJSON(w, http.StatusOK, note)
73 +
}
74 +
75 +
func (a *App) apiDeleteNote(w http.ResponseWriter, r *http.Request) {
76 +
	shortID := r.PathValue("short_id")
77 +
	ok, err := deleteNoteByShortID(a.DB, shortID)
78 +
	if err != nil {
79 +
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
80 +
		return
81 +
	}
82 +
	if !ok {
83 +
		http.NotFound(w, r)
84 +
		return
85 +
	}
86 +
	w.WriteHeader(http.StatusNoContent)
87 +
}
apps/jotts-go/handlers_web.go (added) +148 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"net/http"
6 +
	"strings"
7 +
	"time"
8 +
)
9 +
10 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
11 +
	a.render(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")})
12 +
}
13 +
14 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
15 +
	if err := r.ParseForm(); err != nil {
16 +
		http.Redirect(w, r, "/login?error=Invalid+request", http.StatusSeeOther)
17 +
		return
18 +
	}
19 +
	password := r.FormValue("password")
20 +
	if !secureEqual(password, a.Password) {
21 +
		http.Redirect(w, r, "/login?error=Invalid+password", http.StatusSeeOther)
22 +
		return
23 +
	}
24 +
	token, err := generateSessionToken()
25 +
	if err != nil {
26 +
		a.Log.Error("session token failed", "err", err)
27 +
		http.Redirect(w, r, "/login?error=Server+error", http.StatusSeeOther)
28 +
		return
29 +
	}
30 +
	if err := createSession(a.DB, token, time.Now().UTC().Add(7*24*time.Hour)); err != nil {
31 +
		a.Log.Error("create session failed", "err", err)
32 +
		http.Redirect(w, r, "/login?error=Server+error", http.StatusSeeOther)
33 +
		return
34 +
	}
35 +
	http.SetCookie(w, a.sessionCookie(token))
36 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
37 +
}
38 +
39 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
40 +
	if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
41 +
		deleteSession(a.DB, c.Value)
42 +
	}
43 +
	http.SetCookie(w, a.clearSessionCookie())
44 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
45 +
}
46 +
47 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
48 +
	notes, err := listNotes(a.DB)
49 +
	if err != nil {
50 +
		a.Log.Error("list notes failed", "err", err)
51 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
52 +
		return
53 +
	}
54 +
	a.render(w, "index.html", indexPageData{Notes: notes})
55 +
}
56 +
57 +
func (a *App) newNoteGetHandler(w http.ResponseWriter, r *http.Request) {
58 +
	a.render(w, "new.html", newPageData{Error: r.URL.Query().Get("error")})
59 +
}
60 +
61 +
func (a *App) createNoteHandler(w http.ResponseWriter, r *http.Request) {
62 +
	if err := r.ParseForm(); err != nil {
63 +
		redirectWithError(w, r, "/notes/new", "Invalid request")
64 +
		return
65 +
	}
66 +
	title := strings.TrimSpace(r.FormValue("title"))
67 +
	content := r.FormValue("content")
68 +
	if title == "" {
69 +
		redirectWithError(w, r, "/notes/new", "Title is required")
70 +
		return
71 +
	}
72 +
	note, err := createNote(a.DB, title, content)
73 +
	if err != nil {
74 +
		a.Log.Error("create note failed", "err", err)
75 +
		redirectWithError(w, r, "/notes/new", "Failed to create note")
76 +
		return
77 +
	}
78 +
	http.Redirect(w, r, "/notes/"+note.ShortID, http.StatusSeeOther)
79 +
}
80 +
81 +
func (a *App) viewNoteHandler(w http.ResponseWriter, r *http.Request) {
82 +
	shortID := r.PathValue("short_id")
83 +
	note, err := getNoteByShortID(a.DB, shortID)
84 +
	if err != nil {
85 +
		a.Log.Error("get note failed", "err", err)
86 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
87 +
		return
88 +
	}
89 +
	if note == nil {
90 +
		http.Error(w, "Note not found", http.StatusNotFound)
91 +
		return
92 +
	}
93 +
	rendered, err := renderMarkdown(note.Content)
94 +
	if err != nil {
95 +
		a.Log.Error("render markdown failed", "err", err)
96 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
97 +
		return
98 +
	}
99 +
	a.render(w, "view.html", viewPageData{Note: *note, Rendered: template.HTML(rendered)})
100 +
}
101 +
102 +
func (a *App) editNoteGetHandler(w http.ResponseWriter, r *http.Request) {
103 +
	shortID := r.PathValue("short_id")
104 +
	note, err := getNoteByShortID(a.DB, shortID)
105 +
	if err != nil {
106 +
		a.Log.Error("get note failed", "err", err)
107 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
108 +
		return
109 +
	}
110 +
	if note == nil {
111 +
		http.Error(w, "Note not found", http.StatusNotFound)
112 +
		return
113 +
	}
114 +
	a.render(w, "edit.html", editPageData{Note: *note, Error: r.URL.Query().Get("error")})
115 +
}
116 +
117 +
func (a *App) updateNoteHandler(w http.ResponseWriter, r *http.Request) {
118 +
	shortID := r.PathValue("short_id")
119 +
	if err := r.ParseForm(); err != nil {
120 +
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Invalid request")
121 +
		return
122 +
	}
123 +
	title := strings.TrimSpace(r.FormValue("title"))
124 +
	content := r.FormValue("content")
125 +
	if title == "" {
126 +
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Title is required")
127 +
		return
128 +
	}
129 +
	note, err := updateNoteByShortID(a.DB, shortID, title, content)
130 +
	if err != nil {
131 +
		a.Log.Error("update note failed", "err", err)
132 +
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Failed to update note")
133 +
		return
134 +
	}
135 +
	if note == nil {
136 +
		http.Error(w, "Note not found", http.StatusNotFound)
137 +
		return
138 +
	}
139 +
	http.Redirect(w, r, "/notes/"+shortID, http.StatusSeeOther)
140 +
}
141 +
142 +
func (a *App) deleteNoteHandler(w http.ResponseWriter, r *http.Request) {
143 +
	shortID := r.PathValue("short_id")
144 +
	if _, err := deleteNoteByShortID(a.DB, shortID); err != nil {
145 +
		a.Log.Error("delete note failed", "err", err)
146 +
	}
147 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
148 +
}
apps/jotts-go/main.go (added) +50 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
	"strings"
10 +
)
11 +
12 +
func main() {
13 +
	loadDotEnv(".env")
14 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
15 +
16 +
	dbPath := getenv("JOTTS_DB_PATH", "jotts.sqlite")
17 +
	db, err := openDB(dbPath)
18 +
	if err != nil {
19 +
		log.Fatal(err)
20 +
	}
21 +
	defer db.Close()
22 +
	pruneExpiredSessions(db)
23 +
24 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
25 +
26 +
	password := os.Getenv("JOTTS_PASSWORD")
27 +
	if password == "" {
28 +
		logger.Warn("JOTTS_PASSWORD not set, using default 'changeme'")
29 +
		password = "changeme"
30 +
	}
31 +
	apiKey := os.Getenv("JOTTS_API_KEY")
32 +
	if apiKey == "" {
33 +
		logger.Info("JOTTS_API_KEY not set, /api/* will return 403")
34 +
	}
35 +
36 +
	app := &App{
37 +
		DB:           db,
38 +
		Log:          logger,
39 +
		Templates:    tmpl,
40 +
		Password:     password,
41 +
		APIKey:       apiKey,
42 +
		CookieSecure: strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"),
43 +
	}
44 +
45 +
	addr := getenv("HOST", "127.0.0.1") + ":" + getenv("PORT", "3000")
46 +
	logger.Info("jotts-go server running", "addr", addr)
47 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
48 +
		log.Fatal(err)
49 +
	}
50 +
}
apps/jotts-go/markdown.go (added) +24 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
6 +
	"github.com/yuin/goldmark"
7 +
	"github.com/yuin/goldmark/extension"
8 +
	"github.com/yuin/goldmark/parser"
9 +
	"github.com/yuin/goldmark/renderer/html"
10 +
)
11 +
12 +
var md = goldmark.New(
13 +
	goldmark.WithExtensions(extension.Strikethrough, extension.Table, extension.TaskList),
14 +
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
15 +
	goldmark.WithRendererOptions(html.WithUnsafe()),
16 +
)
17 +
18 +
func renderMarkdown(source string) (string, error) {
19 +
	var buf bytes.Buffer
20 +
	if err := md.Convert([]byte(source), &buf); err != nil {
21 +
		return "", err
22 +
	}
23 +
	return buf.String(), nil
24 +
}
apps/jotts-go/middleware.go (added) +69 −0
1 +
package main
2 +
3 +
import (
4 +
	"crypto/subtle"
5 +
	"net/http"
6 +
)
7 +
8 +
const sessionCookieName = "session"
9 +
10 +
func (a *App) requireSession(next http.HandlerFunc) http.HandlerFunc {
11 +
	return func(w http.ResponseWriter, r *http.Request) {
12 +
		if !a.hasValidSession(r) {
13 +
			http.Redirect(w, r, "/login", http.StatusSeeOther)
14 +
			return
15 +
		}
16 +
		next(w, r)
17 +
	}
18 +
}
19 +
20 +
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
21 +
	return func(w http.ResponseWriter, r *http.Request) {
22 +
		if a.APIKey == "" {
23 +
			http.Error(w, "API key not configured on server", http.StatusForbidden)
24 +
			return
25 +
		}
26 +
		provided := r.Header.Get("x-api-key")
27 +
		if !secureEqual(provided, a.APIKey) {
28 +
			http.Error(w, "Invalid API key", http.StatusUnauthorized)
29 +
			return
30 +
		}
31 +
		next(w, r)
32 +
	}
33 +
}
34 +
35 +
func (a *App) hasValidSession(r *http.Request) bool {
36 +
	c, err := r.Cookie(sessionCookieName)
37 +
	if err != nil || c.Value == "" {
38 +
		return false
39 +
	}
40 +
	return isValidSession(a.DB, c.Value)
41 +
}
42 +
43 +
func (a *App) sessionCookie(token string) *http.Cookie {
44 +
	return &http.Cookie{
45 +
		Name:     sessionCookieName,
46 +
		Value:    token,
47 +
		Path:     "/",
48 +
		HttpOnly: true,
49 +
		Secure:   a.CookieSecure,
50 +
		SameSite: http.SameSiteLaxMode,
51 +
		MaxAge:   7 * 24 * 60 * 60,
52 +
	}
53 +
}
54 +
55 +
func (a *App) clearSessionCookie() *http.Cookie {
56 +
	return &http.Cookie{
57 +
		Name:     sessionCookieName,
58 +
		Value:    "",
59 +
		Path:     "/",
60 +
		HttpOnly: true,
61 +
		Secure:   a.CookieSecure,
62 +
		SameSite: http.SameSiteLaxMode,
63 +
		MaxAge:   -1,
64 +
	}
65 +
}
66 +
67 +
func secureEqual(a, b string) bool {
68 +
	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
69 +
}
apps/jotts-go/routes.go (added) +30 −0
1 +
package main
2 +
3 +
import "net/http"
4 +
5 +
func (a *App) routes() *http.ServeMux {
6 +
	mux := http.NewServeMux()
7 +
8 +
	mux.HandleFunc("GET /static/", a.embeddedHandler("static"))
9 +
	mux.HandleFunc("GET /assets/", a.embeddedHandler("assets"))
10 +
11 +
	mux.HandleFunc("GET /login", a.loginGetHandler)
12 +
	mux.HandleFunc("POST /login", a.loginPostHandler)
13 +
	mux.HandleFunc("GET /logout", a.logoutHandler)
14 +
15 +
	mux.HandleFunc("GET /{$}", a.requireSession(a.indexHandler))
16 +
	mux.HandleFunc("GET /notes/new", a.requireSession(a.newNoteGetHandler))
17 +
	mux.HandleFunc("POST /notes", a.requireSession(a.createNoteHandler))
18 +
	mux.HandleFunc("GET /notes/{short_id}", a.requireSession(a.viewNoteHandler))
19 +
	mux.HandleFunc("GET /notes/{short_id}/edit", a.requireSession(a.editNoteGetHandler))
20 +
	mux.HandleFunc("POST /notes/{short_id}", a.requireSession(a.updateNoteHandler))
21 +
	mux.HandleFunc("POST /notes/{short_id}/delete", a.requireSession(a.deleteNoteHandler))
22 +
23 +
	mux.HandleFunc("GET /api/notes", a.requireAPIKey(a.apiListNotes))
24 +
	mux.HandleFunc("POST /api/notes", a.requireAPIKey(a.apiCreateNote))
25 +
	mux.HandleFunc("GET /api/notes/{short_id}", a.requireAPIKey(a.apiGetNote))
26 +
	mux.HandleFunc("PUT /api/notes/{short_id}", a.requireAPIKey(a.apiUpdateNote))
27 +
	mux.HandleFunc("DELETE /api/notes/{short_id}", a.requireAPIKey(a.apiDeleteNote))
28 +
29 +
	return mux
30 +
}
apps/jotts-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/jotts-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/jotts-go/static/styles.css (added) +144 −0
1 +
/* jotts — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
.note-list {
6 +
  display: flex;
7 +
  flex-direction: column;
8 +
  width: 100%;
9 +
}
10 +
11 +
.note-item {
12 +
  display: flex;
13 +
  justify-content: space-between;
14 +
  align-items: center;
15 +
  padding: 8px 0;
16 +
  border-bottom: 1px solid #333;
17 +
  text-decoration: none;
18 +
}
19 +
20 +
.note-item:hover {
21 +
  opacity: 0.7;
22 +
}
23 +
24 +
.note-title {
25 +
  font-size: 16px;
26 +
}
27 +
28 +
.note-date {
29 +
  font-size: 12px;
30 +
  opacity: 0.5;
31 +
}
32 +
33 +
/* Note view */
34 +
35 +
.note-header {
36 +
  display: flex;
37 +
  flex-direction: column;
38 +
  gap: 0.25rem;
39 +
}
40 +
41 +
.note-header h1 {
42 +
  font-size: 24px;
43 +
  font-weight: 700;
44 +
  letter-spacing: -0.5px;
45 +
}
46 +
47 +
.note-actions {
48 +
  display: flex;
49 +
  gap: 1.5rem;
50 +
  font-size: 12px;
51 +
}
52 +
53 +
/* Markdown rendered content */
54 +
55 +
.markdown-body {
56 +
  width: 100%;
57 +
  line-height: 1.6;
58 +
}
59 +
60 +
.markdown-body h1,
61 +
.markdown-body h2,
62 +
.markdown-body h3,
63 +
.markdown-body h4,
64 +
.markdown-body h5,
65 +
.markdown-body h6 {
66 +
  margin-top: 1.5rem;
67 +
  margin-bottom: 0.5rem;
68 +
  font-weight: 700;
69 +
}
70 +
71 +
.markdown-body h1 { font-size: 18px; }
72 +
.markdown-body h2 { font-size: 16px; }
73 +
.markdown-body h3 { font-size: 15px; }
74 +
.markdown-body h4,
75 +
.markdown-body h5,
76 +
.markdown-body h6 { font-size: 14px; }
77 +
78 +
.markdown-body p {
79 +
  margin-bottom: 0.75rem;
80 +
}
81 +
82 +
.markdown-body ul,
83 +
.markdown-body ol {
84 +
  margin-left: 1.5rem;
85 +
  margin-bottom: 0.75rem;
86 +
}
87 +
88 +
.markdown-body li {
89 +
  margin-bottom: 0.25rem;
90 +
}
91 +
92 +
.markdown-body pre {
93 +
  margin-bottom: 0.75rem;
94 +
}
95 +
96 +
.markdown-body blockquote {
97 +
  border-left: 2px solid #555;
98 +
  padding-left: 12px;
99 +
  opacity: 0.7;
100 +
  margin-bottom: 0.75rem;
101 +
}
102 +
103 +
.markdown-body table {
104 +
  margin-bottom: 0.75rem;
105 +
}
106 +
107 +
.markdown-body th,
108 +
.markdown-body td {
109 +
  border: 1px solid #333;
110 +
  padding: 6px;
111 +
  text-align: left;
112 +
}
113 +
114 +
.markdown-body th {
115 +
  font-weight: 700;
116 +
}
117 +
118 +
.markdown-body hr {
119 +
  border: none;
120 +
  border-top: 1px solid #333;
121 +
  margin: 1rem 0;
122 +
}
123 +
124 +
.markdown-body a {
125 +
  text-decoration: underline;
126 +
}
127 +
128 +
.markdown-body img {
129 +
  max-width: 100%;
130 +
}
131 +
132 +
.markdown-body li:has(> input[type="checkbox"]) {
133 +
  list-style: none;
134 +
  margin-left: -1.5rem;
135 +
}
136 +
137 +
.markdown-body input[type="checkbox"] {
138 +
  width: 14px;
139 +
  height: 14px;
140 +
  margin-right: 6px;
141 +
  vertical-align: middle;
142 +
  position: relative;
143 +
  top: -1px;
144 +
}
apps/jotts-go/templates/edit.html (added) +32 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts — {{.Note.Title}}</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta name="theme-color" content="#121113" />
13 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
14 +
    <link rel="stylesheet" href="/static/styles.css">
15 +
  </head>
16 +
  <body>
17 +
    <header class="header">
18 +
      <a href="/" class="logo">jotts</a>
19 +
      <nav class="links"><a href="/notes/new">new</a></nav>
20 +
    </header>
21 +
    <main>
22 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
      <form method="POST" action="/notes/{{.Note.ShortID}}" class="form">
24 +
        <label for="title">title</label>
25 +
        <input type="text" id="title" name="title" value="{{.Note.Title}}" required>
26 +
        <label for="content">content</label>
27 +
        <textarea id="content" name="content">{{.Note.Content}}</textarea>
28 +
        <button type="submit">save</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/index.html (added) +44 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta property="og:title" content="Jotts">
13 +
    <meta property="og:image" content="/static/og.png">
14 +
    <meta property="og:type" content="website">
15 +
    <meta name="theme-color" content="#121113" />
16 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
17 +
    <link rel="stylesheet" href="/static/styles.css">
18 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <a href="/" class="logo">jotts</a>
22 +
      <nav class="links">
23 +
        <a href="/notes/new">new</a>
24 +
      </nav>
25 +
    </header>
26 +
    <main>
27 +
      {{if not .Notes}}<p class="empty">no notes yet</p>{{end}}
28 +
      <div class="note-list">
29 +
        {{range .Notes}}
30 +
          <a href="/notes/{{.ShortID}}" class="note-item">
31 +
            <span class="note-title">{{.Title}}</span>
32 +
            <time class="note-date" datetime="{{.UpdatedAt}}Z">{{.UpdatedAt}}</time>
33 +
          </a>
34 +
        {{end}}
35 +
      </div>
36 +
    </main>
37 +
    <script>
38 +
      document.querySelectorAll("time.note-date").forEach(el => {
39 +
        const d = new Date(el.getAttribute("datetime"));
40 +
        if (!isNaN(d)) { el.textContent = d.toLocaleString(); }
41 +
      });
42 +
    </script>
43 +
  </body>
44 +
</html>
apps/jotts-go/templates/login.html (added) +32 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta property="og:title" content="Jotts">
13 +
    <meta property="og:image" content="/static/og.png">
14 +
    <meta property="og:type" content="website">
15 +
    <meta name="theme-color" content="#121113" />
16 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
17 +
    <link rel="stylesheet" href="/static/styles.css">
18 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <span class="logo">JOTTS</span>
22 +
    </header>
23 +
    <main>
24 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
25 +
      <form method="POST" action="/login" class="form">
26 +
        <label for="password">password</label>
27 +
        <input type="password" id="password" name="password" autofocus required>
28 +
        <button type="submit">login</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/new.html (added) +32 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts — new</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta name="theme-color" content="#121113" />
13 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
14 +
    <link rel="stylesheet" href="/static/styles.css">
15 +
  </head>
16 +
  <body>
17 +
    <header class="header">
18 +
      <a href="/" class="logo">jotts</a>
19 +
      <nav class="links"><a href="/notes/new">new</a></nav>
20 +
    </header>
21 +
    <main>
22 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
      <form method="POST" action="/notes" class="form">
24 +
        <label for="title">title</label>
25 +
        <input type="text" id="title" name="title" autofocus required>
26 +
        <label for="content">content</label>
27 +
        <textarea id="content" name="content" placeholder="write markdown here..."></textarea>
28 +
        <button type="submit">save</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/view.html (added) +54 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts — {{.Note.Title}}</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta property="og:title" content="Jotts">
13 +
    <meta property="og:image" content="/static/og.png">
14 +
    <meta property="og:type" content="website">
15 +
    <meta name="theme-color" content="#121113" />
16 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
17 +
    <link rel="stylesheet" href="/static/styles.css">
18 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <a href="/" class="logo">jotts</a>
22 +
      <nav class="links"><a href="/notes/new">new</a></nav>
23 +
    </header>
24 +
    <main>
25 +
      <div class="note-header">
26 +
        <h1>{{.Note.Title}}</h1>
27 +
        <time class="note-date" datetime="{{.Note.UpdatedAt}}Z">{{.Note.UpdatedAt}}</time>
28 +
      </div>
29 +
      <div class="note-actions">
30 +
        <a href="/notes/{{.Note.ShortID}}/edit">edit</a>
31 +
        <button type="button" class="link-button" id="copy-md-btn" onclick="copyMarkdown()">copy</button>
32 +
        <form method="POST" action="/notes/{{.Note.ShortID}}/delete" class="inline-form">
33 +
          <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button>
34 +
        </form>
35 +
      </div>
36 +
      <template id="raw-md">{{.Note.Content}}</template>
37 +
      <article class="markdown-body">{{.Rendered}}</article>
38 +
    </main>
39 +
    <script>
40 +
      function copyMarkdown() {
41 +
        const md = document.getElementById("raw-md").content.textContent;
42 +
        const btn = document.getElementById("copy-md-btn");
43 +
        navigator.clipboard.writeText(md).then(() => {
44 +
          btn.textContent = "copied!";
45 +
          setTimeout(() => { btn.textContent = "copy"; }, 1500);
46 +
        });
47 +
      }
48 +
      document.querySelectorAll("time.note-date").forEach(el => {
49 +
        const d = new Date(el.getAttribute("datetime"));
50 +
        if (!isNaN(d)) { el.textContent = d.toLocaleString(); }
51 +
      });
52 +
    </script>
53 +
  </body>
54 +
</html>
apps/jotts-go/util.go (added) +62 −0
1 +
package main
2 +
3 +
import (
4 +
	"crypto/rand"
5 +
	"encoding/hex"
6 +
	"net/http"
7 +
	"net/url"
8 +
	"os"
9 +
	"strings"
10 +
)
11 +
12 +
const shortIDAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
13 +
14 +
func getenv(key, fallback string) string {
15 +
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
16 +
		return v
17 +
	}
18 +
	return fallback
19 +
}
20 +
21 +
func loadDotEnv(path string) {
22 +
	data, err := os.ReadFile(path)
23 +
	if err != nil {
24 +
		return
25 +
	}
26 +
	for _, line := range strings.Split(string(data), "\n") {
27 +
		line = strings.TrimSpace(line)
28 +
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
29 +
			continue
30 +
		}
31 +
		parts := strings.SplitN(line, "=", 2)
32 +
		key := strings.TrimSpace(parts[0])
33 +
		val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
34 +
		if os.Getenv(key) == "" {
35 +
			_ = os.Setenv(key, val)
36 +
		}
37 +
	}
38 +
}
39 +
40 +
func generateShortID(n int) (string, error) {
41 +
	buf := make([]byte, n)
42 +
	if _, err := rand.Read(buf); err != nil {
43 +
		return "", err
44 +
	}
45 +
	out := make([]byte, n)
46 +
	for i, b := range buf {
47 +
		out[i] = shortIDAlphabet[int(b)%len(shortIDAlphabet)]
48 +
	}
49 +
	return string(out), nil
50 +
}
51 +
52 +
func generateSessionToken() (string, error) {
53 +
	buf := make([]byte, 32)
54 +
	if _, err := rand.Read(buf); err != nil {
55 +
		return "", err
56 +
	}
57 +
	return hex.EncodeToString(buf), nil
58 +
}
59 +
60 +
func redirectWithError(w http.ResponseWriter, r *http.Request, target, msg string) {
61 +
	http.Redirect(w, r, target+"?error="+url.QueryEscape(msg), http.StatusSeeOther)
62 +
}
apps/jotts-go/web.go (added) +48 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/json"
5 +
	"mime"
6 +
	"net/http"
7 +
	"path/filepath"
8 +
	"strings"
9 +
)
10 +
11 +
func (a *App) embeddedHandler(prefix string) http.HandlerFunc {
12 +
	return func(w http.ResponseWriter, r *http.Request) {
13 +
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
14 +
		path := filepath.ToSlash(filepath.Join(prefix, name))
15 +
		data, err := appFS.ReadFile(path)
16 +
		if err != nil {
17 +
			http.NotFound(w, r)
18 +
			return
19 +
		}
20 +
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
21 +
			w.Header().Set("Content-Type", ct)
22 +
		}
23 +
		_, _ = w.Write(data)
24 +
	}
25 +
}
26 +
27 +
func (a *App) render(w http.ResponseWriter, name string, data any) {
28 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
29 +
	if err := a.Templates.ExecuteTemplate(w, name, data); err != nil {
30 +
		a.Log.Error("template render failed", "name", name, "err", err)
31 +
		http.Error(w, "template error", http.StatusInternalServerError)
32 +
	}
33 +
}
34 +
35 +
func writeJSON(w http.ResponseWriter, status int, data any) {
36 +
	w.Header().Set("Content-Type", "application/json")
37 +
	w.WriteHeader(status)
38 +
	_ = json.NewEncoder(w).Encode(data)
39 +
}
40 +
41 +
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
42 +
	defer r.Body.Close()
43 +
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
44 +
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"})
45 +
		return false
46 +
	}
47 +
	return true
48 +
}