chore: go app parity
3e57f631
24 file(s) · +379 −51
| 7 | 7 | - Go stdlib `net/http` + `html/template` |
|
| 8 | 8 | - `modernc.org/sqlite` (pure-Go SQLite, no CGO) |
|
| 9 | 9 | - `github.com/yuin/goldmark` (markdown rendering w/ strikethrough, tables, tasklists) |
|
| 10 | - | ||
| 11 | - | No other dependencies. |
|
| 10 | + | - Bubble Tea/Lip Gloss/Glamour for the TUI editor |
|
| 11 | + | - `github.com/pkg/browser` and `github.com/atotto/clipboard` for TUI browser/copy actions |
|
| 12 | 12 | ||
| 13 | 13 | ## Quickstart |
|
| 14 | 14 |
| 53 | 53 | github.com/muesli/reflow v0.3.0 // indirect |
|
| 54 | 54 | github.com/muesli/termenv v0.16.0 // indirect |
|
| 55 | 55 | github.com/ncruces/go-strftime v0.1.9 // indirect |
|
| 56 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect |
|
| 56 | 57 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|
| 57 | 58 | github.com/rivo/uniseg v0.4.7 // indirect |
|
| 58 | 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect |
| 77 | 77 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= |
|
| 78 | 78 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|
| 79 | 79 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|
| 80 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= |
|
| 81 | + | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= |
|
| 80 | 82 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|
| 81 | 83 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 82 | 84 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= |
|
| 100 | 102 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= |
|
| 101 | 103 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= |
|
| 102 | 104 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 105 | + | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 103 | 106 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 104 | 107 | golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= |
|
| 105 | 108 | golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
|
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import "github.com/pkg/browser" |
|
| 4 | + | ||
| 5 | + | func openURL(url string) error { |
|
| 6 | + | return browser.OpenURL(url) |
|
| 7 | + | } |
| 1 | 1 | package tui |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | - | "fmt" |
|
| 5 | 4 | "os" |
|
| 6 | 5 | "os/exec" |
|
| 7 | - | "path/filepath" |
|
| 8 | - | "runtime" |
|
| 9 | 6 | ||
| 10 | 7 | tea "github.com/charmbracelet/bubbletea" |
|
| 11 | 8 | ) |
|
| 18 | 15 | } |
|
| 19 | 16 | } |
|
| 20 | 17 | ||
| 21 | - | tmp := filepath.Join(os.TempDir(), fmt.Sprintf("jotts-%s.md", shortID)) |
|
| 22 | - | if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil { |
|
| 18 | + | tmp, err := os.CreateTemp("", "jotts-*.md") |
|
| 19 | + | if err != nil { |
|
| 20 | + | return func() tea.Msg { |
|
| 21 | + | return statusMsg{text: "tempfile: " + err.Error(), ok: false} |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | path := tmp.Name() |
|
| 25 | + | if _, err := tmp.WriteString(content); err != nil { |
|
| 26 | + | _ = tmp.Close() |
|
| 27 | + | _ = os.Remove(path) |
|
| 23 | 28 | return func() tea.Msg { |
|
| 24 | 29 | return statusMsg{text: "tempfile: " + err.Error(), ok: false} |
|
| 25 | 30 | } |
|
| 26 | 31 | } |
|
| 32 | + | _ = tmp.Close() |
|
| 27 | 33 | ||
| 28 | - | cmd := exec.Command(editor, tmp) |
|
| 34 | + | cmd := exec.Command(editor, path) |
|
| 29 | 35 | return tea.ExecProcess(cmd, func(err error) tea.Msg { |
|
| 30 | - | defer os.Remove(tmp) |
|
| 36 | + | defer os.Remove(path) |
|
| 31 | 37 | if err != nil { |
|
| 32 | 38 | return editorFinishedMsg{shortID: shortID, err: err} |
|
| 33 | 39 | } |
|
| 34 | - | b, rerr := os.ReadFile(tmp) |
|
| 40 | + | b, rerr := os.ReadFile(path) |
|
| 35 | 41 | if rerr != nil { |
|
| 36 | 42 | return editorFinishedMsg{shortID: shortID, err: rerr} |
|
| 37 | 43 | } |
|
| 38 | 44 | return editorFinishedMsg{shortID: shortID, content: string(b)} |
|
| 39 | 45 | }) |
|
| 40 | 46 | } |
|
| 41 | - | ||
| 42 | - | func openURL(url string) error { |
|
| 43 | - | var cmd *exec.Cmd |
|
| 44 | - | switch runtime.GOOS { |
|
| 45 | - | case "linux": |
|
| 46 | - | cmd = exec.Command("xdg-open", url) |
|
| 47 | - | case "darwin": |
|
| 48 | - | cmd = exec.Command("open", url) |
|
| 49 | - | case "windows": |
|
| 50 | - | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) |
|
| 51 | - | default: |
|
| 52 | - | return fmt.Errorf("unsupported platform %s", runtime.GOOS) |
|
| 53 | - | } |
|
| 54 | - | return cmd.Start() |
|
| 55 | - | } |
|
| 5 | 5 | HOST=127.0.0.1 |
|
| 6 | 6 | PORT=3000 |
|
| 7 | 7 | SITE_URL=http://localhost:3000 |
|
| 8 | + | ||
| 9 | + | # Optional Cloudflare R2 storage for uploads. Leave R2_BUCKET empty for local files. |
|
| 10 | + | R2_ACCOUNT_ID= |
|
| 11 | + | R2_ACCESS_KEY_ID= |
|
| 12 | + | R2_SECRET_ACCESS_KEY= |
|
| 13 | + | R2_BUCKET= |
|
| 14 | + | R2_PUBLIC_URL= |
| 5 | 5 | ||
| 6 | 6 | ## Notes vs Rust version |
|
| 7 | 7 | ||
| 8 | - | - **R2/S3 storage dropped.** Local filesystem only (`UPLOADS_DIR`, default |
|
| 9 | - | `uploads`). The `files.storage_backend` column stays for schema parity but |
|
| 10 | - | is always `"local"`. |
|
| 8 | + | - Upload storage supports local filesystem (`UPLOADS_DIR`, default `uploads`) |
|
| 9 | + | or Cloudflare R2 when `R2_BUCKET` and credentials are set. |
|
| 11 | 10 | - Markdown: `github.com/yuin/goldmark` with GFM + Footnotes (replaces |
|
| 12 | 11 | pulldown-cmark). |
|
| 13 | 12 | - Zip via stdlib `archive/zip`. Upload limit 10 MB; import zip limit 50 MB. |
| 7 | 7 | "log/slog" |
|
| 8 | 8 | "strings" |
|
| 9 | 9 | ||
| 10 | + | poststorage "github.com/stevedylandev/andromeda/apps/posts-go/storage" |
|
| 10 | 11 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 11 | 12 | ) |
|
| 12 | 13 | ||
| 21 | 22 | AppPassword string |
|
| 22 | 23 | CookieSecure bool |
|
| 23 | 24 | UploadsDir string |
|
| 25 | + | Storage poststorage.Backend |
|
| 24 | 26 | SiteURL string |
|
| 25 | 27 | } |
|
| 26 | 28 | ||
| 213 | 215 | } |
|
| 214 | 216 | ||
| 215 | 217 | type siteContext struct { |
|
| 216 | - | BlogTitle string |
|
| 217 | - | NavLinks []NavLink |
|
| 218 | - | FaviconURL string |
|
| 219 | - | OGImageURL string |
|
| 220 | - | SiteURL string |
|
| 221 | - | HeaderHTML template.HTML |
|
| 222 | - | FooterHTML template.HTML |
|
| 218 | + | BlogTitle string |
|
| 219 | + | NavLinks []NavLink |
|
| 220 | + | FaviconURL string |
|
| 221 | + | OGImageURL string |
|
| 222 | + | SiteURL string |
|
| 223 | + | HeaderHTML template.HTML |
|
| 224 | + | FooterHTML template.HTML |
|
| 223 | 225 | } |
|
| 224 | 226 | ||
| 225 | 227 | type loginPageData struct { |
|
| 386 | 386 | return &f, nil |
|
| 387 | 387 | } |
|
| 388 | 388 | ||
| 389 | - | func createFile(db *sql.DB, filename, originalName, contentType string, size int64) (*UploadedFile, error) { |
|
| 389 | + | func createFile(db *sql.DB, filename, originalName, contentType string, size int64, backend string) (*UploadedFile, error) { |
|
| 390 | 390 | shortID, err := auth.GenerateShortID(10) |
|
| 391 | 391 | if err != nil { |
|
| 392 | 392 | return nil, err |
|
| 393 | + | } |
|
| 394 | + | if backend == "" { |
|
| 395 | + | backend = "local" |
|
| 393 | 396 | } |
|
| 394 | 397 | res, err := db.Exec( |
|
| 395 | - | `INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?, ?, ?, ?, ?, 'local')`, |
|
| 396 | - | shortID, filename, originalName, contentType, size) |
|
| 398 | + | `INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?, ?, ?, ?, ?, ?)`, |
|
| 399 | + | shortID, filename, originalName, contentType, size, backend) |
|
| 397 | 400 | if err != nil { |
|
| 398 | 401 | return nil, err |
|
| 399 | 402 | } |
| 12 | 12 | ) |
|
| 13 | 13 | ||
| 14 | 14 | require ( |
|
| 15 | + | github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect |
|
| 16 | + | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect |
|
| 17 | + | github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect |
|
| 18 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect |
|
| 19 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect |
|
| 20 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect |
|
| 21 | + | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect |
|
| 22 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect |
|
| 23 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect |
|
| 24 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect |
|
| 25 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect |
|
| 26 | + | github.com/aws/smithy-go v1.25.1 // indirect |
|
| 15 | 27 | github.com/dustin/go-humanize v1.0.1 // indirect |
|
| 16 | 28 | github.com/google/uuid v1.6.0 // indirect |
|
| 17 | 29 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 1 | + | github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= |
|
| 2 | + | github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= |
|
| 3 | + | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= |
|
| 4 | + | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= |
|
| 5 | + | github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= |
|
| 6 | + | github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= |
|
| 7 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= |
|
| 8 | + | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= |
|
| 9 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= |
|
| 10 | + | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= |
|
| 11 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= |
|
| 12 | + | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= |
|
| 13 | + | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= |
|
| 14 | + | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= |
|
| 15 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= |
|
| 16 | + | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= |
|
| 17 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= |
|
| 18 | + | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= |
|
| 19 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= |
|
| 20 | + | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= |
|
| 21 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= |
|
| 22 | + | github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= |
|
| 23 | + | github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= |
|
| 24 | + | github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
|
| 1 | 25 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|
| 2 | 26 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|
| 3 | 27 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| 336 | 336 | if ext != "" { |
|
| 337 | 337 | stored = id + "." + ext |
|
| 338 | 338 | } |
|
| 339 | - | if err := ensureDir(a.UploadsDir); err != nil { |
|
| 340 | - | http.Redirect(w, r, "/admin/files?error=Failed+to+save+file", http.StatusSeeOther) |
|
| 339 | + | backend := a.Storage |
|
| 340 | + | if backend == nil { |
|
| 341 | + | http.Redirect(w, r, "/admin/files?error=Storage+not+configured", http.StatusSeeOther) |
|
| 341 | 342 | return |
|
| 342 | 343 | } |
|
| 343 | - | if err := writeFile(joinPath(a.UploadsDir, stored), data); err != nil { |
|
| 344 | - | a.Log.Error("write file", "err", err) |
|
| 344 | + | if err := backend.Put(r.Context(), stored, contentType, data); err != nil { |
|
| 345 | + | a.Log.Error("save file", "backend", backend.Name(), "err", err) |
|
| 345 | 346 | http.Redirect(w, r, "/admin/files?error=Failed+to+save+file", http.StatusSeeOther) |
|
| 346 | 347 | return |
|
| 347 | 348 | } |
|
| 348 | - | if _, err := createFile(a.DB, stored, originalName, contentType, int64(len(data))); err != nil { |
|
| 349 | + | if _, err := createFile(a.DB, stored, originalName, contentType, int64(len(data)), backend.Name()); err != nil { |
|
| 349 | 350 | a.Log.Error("record file", "err", err) |
|
| 350 | - | _ = removeFile(joinPath(a.UploadsDir, stored)) |
|
| 351 | + | _ = backend.Delete(r.Context(), stored) |
|
| 351 | 352 | http.Redirect(w, r, "/admin/files?error=Failed+to+record+file", http.StatusSeeOther) |
|
| 352 | 353 | return |
|
| 353 | 354 | } |
|
| 360 | 361 | http.Redirect(w, r, "/admin/files", http.StatusSeeOther) |
|
| 361 | 362 | return |
|
| 362 | 363 | } |
|
| 363 | - | _ = removeFile(joinPath(a.UploadsDir, file.Filename)) |
|
| 364 | + | if file.StorageBackend == "r2" && a.Storage != nil && a.Storage.Name() == "r2" { |
|
| 365 | + | _ = a.Storage.Delete(r.Context(), file.Filename) |
|
| 366 | + | } else { |
|
| 367 | + | _ = removeFile(joinPath(a.UploadsDir, file.Filename)) |
|
| 368 | + | } |
|
| 364 | 369 | http.Redirect(w, r, "/admin/files", http.StatusSeeOther) |
|
| 365 | 370 | } |
|
| 366 | 371 | ||
| 159 | 159 | http.NotFound(w, r) |
|
| 160 | 160 | return |
|
| 161 | 161 | } |
|
| 162 | + | f, _ := getFileByFilename(a.DB, filename) |
|
| 163 | + | if f != nil && f.StorageBackend == "r2" { |
|
| 164 | + | if a.Storage != nil && a.Storage.Name() == "r2" { |
|
| 165 | + | if u := a.Storage.PublicURL(filename); u != "" { |
|
| 166 | + | http.Redirect(w, r, u, http.StatusTemporaryRedirect) |
|
| 167 | + | return |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | http.NotFound(w, r) |
|
| 171 | + | return |
|
| 172 | + | } |
|
| 162 | 173 | path := a.UploadsDir + "/" + filename |
|
| 163 | 174 | data, err := readFile(path) |
|
| 164 | 175 | if err != nil { |
|
| 166 | 177 | return |
|
| 167 | 178 | } |
|
| 168 | 179 | ct := mimeFromPath(filename) |
|
| 169 | - | if f, _ := getFileByFilename(a.DB, filename); f != nil && f.ContentType != "" { |
|
| 180 | + | if f != nil && f.ContentType != "" { |
|
| 170 | 181 | ct = f.ContentType |
|
| 171 | 182 | } |
|
| 172 | 183 | w.Header().Set("Content-Type", ct) |
|
| 8 | 8 | "os" |
|
| 9 | 9 | "strings" |
|
| 10 | 10 | ||
| 11 | + | poststorage "github.com/stevedylandev/andromeda/apps/posts-go/storage" |
|
| 11 | 12 | "github.com/stevedylandev/andromeda/crates-go/auth" |
|
| 12 | 13 | "github.com/stevedylandev/andromeda/crates-go/config" |
|
| 13 | 14 | "github.com/stevedylandev/andromeda/crates-go/sqlite" |
|
| 29 | 30 | if err := ensureDir(uploadsDir); err != nil { |
|
| 30 | 31 | log.Fatalf("create uploads dir: %v", err) |
|
| 31 | 32 | } |
|
| 33 | + | storageBackend := poststorage.Backend(poststorage.NewLocal(uploadsDir)) |
|
| 34 | + | if os.Getenv("R2_BUCKET") != "" { |
|
| 35 | + | r2, err := poststorage.NewR2( |
|
| 36 | + | os.Getenv("R2_ACCOUNT_ID"), |
|
| 37 | + | os.Getenv("R2_ACCESS_KEY_ID"), |
|
| 38 | + | os.Getenv("R2_SECRET_ACCESS_KEY"), |
|
| 39 | + | os.Getenv("R2_BUCKET"), |
|
| 40 | + | os.Getenv("R2_PUBLIC_URL"), |
|
| 41 | + | ) |
|
| 42 | + | if err != nil { |
|
| 43 | + | log.Fatalf("configure r2: %v", err) |
|
| 44 | + | } |
|
| 45 | + | storageBackend = r2 |
|
| 46 | + | logger.Info("using R2 storage backend", "bucket", os.Getenv("R2_BUCKET")) |
|
| 47 | + | } else { |
|
| 48 | + | logger.Info("using local storage backend", "dir", uploadsDir) |
|
| 49 | + | } |
|
| 32 | 50 | ||
| 33 | 51 | password := os.Getenv("POSTS_PASSWORD") |
|
| 34 | 52 | if password == "" { |
|
| 51 | 69 | AppPassword: password, |
|
| 52 | 70 | CookieSecure: sessions.CookieSecure, |
|
| 53 | 71 | UploadsDir: uploadsDir, |
|
| 72 | + | Storage: storageBackend, |
|
| 54 | 73 | SiteURL: strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"), |
|
| 55 | 74 | } |
|
| 56 | 75 | ||
| 1 | + | package storage |
|
| 2 | + | ||
| 3 | + | import "context" |
|
| 4 | + | ||
| 5 | + | type Backend interface { |
|
| 6 | + | Put(ctx context.Context, key, contentType string, data []byte) error |
|
| 7 | + | Delete(ctx context.Context, key string) error |
|
| 8 | + | PublicURL(key string) string |
|
| 9 | + | Name() string |
|
| 10 | + | } |
| 1 | + | package storage |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "context" |
|
| 5 | + | "os" |
|
| 6 | + | "path/filepath" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | type Local struct { |
|
| 10 | + | Dir string |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | func NewLocal(dir string) *Local { return &Local{Dir: dir} } |
|
| 14 | + | ||
| 15 | + | func (l *Local) Put(ctx context.Context, key, contentType string, data []byte) error { |
|
| 16 | + | select { |
|
| 17 | + | case <-ctx.Done(): |
|
| 18 | + | return ctx.Err() |
|
| 19 | + | default: |
|
| 20 | + | } |
|
| 21 | + | if err := os.MkdirAll(l.Dir, 0o755); err != nil { |
|
| 22 | + | return err |
|
| 23 | + | } |
|
| 24 | + | return os.WriteFile(filepath.Join(l.Dir, key), data, 0o644) |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | func (l *Local) Delete(ctx context.Context, key string) error { |
|
| 28 | + | select { |
|
| 29 | + | case <-ctx.Done(): |
|
| 30 | + | return ctx.Err() |
|
| 31 | + | default: |
|
| 32 | + | } |
|
| 33 | + | err := os.Remove(filepath.Join(l.Dir, key)) |
|
| 34 | + | if os.IsNotExist(err) { |
|
| 35 | + | return nil |
|
| 36 | + | } |
|
| 37 | + | return err |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | func (l *Local) PublicURL(key string) string { return "/uploads/" + key } |
|
| 41 | + | func (l *Local) Name() string { return "local" } |
| 1 | + | package storage |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "bytes" |
|
| 5 | + | "context" |
|
| 6 | + | "fmt" |
|
| 7 | + | "net/url" |
|
| 8 | + | "strings" |
|
| 9 | + | ||
| 10 | + | "github.com/aws/aws-sdk-go-v2/aws" |
|
| 11 | + | "github.com/aws/aws-sdk-go-v2/credentials" |
|
| 12 | + | "github.com/aws/aws-sdk-go-v2/service/s3" |
|
| 13 | + | ) |
|
| 14 | + | ||
| 15 | + | type R2 struct { |
|
| 16 | + | bucket string |
|
| 17 | + | publicURL string |
|
| 18 | + | client *s3.Client |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | func NewR2(accountID, accessKeyID, secretAccessKey, bucket, publicURL string) (*R2, error) { |
|
| 22 | + | if strings.TrimSpace(accountID) == "" || strings.TrimSpace(accessKeyID) == "" || strings.TrimSpace(secretAccessKey) == "" || strings.TrimSpace(bucket) == "" || strings.TrimSpace(publicURL) == "" { |
|
| 23 | + | return nil, fmt.Errorf("R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, and R2_PUBLIC_URL are required") |
|
| 24 | + | } |
|
| 25 | + | endpoint := "https://" + accountID + ".r2.cloudflarestorage.com" |
|
| 26 | + | cfg := aws.Config{ |
|
| 27 | + | Region: "auto", |
|
| 28 | + | Credentials: credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""), |
|
| 29 | + | } |
|
| 30 | + | client := s3.NewFromConfig(cfg, func(o *s3.Options) { |
|
| 31 | + | o.BaseEndpoint = aws.String(endpoint) |
|
| 32 | + | o.UsePathStyle = true |
|
| 33 | + | }) |
|
| 34 | + | return &R2{bucket: bucket, publicURL: strings.TrimRight(publicURL, "/"), client: client}, nil |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | func (r *R2) Put(ctx context.Context, key, contentType string, data []byte) error { |
|
| 38 | + | _, err := r.client.PutObject(ctx, &s3.PutObjectInput{ |
|
| 39 | + | Bucket: aws.String(r.bucket), |
|
| 40 | + | Key: aws.String(key), |
|
| 41 | + | Body: bytes.NewReader(data), |
|
| 42 | + | ContentType: aws.String(contentType), |
|
| 43 | + | }) |
|
| 44 | + | return err |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | func (r *R2) Delete(ctx context.Context, key string) error { |
|
| 48 | + | _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(r.bucket), Key: aws.String(key)}) |
|
| 49 | + | return err |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | func (r *R2) PublicURL(key string) string { |
|
| 53 | + | if r.publicURL == "" { |
|
| 54 | + | return "" |
|
| 55 | + | } |
|
| 56 | + | return r.publicURL + "/" + url.PathEscape(key) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | func (r *R2) Name() string { return "r2" } |
| 26 | 26 | ||
| 27 | 27 | ## Notes vs Rust version |
|
| 28 | 28 | ||
| 29 | - | EXIF reinjection is dropped; standard JPEG encoder is used. Output is a fresh |
|
| 30 | - | JPEG without metadata. The 20 MB upload limit is preserved. |
|
| 29 | + | JPEG EXIF metadata is preserved after recompression, with GPS data stripped to |
|
| 30 | + | match the Rust implementation. The 20 MB upload limit is preserved. |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import "encoding/binary" |
|
| 4 | + | ||
| 5 | + | const exifPrefix = "Exif\x00\x00" |
|
| 6 | + | ||
| 7 | + | func extractExif(orig []byte) []byte { |
|
| 8 | + | if len(orig) < 4 || orig[0] != 0xff || orig[1] != 0xd8 { |
|
| 9 | + | return nil |
|
| 10 | + | } |
|
| 11 | + | pos := 2 |
|
| 12 | + | for pos+4 <= len(orig) { |
|
| 13 | + | if orig[pos] != 0xff { |
|
| 14 | + | return nil |
|
| 15 | + | } |
|
| 16 | + | marker := orig[pos+1] |
|
| 17 | + | pos += 2 |
|
| 18 | + | if marker == 0xda || marker == 0xd9 { // SOS or EOI |
|
| 19 | + | return nil |
|
| 20 | + | } |
|
| 21 | + | if marker >= 0xd0 && marker <= 0xd7 { // restart markers have no length |
|
| 22 | + | continue |
|
| 23 | + | } |
|
| 24 | + | if pos+2 > len(orig) { |
|
| 25 | + | return nil |
|
| 26 | + | } |
|
| 27 | + | segLen := int(binary.BigEndian.Uint16(orig[pos : pos+2])) |
|
| 28 | + | if segLen < 2 || pos+segLen > len(orig) { |
|
| 29 | + | return nil |
|
| 30 | + | } |
|
| 31 | + | payload := orig[pos+2 : pos+segLen] |
|
| 32 | + | if marker == 0xe1 && len(payload) >= len(exifPrefix) && string(payload[:len(exifPrefix)]) == exifPrefix { |
|
| 33 | + | exif := make([]byte, len(payload)-len(exifPrefix)) |
|
| 34 | + | copy(exif, payload[len(exifPrefix):]) |
|
| 35 | + | return exif |
|
| 36 | + | } |
|
| 37 | + | pos += segLen |
|
| 38 | + | } |
|
| 39 | + | return nil |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | func stripGPS(exif []byte) []byte { |
|
| 43 | + | if len(exif) < 8 { |
|
| 44 | + | return exif |
|
| 45 | + | } |
|
| 46 | + | out := make([]byte, len(exif)) |
|
| 47 | + | copy(out, exif) |
|
| 48 | + | ||
| 49 | + | var order binary.ByteOrder |
|
| 50 | + | switch string(out[:2]) { |
|
| 51 | + | case "II": |
|
| 52 | + | order = binary.LittleEndian |
|
| 53 | + | case "MM": |
|
| 54 | + | order = binary.BigEndian |
|
| 55 | + | default: |
|
| 56 | + | return out |
|
| 57 | + | } |
|
| 58 | + | if order.Uint16(out[2:4]) != 42 { |
|
| 59 | + | return out |
|
| 60 | + | } |
|
| 61 | + | ifd0 := int(order.Uint32(out[4:8])) |
|
| 62 | + | if ifd0 < 0 || ifd0+2 > len(out) { |
|
| 63 | + | return out |
|
| 64 | + | } |
|
| 65 | + | count := int(order.Uint16(out[ifd0 : ifd0+2])) |
|
| 66 | + | entries := ifd0 + 2 |
|
| 67 | + | for i := 0; i < count; i++ { |
|
| 68 | + | entry := entries + i*12 |
|
| 69 | + | if entry+12 > len(out) { |
|
| 70 | + | return out |
|
| 71 | + | } |
|
| 72 | + | tag := order.Uint16(out[entry : entry+2]) |
|
| 73 | + | if tag == 0x8825 { // GPSInfo IFDPointer |
|
| 74 | + | gpsOffset := int(order.Uint32(out[entry+8 : entry+12])) |
|
| 75 | + | if gpsOffset >= 0 && gpsOffset+2 <= len(out) { |
|
| 76 | + | order.PutUint16(out[gpsOffset:gpsOffset+2], 0) |
|
| 77 | + | } |
|
| 78 | + | return out |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | return out |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | func injectExif(jpegBytes, exif []byte) []byte { |
|
| 85 | + | if len(exif) == 0 || len(jpegBytes) < 2 || jpegBytes[0] != 0xff || jpegBytes[1] != 0xd8 { |
|
| 86 | + | return jpegBytes |
|
| 87 | + | } |
|
| 88 | + | payloadLen := len(exifPrefix) + len(exif) |
|
| 89 | + | segLen := payloadLen + 2 |
|
| 90 | + | if segLen > 0xffff { |
|
| 91 | + | return jpegBytes |
|
| 92 | + | } |
|
| 93 | + | out := make([]byte, 0, len(jpegBytes)+segLen+2) |
|
| 94 | + | out = append(out, jpegBytes[:2]...) |
|
| 95 | + | out = append(out, 0xff, 0xe1, byte(segLen>>8), byte(segLen)) |
|
| 96 | + | out = append(out, exifPrefix...) |
|
| 97 | + | out = append(out, exif...) |
|
| 98 | + | out = append(out, jpegBytes[2:]...) |
|
| 99 | + | return out |
|
| 100 | + | } |
| 13 | 13 | ) |
|
| 14 | 14 | ||
| 15 | 15 | func compressImage(data []byte, quality int, width int) ([]byte, error) { |
|
| 16 | + | exif := stripGPS(extractExif(data)) |
|
| 16 | 17 | img, _, err := image.Decode(bytes.NewReader(data)) |
|
| 17 | 18 | if err != nil { |
|
| 18 | 19 | return nil, fmt.Errorf("Failed to decode image: %w", err) |
|
| 36 | 37 | if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: quality}); err != nil { |
|
| 37 | 38 | return nil, fmt.Errorf("JPEG encoding failed: %w", err) |
|
| 38 | 39 | } |
|
| 39 | - | return out.Bytes(), nil |
|
| 40 | + | return injectExif(out.Bytes(), exif), nil |
|
| 40 | 41 | } |
|
| 41 | 42 | ||
| 42 | 43 | func buildDownloadFilename(original, newExt string) string { |
|
| 17 | 17 | OpenBrowser key.Binding |
|
| 18 | 18 | Search key.Binding |
|
| 19 | 19 | Refresh key.Binding |
|
| 20 | + | WrapToggle key.Binding |
|
| 20 | 21 | Help key.Binding |
|
| 21 | 22 | Save key.Binding |
|
| 22 | 23 | SwitchField key.Binding |
|
| 39 | 40 | OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), |
|
| 40 | 41 | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), |
|
| 41 | 42 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), |
|
| 43 | + | WrapToggle: key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")), |
|
| 42 | 44 | Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), |
|
| 43 | 45 | Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")), |
|
| 44 | 46 | SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")), |
|
| 55 | 57 | {k.Up, k.Down, k.Open, k.Back}, |
|
| 56 | 58 | {k.Create, k.Edit, k.ExtEdit, k.Delete}, |
|
| 57 | 59 | {k.Copy, k.CopyLink, k.OpenBrowser, k.Search}, |
|
| 58 | - | {k.Refresh, k.Help, k.Save, k.SwitchField}, |
|
| 60 | + | {k.Refresh, k.WrapToggle, k.Help, k.Save, k.SwitchField}, |
|
| 59 | 61 | {k.Cancel, k.Quit}, |
|
| 60 | 62 | } |
|
| 61 | 63 | } |
|
| 34 | 34 | focus Focus |
|
| 35 | 35 | showHelp bool |
|
| 36 | 36 | confirmDelete bool |
|
| 37 | + | wrapContent bool |
|
| 37 | 38 | ||
| 38 | 39 | nameInput textinput.Model |
|
| 39 | 40 | contentArea textarea.Model |
| 6 | 6 | "github.com/atotto/clipboard" |
|
| 7 | 7 | "github.com/charmbracelet/bubbles/key" |
|
| 8 | 8 | tea "github.com/charmbracelet/bubbletea" |
|
| 9 | + | "github.com/charmbracelet/lipgloss" |
|
| 9 | 10 | ) |
|
| 10 | 11 | ||
| 11 | 12 | func loadSnippetsCmd(b Backend) tea.Cmd { |
|
| 133 | 134 | m.contentVP.Height = bodyH - 2 |
|
| 134 | 135 | ||
| 135 | 136 | m.nameInput.Width = contentW - 4 |
|
| 136 | - | m.contentArea.SetWidth(contentW - 2) |
|
| 137 | + | if m.wrapContent { |
|
| 138 | + | m.contentArea.SetWidth(contentW - 2) |
|
| 139 | + | } else { |
|
| 140 | + | m.contentArea.SetWidth(10000) |
|
| 141 | + | } |
|
| 137 | 142 | m.contentArea.SetHeight(bodyH - 5) |
|
| 138 | 143 | ||
| 139 | 144 | m.searchInput.Width = listW - 4 |
|
| 151 | 156 | if m.highlighter != nil { |
|
| 152 | 157 | body = m.highlighter.render(s.ShortID, s.Name, s.Content) |
|
| 153 | 158 | } |
|
| 159 | + | if m.wrapContent && m.contentVP.Width > 0 { |
|
| 160 | + | body = lipgloss.NewStyle().Width(m.contentVP.Width).Render(body) |
|
| 161 | + | } |
|
| 154 | 162 | m.contentVP.SetContent(body) |
|
| 155 | 163 | } |
|
| 156 | 164 | ||
| 282 | 290 | ||
| 283 | 291 | func (m Model) keyContent(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
|
| 284 | 292 | switch { |
|
| 293 | + | case key.Matches(msg, m.keys.WrapToggle): |
|
| 294 | + | m.wrapContent = !m.wrapContent |
|
| 295 | + | m.contentVP.GotoTop() |
|
| 296 | + | m.refreshPreview() |
|
| 297 | + | if m.wrapContent { |
|
| 298 | + | return m, m.setStatus("wrap on", true) |
|
| 299 | + | } |
|
| 300 | + | return m, m.setStatus("wrap off", true) |
|
| 285 | 301 | case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back): |
|
| 286 | 302 | m.focus = FocusList |
|
| 287 | 303 | return m, nil |
|
| 330 | 346 | ||
| 331 | 347 | func (m Model) keyForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { |
|
| 332 | 348 | switch { |
|
| 349 | + | case key.Matches(msg, m.keys.WrapToggle): |
|
| 350 | + | m.wrapContent = !m.wrapContent |
|
| 351 | + | if m.wrapContent { |
|
| 352 | + | m.contentArea.SetWidth(m.contentVP.Width) |
|
| 353 | + | return m, m.setStatus("wrap on", true) |
|
| 354 | + | } |
|
| 355 | + | m.contentArea.SetWidth(10000) |
|
| 356 | + | return m, m.setStatus("wrap off", true) |
|
| 333 | 357 | case key.Matches(msg, m.keys.Cancel): |
|
| 334 | 358 | m.focus = FocusList |
|
| 335 | 359 | m.nameInput.Blur() |
|
| 134 | 134 | style = borderActive |
|
| 135 | 135 | } |
|
| 136 | 136 | header := "preview" |
|
| 137 | + | if m.wrapContent { |
|
| 138 | + | header += " (wrap)" |
|
| 139 | + | } |
|
| 137 | 140 | s := m.current() |
|
| 138 | 141 | if s != nil { |
|
| 139 | 142 | header = s.Name |
|
| 150 | 153 | header := "new snippet" |
|
| 151 | 154 | if m.editShortID != "" { |
|
| 152 | 155 | header = "edit" |
|
| 156 | + | } |
|
| 157 | + | if m.wrapContent { |
|
| 158 | + | header += " (wrap)" |
|
| 153 | 159 | } |
|
| 154 | 160 | name := m.nameInput.View() |
|
| 155 | 161 | if m.focus == FocusCreateName || m.focus == FocusEditName { |
|