chore: go app parity 3e57f631
Steve Simkins · 2026-05-16 19:34 24 file(s) · +379 −51
apps/jotts-go/README.md +2 −2
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
apps/jotts-go/go.mod +1 −0
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
apps/jotts-go/go.sum +3 −0
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=
apps/jotts-go/tui/browser.go (added) +7 −0
1 +
package tui
2 +
3 +
import "github.com/pkg/browser"
4 +
5 +
func openURL(url string) error {
6 +
	return browser.OpenURL(url)
7 +
}
apps/jotts-go/tui/editor.go +14 −23
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 -
}
apps/posts-go/.env.example +7 −0
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=
apps/posts-go/README.md +2 −3
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.
apps/posts-go/app.go +9 −7
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 {
apps/posts-go/db.go +6 −3
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
	}
apps/posts-go/go.mod +12 −0
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
apps/posts-go/go.sum +24 −0
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=
apps/posts-go/handlers_admin.go +12 −7
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
apps/posts-go/handlers_public.go +12 −1
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)
apps/posts-go/main.go +19 −0
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
apps/posts-go/storage/interface.go (added) +10 −0
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 +
}
apps/posts-go/storage/local.go (added) +41 −0
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" }
apps/posts-go/storage/r2.go (added) +59 −0
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" }
apps/shrink-go/README.md +2 −2
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.
apps/shrink-go/exif.go (added) +100 −0
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 +
}
apps/shrink-go/image.go +2 −1
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 {
apps/sipp-go/tui/keys.go +3 −1
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
}
apps/sipp-go/tui/model.go +1 −0
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
apps/sipp-go/tui/update.go +25 −1
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()
apps/sipp-go/tui/view.go +6 −0
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 {