chore: init jotts rewrite
b5c140bb
37 file(s) · +1892 −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= |
| 1 | + | .env |
|
| 2 | + | *.sqlite |
|
| 3 | + | *.sqlite-journal |
|
| 4 | + | *.sqlite-wal |
|
| 5 | + | *.sqlite-shm |
|
| 6 | + | jotts-go |
|
| 7 | + | /jotts-go |
| 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"] |
| 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 | + | ``` |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
| 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 | + | } |
| 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: |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 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"} |
| 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 | + | } |
| 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> |
| 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> |
| 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> |
| 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> |
| 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> |
| 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 | + | } |
| 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 | + | } |