Merge pull request #51 from stevedylandev/feat/init-blobs-app 14d9b0e5
Feat/init blobs app
Steve Simkins · 2026-05-24 19:36 27 file(s) · +1740 −4
.github/workflows/docker-test.yml +2 −2
19 19
      - name: Determine which apps to build
20 20
        id: filter
21 21
        run: |
22 -
          ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
22 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
23 23
24 24
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 25
29 29
          fi
30 30
31 31
          apps=()
32 -
          for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp; do
32 +
          for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do
33 33
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 34
              apps+=("\"${app}\"")
35 35
            fi
.github/workflows/docker.yml +2 −2
25 25
      - name: Determine which apps to build
26 26
        id: filter
27 27
        run: |
28 -
          ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
28 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]'
29 29
30 30
          # Tags: per-app (app/version) or bare (version)
31 31
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
52 52
          fi
53 53
54 54
          apps=()
55 -
          for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp; do
55 +
          for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do
56 56
            if echo "$changed" | grep -q "^apps/${app}/"; then
57 57
              apps+=("\"${app}\"")
58 58
            fi
.gitignore +1 −0
18 18
*.out
19 19
coverage.*
20 20
vendor/
21 +
apps/blobs/blobs
21 22
apps/bookmarks/bookmarks
22 23
apps/cellar/cellar
23 24
apps/easel/easel
apps/blobs/.env.example (added) +26 −0
1 +
BLOBS_PASSWORD=changeme
2 +
BLOBS_DB_PATH=blobs.sqlite
3 +
BLOBS_HOST=127.0.0.1
4 +
BLOBS_PORT=3000
5 +
BLOBS_COOKIE_SECURE=false
6 +
BLOBS_MAX_UPLOAD_MB=100
7 +
BLOBS_PRESIGN_TTL_SECONDS=3600
8 +
9 +
# Pick one credential style:
10 +
#
11 +
# Option A — generic S3 endpoint (works with AWS, Minio, B2, R2, etc).
12 +
S3_ENDPOINT=
13 +
S3_REGION=auto
14 +
S3_ACCESS_KEY_ID=
15 +
S3_SECRET_ACCESS_KEY=
16 +
#
17 +
# Option B — Cloudflare R2 shortcut. Endpoint derived from account ID.
18 +
# S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY are still used; R2_ACCESS_KEY_ID /
19 +
# R2_SECRET_ACCESS_KEY are accepted as fallbacks for convenience.
20 +
R2_ACCOUNT_ID=
21 +
R2_ACCESS_KEY_ID=
22 +
R2_SECRET_ACCESS_KEY=
23 +
24 +
# Optional per-bucket public URL prefixes. Comma-separated bucket=url pairs.
25 +
# Example: BLOBS_PUBLIC_URLS=my-bucket=https://cdn.example.com,other=https://pub-xyz.r2.dev
26 +
BLOBS_PUBLIC_URLS=
apps/blobs/.goreleaser.yml (added) +80 −0
1 +
# GoReleaser config for blobs.
2 +
# Triggered by tags shaped like `blobs/vX.Y.Z`.
3 +
version: 2
4 +
5 +
project_name: blobs
6 +
7 +
before:
8 +
  hooks:
9 +
    - go mod tidy
10 +
11 +
builds:
12 +
  - id: blobs
13 +
    main: .
14 +
    binary: blobs
15 +
    env:
16 +
      - CGO_ENABLED=0
17 +
    goos:
18 +
      - linux
19 +
      - darwin
20 +
      - windows
21 +
    goarch:
22 +
      - amd64
23 +
      - arm64
24 +
    ignore:
25 +
      - goos: windows
26 +
        goarch: arm64
27 +
    ldflags:
28 +
      - -s -w
29 +
      - -X main.version={{.Version}}
30 +
      - -X main.commit={{.Commit}}
31 +
      - -X main.date={{.Date}}
32 +
33 +
archives:
34 +
  - id: blobs
35 +
    name_template: >-
36 +
      {{ .ProjectName }}_{{ .Version }}_
37 +
      {{- if eq .Os "darwin" }}macos{{- else }}{{ .Os }}{{ end }}_
38 +
      {{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }}
39 +
    formats: [tar.gz]
40 +
    format_overrides:
41 +
      - goos: windows
42 +
        formats: [zip]
43 +
    files:
44 +
      - README.md
45 +
46 +
checksum:
47 +
  name_template: "checksums.txt"
48 +
49 +
snapshot:
50 +
  version_template: "0.0.0-snapshot-{{.ShortCommit}}"
51 +
52 +
changelog:
53 +
  use: github
54 +
  sort: asc
55 +
  filters:
56 +
    exclude:
57 +
      - "^docs:"
58 +
      - "^test:"
59 +
      - "^chore:"
60 +
61 +
release:
62 +
  disable: true
63 +
64 +
brews:
65 +
  - name: blobs
66 +
    repository:
67 +
      owner: stevedylandev
68 +
      name: homebrew-tap
69 +
      token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
70 +
    url_template: "https://github.com/stevedylandev/andromeda/releases/download/blobs/v{{ .Version }}/{{ .ArtifactName }}"
71 +
    homepage: "https://github.com/stevedylandev/andromeda"
72 +
    description: "Self-hosted browser for S3-compatible blob storage (R2, AWS, Minio, B2)"
73 +
    license: "MIT"
74 +
    commit_author:
75 +
      name: goreleaserbot
76 +
      email: bot@goreleaser.com
77 +
    commit_msg_template: "brew: blobs {{ .Tag }}"
78 +
    directory: Formula
79 +
    install: |
80 +
      bin.install "blobs"
apps/blobs/Dockerfile (added) +19 −0
1 +
# Build from repo root: docker build -t blobs -f apps/blobs/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY pkg/ ./pkg/
5 +
COPY apps/blobs/go.mod apps/blobs/go.sum ./apps/blobs/
6 +
WORKDIR /app/apps/blobs
7 +
RUN go mod download
8 +
COPY apps/blobs/ ./
9 +
RUN CGO_ENABLED=0 go build -o /blobs .
10 +
11 +
FROM debian:bookworm-slim
12 +
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
13 +
COPY --from=builder /blobs /usr/local/bin/blobs
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
ENV BLOBS_DB_PATH=/data/blobs.sqlite
18 +
EXPOSE 3000
19 +
CMD ["blobs"]
apps/blobs/README.md (added) +67 −0
1 +
# blobs
2 +
3 +
Single-owner web browser for S3-compatible blob storage. Built for Cloudflare R2 but works with any S3-compatible endpoint (AWS S3, MinIO, Backblaze B2, etc).
4 +
5 +
Features:
6 +
7 +
- Password login + session cookie auth
8 +
- Lists every bucket the credentials can see
9 +
- Folder/file navigation with breadcrumbs
10 +
- Inline image thumbnails in folder view
11 +
- File detail page: metadata, presigned download link, optional static public URL
12 +
- Upload (multi-file), replace, delete, create folder
13 +
14 +
## Quick start
15 +
16 +
```sh
17 +
cp .env.example .env
18 +
# edit .env — set BLOBS_PASSWORD and either:
19 +
#   S3_ENDPOINT + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY  (generic)
20 +
#   R2_ACCOUNT_ID + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY  (R2)
21 +
go run .
22 +
```
23 +
24 +
Visit `http://127.0.0.1:3000` and log in.
25 +
26 +
## Configuration
27 +
28 +
See `.env.example` for the full list. Notable knobs:
29 +
30 +
- `BLOBS_MAX_UPLOAD_MB` — single-shot upload cap (default 100MB)
31 +
- `BLOBS_PRESIGN_TTL_SECONDS` — presigned download URL lifetime (default 3600)
32 +
- `BLOBS_PUBLIC_URLS` — `bucket=url,bucket=url` map; when a file's bucket appears here, the detail page also surfaces a permanent public URL (e.g. an R2 public dev URL or custom domain)
33 +
34 +
## R2 setup
35 +
36 +
1. In the Cloudflare dashboard, create an R2 API token with read+write access to your bucket(s).
37 +
2. Set in `.env`:
38 +
   ```
39 +
   R2_ACCOUNT_ID=<your account id>
40 +
   S3_ACCESS_KEY_ID=<token id>
41 +
   S3_SECRET_ACCESS_KEY=<token secret>
42 +
   ```
43 +
3. (Optional) Enable a public dev URL or custom domain on the bucket and add it to `BLOBS_PUBLIC_URLS`.
44 +
45 +
## Generic S3 setup (e.g. MinIO)
46 +
47 +
```
48 +
S3_ENDPOINT=http://localhost:9000
49 +
S3_REGION=us-east-1
50 +
S3_ACCESS_KEY_ID=minioadmin
51 +
S3_SECRET_ACCESS_KEY=minioadmin
52 +
```
53 +
54 +
## Routes
55 +
56 +
| Method | Path | Notes |
57 +
| --- | --- | --- |
58 +
| GET | `/login`, POST `/login` | password form |
59 +
| POST | `/logout` | clear session |
60 +
| GET | `/buckets` | list buckets |
61 +
| GET | `/b/{bucket}/browse/{prefix...}` | folder listing |
62 +
| GET | `/b/{bucket}/object/{key...}` | file detail |
63 +
| GET | `/b/{bucket}/preview/{key...}` | proxied file stream (for inline images) |
64 +
| POST | `/b/{bucket}/upload` | multipart file upload |
65 +
| POST | `/b/{bucket}/replace` | overwrite existing key |
66 +
| POST | `/b/{bucket}/delete` | delete by key |
67 +
| POST | `/b/{bucket}/mkdir` | create zero-byte `prefix/name/` marker |
apps/blobs/app.go (added) +85 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/auth"
10 +
)
11 +
12 +
//go:embed templates/*.html static/*
13 +
var appFS embed.FS
14 +
15 +
type App struct {
16 +
	DB             *sql.DB
17 +
	Log            *slog.Logger
18 +
	Templates      map[string]*template.Template
19 +
	Sessions       *auth.Store
20 +
	S3             *S3Client
21 +
	Password       string
22 +
	CookieSecure   bool
23 +
	MaxUploadBytes int64
24 +
}
25 +
26 +
type loginPageData struct {
27 +
	Error string
28 +
}
29 +
30 +
type bucketsPageData struct {
31 +
	Buckets []BucketInfo
32 +
	Error   string
33 +
}
34 +
35 +
type crumb struct {
36 +
	Label string
37 +
	Href  string
38 +
}
39 +
40 +
type folderItem struct {
41 +
	Name string
42 +
	Href string
43 +
}
44 +
45 +
type fileItem struct {
46 +
	Name         string
47 +
	Size         int64
48 +
	SizeHuman    string
49 +
	LastModified string
50 +
	DetailHref   string
51 +
	PreviewSrc   string
52 +
	IsImage      bool
53 +
}
54 +
55 +
type browsePageData struct {
56 +
	Bucket   string
57 +
	Prefix   string
58 +
	ParentHref string
59 +
	Crumbs   []crumb
60 +
	Folders  []folderItem
61 +
	Files    []fileItem
62 +
	Error    string
63 +
	Success  string
64 +
	MaxUploadMB int64
65 +
}
66 +
67 +
type detailPageData struct {
68 +
	Bucket       string
69 +
	Key          string
70 +
	Name         string
71 +
	ContentType  string
72 +
	Size         int64
73 +
	SizeHuman    string
74 +
	LastModified string
75 +
	ETag         string
76 +
	IsImage      bool
77 +
	PreviewSrc   string
78 +
	PresignedURL string
79 +
	PublicURL    string
80 +
	HasPublic    bool
81 +
	ParentHref   string
82 +
	ParentPrefix string
83 +
	Crumbs       []crumb
84 +
	Error        string
85 +
}
apps/blobs/db.go (added) +11 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
6 +
	"github.com/stevedylandev/andromeda/pkg/sqlite"
7 +
)
8 +
9 +
func openDB(path string) (*sql.DB, error) {
10 +
	return sqlite.Open(path, "")
11 +
}
apps/blobs/go.mod (added) +46 −0
1 +
module github.com/stevedylandev/andromeda/apps/blobs
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/aws/aws-sdk-go-v2 v1.41.7
7 +
	github.com/aws/aws-sdk-go-v2/credentials v1.19.16
8 +
	github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
9 +
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
10 +
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
11 +
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
12 +
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
13 +
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
14 +
)
15 +
16 +
require (
17 +
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // 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/smithy-go v1.25.1 // indirect
26 +
	github.com/dustin/go-humanize v1.0.1 // indirect
27 +
	github.com/google/uuid v1.6.0 // indirect
28 +
	github.com/mattn/go-isatty v0.0.20 // indirect
29 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
30 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
31 +
	golang.org/x/crypto v0.39.0 // indirect
32 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
33 +
	golang.org/x/sys v0.33.0 // indirect
34 +
	modernc.org/libc v1.65.7 // indirect
35 +
	modernc.org/mathutil v1.7.1 // indirect
36 +
	modernc.org/memory v1.11.0 // indirect
37 +
	modernc.org/sqlite v1.37.1 // indirect
38 +
)
39 +
40 +
replace (
41 +
	github.com/stevedylandev/andromeda/pkg/auth => ../../pkg/auth
42 +
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
43 +
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
44 +
	github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
45 +
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
46 +
)
apps/blobs/go.sum (added) +73 −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=
25 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
26 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
27 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
28 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
29 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
30 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
31 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
32 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
33 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
34 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
35 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
36 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
37 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
38 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
39 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
40 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
41 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
42 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
43 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
44 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
45 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
46 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
47 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
48 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
49 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
50 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
51 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
52 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
53 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
54 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
55 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
56 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
57 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
58 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
59 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
60 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
61 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
62 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
63 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
64 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
65 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
66 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
67 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
68 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
69 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
70 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
71 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
72 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
73 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/blobs/handlers_actions.go (added) +150 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"mime"
6 +
	"net/http"
7 +
	"net/url"
8 +
	"path"
9 +
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/pkg/web"
12 +
)
13 +
14 +
func (a *App) upload(w http.ResponseWriter, r *http.Request) {
15 +
	bucket := r.PathValue("bucket")
16 +
	r.Body = http.MaxBytesReader(w, r.Body, a.MaxUploadBytes+1<<20)
17 +
	if err := r.ParseMultipartForm(a.MaxUploadBytes); err != nil {
18 +
		web.RedirectWithError(w, r, browseHref(bucket, ""), "Upload too large or malformed")
19 +
		return
20 +
	}
21 +
	prefix := strings.TrimSpace(r.FormValue("prefix"))
22 +
	if prefix != "" && !strings.HasSuffix(prefix, "/") {
23 +
		prefix += "/"
24 +
	}
25 +
	target := browseHref(bucket, prefix)
26 +
27 +
	files := r.MultipartForm.File["file"]
28 +
	if len(files) == 0 {
29 +
		web.RedirectWithError(w, r, target, "No file provided")
30 +
		return
31 +
	}
32 +
	for _, header := range files {
33 +
		if header.Size > a.MaxUploadBytes {
34 +
			web.RedirectWithError(w, r, target, "File exceeds upload limit")
35 +
			return
36 +
		}
37 +
		f, err := header.Open()
38 +
		if err != nil {
39 +
			web.RedirectWithError(w, r, target, "Failed to read upload")
40 +
			return
41 +
		}
42 +
		name := path.Base(header.Filename)
43 +
		if name == "" || name == "." || name == "/" {
44 +
			f.Close()
45 +
			web.RedirectWithError(w, r, target, "Invalid filename")
46 +
			return
47 +
		}
48 +
		ct := header.Header.Get("Content-Type")
49 +
		if ct == "" {
50 +
			ct = mime.TypeByExtension(path.Ext(name))
51 +
		}
52 +
		if ct == "" {
53 +
			ct = "application/octet-stream"
54 +
		}
55 +
		if err := a.S3.Put(r.Context(), bucket, prefix+name, ct, f, header.Size); err != nil {
56 +
			f.Close()
57 +
			a.Log.Error("put", "bucket", bucket, "key", prefix+name, "err", err)
58 +
			web.RedirectWithError(w, r, target, "Upload failed: "+err.Error())
59 +
			return
60 +
		}
61 +
		f.Close()
62 +
	}
63 +
	web.RedirectWithSuccess(w, r, target, "Uploaded")
64 +
}
65 +
66 +
func (a *App) replace(w http.ResponseWriter, r *http.Request) {
67 +
	bucket := r.PathValue("bucket")
68 +
	r.Body = http.MaxBytesReader(w, r.Body, a.MaxUploadBytes+1<<20)
69 +
	if err := r.ParseMultipartForm(a.MaxUploadBytes); err != nil {
70 +
		web.RedirectWithError(w, r, browseHref(bucket, ""), "Upload too large or malformed")
71 +
		return
72 +
	}
73 +
	key := strings.TrimSpace(r.FormValue("key"))
74 +
	if key == "" {
75 +
		http.Error(w, "key required", http.StatusBadRequest)
76 +
		return
77 +
	}
78 +
	target := "/b/" + url.PathEscape(bucket) + "/object/" + escapeKeyPath(key)
79 +
	f, header, err := r.FormFile("file")
80 +
	if err != nil {
81 +
		web.RedirectWithError(w, r, target, "No file provided")
82 +
		return
83 +
	}
84 +
	defer f.Close()
85 +
	if header.Size > a.MaxUploadBytes {
86 +
		web.RedirectWithError(w, r, target, "File exceeds upload limit")
87 +
		return
88 +
	}
89 +
	ct := header.Header.Get("Content-Type")
90 +
	if ct == "" {
91 +
		ct = mime.TypeByExtension(path.Ext(key))
92 +
	}
93 +
	if ct == "" {
94 +
		ct = "application/octet-stream"
95 +
	}
96 +
	if err := a.S3.Put(r.Context(), bucket, key, ct, f, header.Size); err != nil {
97 +
		a.Log.Error("put replace", "bucket", bucket, "key", key, "err", err)
98 +
		web.RedirectWithError(w, r, target, "Replace failed: "+err.Error())
99 +
		return
100 +
	}
101 +
	web.RedirectWithSuccess(w, r, target, "Replaced")
102 +
}
103 +
104 +
func (a *App) deleteObject(w http.ResponseWriter, r *http.Request) {
105 +
	bucket := r.PathValue("bucket")
106 +
	if err := r.ParseForm(); err != nil {
107 +
		http.Error(w, "bad request", http.StatusBadRequest)
108 +
		return
109 +
	}
110 +
	key := strings.TrimSpace(r.FormValue("key"))
111 +
	if key == "" {
112 +
		http.Error(w, "key required", http.StatusBadRequest)
113 +
		return
114 +
	}
115 +
	returnTo := strings.TrimSpace(r.FormValue("returnTo"))
116 +
	if returnTo == "" {
117 +
		returnTo = browseHref(bucket, parentOfKey(strings.TrimSuffix(key, "/")))
118 +
	}
119 +
	if err := a.S3.Delete(r.Context(), bucket, key); err != nil {
120 +
		a.Log.Error("delete", "bucket", bucket, "key", key, "err", err)
121 +
		web.RedirectWithError(w, r, returnTo, "Delete failed: "+err.Error())
122 +
		return
123 +
	}
124 +
	web.RedirectWithSuccess(w, r, returnTo, "Deleted")
125 +
}
126 +
127 +
func (a *App) mkdir(w http.ResponseWriter, r *http.Request) {
128 +
	bucket := r.PathValue("bucket")
129 +
	if err := r.ParseForm(); err != nil {
130 +
		http.Error(w, "bad request", http.StatusBadRequest)
131 +
		return
132 +
	}
133 +
	prefix := strings.TrimSpace(r.FormValue("prefix"))
134 +
	if prefix != "" && !strings.HasSuffix(prefix, "/") {
135 +
		prefix += "/"
136 +
	}
137 +
	name := strings.TrimSpace(r.FormValue("name"))
138 +
	name = strings.Trim(name, "/")
139 +
	if name == "" || strings.HasPrefix(name, ".") || strings.Contains(name, "/") {
140 +
		web.RedirectWithError(w, r, browseHref(bucket, prefix), "Invalid folder name")
141 +
		return
142 +
	}
143 +
	newPrefix := prefix + name + "/"
144 +
	if err := a.S3.Put(r.Context(), bucket, newPrefix, "application/x-directory", bytes.NewReader(nil), 0); err != nil {
145 +
		a.Log.Error("mkdir", "bucket", bucket, "key", newPrefix, "err", err)
146 +
		web.RedirectWithError(w, r, browseHref(bucket, prefix), "Create folder failed: "+err.Error())
147 +
		return
148 +
	}
149 +
	web.RedirectWithSuccess(w, r, browseHref(bucket, newPrefix), "Folder created")
150 +
}
apps/blobs/handlers_auth.go (added) +47 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/pkg/auth"
7 +
)
8 +
9 +
func (a *App) rootRedirect(w http.ResponseWriter, r *http.Request) {
10 +
	http.Redirect(w, r, "/buckets", http.StatusSeeOther)
11 +
}
12 +
13 +
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
14 +
	if a.Sessions.HasValid(r) {
15 +
		http.Redirect(w, r, "/buckets", http.StatusSeeOther)
16 +
		return
17 +
	}
18 +
	a.renderPage(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")})
19 +
}
20 +
21 +
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
22 +
	if err := r.ParseForm(); err != nil {
23 +
		http.Redirect(w, r, "/login?error=Bad+request", http.StatusSeeOther)
24 +
		return
25 +
	}
26 +
	if !auth.VerifyPassword(r.FormValue("password"), a.Password) {
27 +
		http.Redirect(w, r, "/login?error=Invalid+password", http.StatusSeeOther)
28 +
		return
29 +
	}
30 +
	token, err := a.Sessions.Create()
31 +
	if err != nil {
32 +
		a.Log.Error("create session", "err", err)
33 +
		http.Redirect(w, r, "/login?error=Server+error", http.StatusSeeOther)
34 +
		return
35 +
	}
36 +
	a.Sessions.PruneExpired()
37 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
38 +
	http.Redirect(w, r, "/buckets", http.StatusSeeOther)
39 +
}
40 +
41 +
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
42 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
43 +
		a.Sessions.Delete(c.Value)
44 +
	}
45 +
	http.SetCookie(w, a.Sessions.ClearCookie())
46 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
47 +
}
apps/blobs/handlers_browse.go (added) +153 −0
1 +
package main
2 +
3 +
import (
4 +
	"errors"
5 +
	"io"
6 +
	"net/http"
7 +
	"strconv"
8 +
	"strings"
9 +
)
10 +
11 +
func (a *App) listBuckets(w http.ResponseWriter, r *http.Request) {
12 +
	buckets, err := a.S3.ListBuckets(r.Context())
13 +
	data := bucketsPageData{Buckets: buckets}
14 +
	if err != nil {
15 +
		a.Log.Error("list buckets", "err", err)
16 +
		data.Error = err.Error()
17 +
	}
18 +
	a.renderPage(w, "buckets.html", data)
19 +
}
20 +
21 +
func (a *App) bucketRoot(w http.ResponseWriter, r *http.Request) {
22 +
	bucket := r.PathValue("bucket")
23 +
	http.Redirect(w, r, browseHref(bucket, ""), http.StatusSeeOther)
24 +
}
25 +
26 +
func (a *App) browse(w http.ResponseWriter, r *http.Request) {
27 +
	bucket := r.PathValue("bucket")
28 +
	prefix := r.PathValue("prefix")
29 +
	if prefix != "" && !strings.HasSuffix(prefix, "/") {
30 +
		http.Redirect(w, r, browseHref(bucket, prefix+"/"), http.StatusSeeOther)
31 +
		return
32 +
	}
33 +
34 +
	folders, files, err := a.S3.List(r.Context(), bucket, prefix)
35 +
	data := browsePageData{
36 +
		Bucket:      bucket,
37 +
		Prefix:      prefix,
38 +
		Crumbs:      buildCrumbs(bucket, prefix),
39 +
		Error:       r.URL.Query().Get("error"),
40 +
		Success:     r.URL.Query().Get("success"),
41 +
		MaxUploadMB: a.MaxUploadBytes >> 20,
42 +
	}
43 +
	if prefix != "" {
44 +
		data.ParentHref = browseHref(bucket, parentPrefix(prefix))
45 +
	}
46 +
	if err != nil {
47 +
		a.Log.Error("list", "bucket", bucket, "prefix", prefix, "err", err)
48 +
		if data.Error == "" {
49 +
			data.Error = err.Error()
50 +
		}
51 +
	}
52 +
	for _, f := range folders {
53 +
		name := strings.TrimSuffix(strings.TrimPrefix(f, prefix), "/")
54 +
		if name == "" {
55 +
			continue
56 +
		}
57 +
		data.Folders = append(data.Folders, folderItem{
58 +
			Name: name,
59 +
			Href: browseHref(bucket, f),
60 +
		})
61 +
	}
62 +
	for _, f := range files {
63 +
		// Skip the zero-byte "folder marker" key that matches the prefix exactly.
64 +
		if f.Name == "" {
65 +
			continue
66 +
		}
67 +
		img := isImageName(f.Name)
68 +
		item := fileItem{
69 +
			Name:         f.Name,
70 +
			Size:         f.Size,
71 +
			SizeHuman:    humanSize(f.Size),
72 +
			LastModified: f.LastModified,
73 +
			DetailHref:   objectHref(bucket, f.Key),
74 +
			IsImage:      img,
75 +
		}
76 +
		if img {
77 +
			item.PreviewSrc = previewHref(bucket, f.Key)
78 +
		}
79 +
		data.Files = append(data.Files, item)
80 +
	}
81 +
	a.renderPage(w, "browse.html", data)
82 +
}
83 +
84 +
func (a *App) detail(w http.ResponseWriter, r *http.Request) {
85 +
	bucket := r.PathValue("bucket")
86 +
	key := r.PathValue("key")
87 +
	if key == "" {
88 +
		http.NotFound(w, r)
89 +
		return
90 +
	}
91 +
	meta, err := a.S3.Head(r.Context(), bucket, key)
92 +
	if err != nil {
93 +
		a.Log.Error("head", "bucket", bucket, "key", key, "err", err)
94 +
		http.Error(w, "object not found", http.StatusNotFound)
95 +
		return
96 +
	}
97 +
	parent := parentOfKey(key)
98 +
	data := detailPageData{
99 +
		Bucket:       bucket,
100 +
		Key:          key,
101 +
		Name:         nameOfKey(key),
102 +
		ContentType:  meta.ContentType,
103 +
		Size:         meta.Size,
104 +
		SizeHuman:    humanSize(meta.Size),
105 +
		LastModified: meta.LastModified,
106 +
		ETag:         meta.ETag,
107 +
		IsImage:      isImageName(key) || strings.HasPrefix(meta.ContentType, "image/"),
108 +
		PreviewSrc:   previewHref(bucket, key),
109 +
		ParentHref:   browseHref(bucket, parent),
110 +
		ParentPrefix: parent,
111 +
		Crumbs:       buildCrumbs(bucket, parent),
112 +
		Error:        r.URL.Query().Get("error"),
113 +
	}
114 +
	if u, perr := a.S3.PresignGet(r.Context(), bucket, key); perr == nil {
115 +
		data.PresignedURL = u
116 +
	} else {
117 +
		a.Log.Warn("presign", "err", perr)
118 +
	}
119 +
	if u, ok := a.S3.PublicURL(bucket, key); ok {
120 +
		data.PublicURL = u
121 +
		data.HasPublic = true
122 +
	}
123 +
	a.renderPage(w, "detail.html", data)
124 +
}
125 +
126 +
func (a *App) preview(w http.ResponseWriter, r *http.Request) {
127 +
	bucket := r.PathValue("bucket")
128 +
	key := r.PathValue("key")
129 +
	if key == "" {
130 +
		http.NotFound(w, r)
131 +
		return
132 +
	}
133 +
	body, meta, err := a.S3.Get(r.Context(), bucket, key)
134 +
	if err != nil {
135 +
		a.Log.Error("get", "bucket", bucket, "key", key, "err", err)
136 +
		http.Error(w, "object not found", http.StatusNotFound)
137 +
		return
138 +
	}
139 +
	defer body.Close()
140 +
	if meta.ContentType != "" {
141 +
		w.Header().Set("Content-Type", meta.ContentType)
142 +
	}
143 +
	if meta.Size > 0 {
144 +
		w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
145 +
	}
146 +
	if meta.ETag != "" {
147 +
		w.Header().Set("ETag", `"`+meta.ETag+`"`)
148 +
	}
149 +
	w.Header().Set("Cache-Control", "private, max-age=60")
150 +
	if _, err := io.Copy(w, body); err != nil && !errors.Is(err, http.ErrBodyReadAfterClose) {
151 +
		a.Log.Warn("preview copy", "err", err)
152 +
	}
153 +
}
apps/blobs/main.go (added) +88 −0
1 +
package main
2 +
3 +
import (
4 +
	"log"
5 +
	"log/slog"
6 +
	"net/http"
7 +
	"os"
8 +
	"time"
9 +
10 +
	"github.com/stevedylandev/andromeda/pkg/auth"
11 +
	"github.com/stevedylandev/andromeda/pkg/config"
12 +
)
13 +
14 +
func main() {
15 +
	config.LoadDotEnv(".env")
16 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 +
18 +
	dbPath := config.Getenv("BLOBS_DB_PATH", "blobs.sqlite")
19 +
	db, err := openDB(dbPath)
20 +
	if err != nil {
21 +
		log.Fatal(err)
22 +
	}
23 +
	defer db.Close()
24 +
25 +
	sessions := &auth.Store{
26 +
		DB:           db,
27 +
		CookieName:   "blobs_session",
28 +
		CookieSecure: config.GetenvBool("BLOBS_COOKIE_SECURE", false),
29 +
	}
30 +
	if err := sessions.EnsureSchema(); err != nil {
31 +
		log.Fatal(err)
32 +
	}
33 +
	sessions.PruneExpired()
34 +
	go func() {
35 +
		ticker := time.NewTicker(time.Hour)
36 +
		defer ticker.Stop()
37 +
		for range ticker.C {
38 +
			sessions.PruneExpired()
39 +
		}
40 +
	}()
41 +
42 +
	password := os.Getenv("BLOBS_PASSWORD")
43 +
	if password == "" {
44 +
		logger.Warn("BLOBS_PASSWORD not set, using default 'changeme'")
45 +
		password = "changeme"
46 +
	}
47 +
48 +
	endpoint := config.Getenv("S3_ENDPOINT", "")
49 +
	if endpoint == "" {
50 +
		if accountID := config.Getenv("R2_ACCOUNT_ID", ""); accountID != "" {
51 +
			endpoint = "https://" + accountID + ".r2.cloudflarestorage.com"
52 +
		}
53 +
	}
54 +
	accessKey := config.Getenv("S3_ACCESS_KEY_ID", config.Getenv("R2_ACCESS_KEY_ID", ""))
55 +
	secretKey := config.Getenv("S3_SECRET_ACCESS_KEY", config.Getenv("R2_SECRET_ACCESS_KEY", ""))
56 +
	region := config.Getenv("S3_REGION", "auto")
57 +
	publicURLs := parsePublicURLs(os.Getenv("BLOBS_PUBLIC_URLS"))
58 +
	presignTTL := time.Duration(config.GetenvInt("BLOBS_PRESIGN_TTL_SECONDS", 3600)) * time.Second
59 +
60 +
	client, err := NewS3Client(endpoint, region, accessKey, secretKey, publicURLs, presignTTL)
61 +
	if err != nil {
62 +
		log.Fatalf("configure S3: %v", err)
63 +
	}
64 +
	logger.Info("S3 client ready", "endpoint", endpoint, "region", region, "public_buckets", len(publicURLs))
65 +
66 +
	tmpl, err := buildTemplates()
67 +
	if err != nil {
68 +
		log.Fatal(err)
69 +
	}
70 +
71 +
	maxUploadMB := int64(config.GetenvInt("BLOBS_MAX_UPLOAD_MB", 100))
72 +
	app := &App{
73 +
		DB:             db,
74 +
		Log:            logger,
75 +
		Templates:      tmpl,
76 +
		Sessions:       sessions,
77 +
		S3:             client,
78 +
		Password:       password,
79 +
		CookieSecure:   sessions.CookieSecure,
80 +
		MaxUploadBytes: maxUploadMB << 20,
81 +
	}
82 +
83 +
	addr := config.Getenv("BLOBS_HOST", "127.0.0.1") + ":" + config.Getenv("BLOBS_PORT", "3000")
84 +
	logger.Info("blobs server running", "addr", addr)
85 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
86 +
		log.Fatal(err)
87 +
	}
88 +
}
apps/blobs/render.go (added) +45 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"html/template"
6 +
	"io/fs"
7 +
	"net/http"
8 +
	"path"
9 +
10 +
	"github.com/stevedylandev/andromeda/pkg/web"
11 +
)
12 +
13 +
func buildTemplates() (map[string]*template.Template, error) {
14 +
	pages, err := fs.Glob(appFS, "templates/*.html")
15 +
	if err != nil {
16 +
		return nil, err
17 +
	}
18 +
	out := make(map[string]*template.Template, len(pages))
19 +
	for _, page := range pages {
20 +
		name := path.Base(page)
21 +
		if name == "base.html" {
22 +
			continue
23 +
		}
24 +
		patterns := []string{page}
25 +
		if name != "login.html" {
26 +
			patterns = append([]string{"templates/base.html"}, patterns...)
27 +
		}
28 +
		tmpl, err := template.ParseFS(appFS, patterns...)
29 +
		if err != nil {
30 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
31 +
		}
32 +
		out[name] = tmpl
33 +
	}
34 +
	return out, nil
35 +
}
36 +
37 +
func (a *App) renderPage(w http.ResponseWriter, name string, data any) {
38 +
	tmpl, ok := a.Templates[name]
39 +
	if !ok {
40 +
		a.Log.Error("template missing", "name", name)
41 +
		http.Error(w, "template missing", http.StatusInternalServerError)
42 +
		return
43 +
	}
44 +
	web.Render(tmpl, w, name, data, a.Log)
45 +
}
apps/blobs/routes.go (added) +38 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/pkg/web"
8 +
)
9 +
10 +
func (a *App) routes() *http.ServeMux {
11 +
	mux := http.NewServeMux()
12 +
13 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
14 +
		return a.Sessions.RequireSession("/login", next)
15 +
	}
16 +
17 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
18 +
	darkmatter.Mount(mux, "/assets")
19 +
20 +
	mux.HandleFunc("GET /login", a.loginGet)
21 +
	mux.HandleFunc("POST /login", a.loginPost)
22 +
	mux.HandleFunc("POST /logout", a.logout)
23 +
24 +
	mux.HandleFunc("GET /{$}", requireSession(a.rootRedirect))
25 +
	mux.HandleFunc("GET /buckets", requireSession(a.listBuckets))
26 +
27 +
	mux.HandleFunc("GET /b/{bucket}", requireSession(a.bucketRoot))
28 +
	mux.HandleFunc("GET /b/{bucket}/browse/{prefix...}", requireSession(a.browse))
29 +
	mux.HandleFunc("GET /b/{bucket}/object/{key...}", requireSession(a.detail))
30 +
	mux.HandleFunc("GET /b/{bucket}/preview/{key...}", requireSession(a.preview))
31 +
32 +
	mux.HandleFunc("POST /b/{bucket}/upload", requireSession(a.upload))
33 +
	mux.HandleFunc("POST /b/{bucket}/replace", requireSession(a.replace))
34 +
	mux.HandleFunc("POST /b/{bucket}/delete", requireSession(a.deleteObject))
35 +
	mux.HandleFunc("POST /b/{bucket}/mkdir", requireSession(a.mkdir))
36 +
37 +
	return mux
38 +
}
apps/blobs/s3client.go (added) +221 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"fmt"
6 +
	"io"
7 +
	"net/url"
8 +
	"strings"
9 +
	"time"
10 +
11 +
	"github.com/aws/aws-sdk-go-v2/aws"
12 +
	"github.com/aws/aws-sdk-go-v2/credentials"
13 +
	"github.com/aws/aws-sdk-go-v2/service/s3"
14 +
	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15 +
)
16 +
17 +
type S3Client struct {
18 +
	s3         *s3.Client
19 +
	presigner  *s3.PresignClient
20 +
	publicURLs map[string]string
21 +
	presignTTL time.Duration
22 +
}
23 +
24 +
type BucketInfo struct {
25 +
	Name         string
26 +
	CreationDate string
27 +
}
28 +
29 +
type ObjectInfo struct {
30 +
	Key          string
31 +
	Name         string
32 +
	Size         int64
33 +
	LastModified string
34 +
	ETag         string
35 +
}
36 +
37 +
type ObjectMeta struct {
38 +
	Key           string
39 +
	ContentType   string
40 +
	Size          int64
41 +
	LastModified  string
42 +
	ETag          string
43 +
}
44 +
45 +
func NewS3Client(endpoint, region, accessKey, secretKey string, publicURLs map[string]string, presignTTL time.Duration) (*S3Client, error) {
46 +
	if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" {
47 +
		return nil, fmt.Errorf("S3 access key and secret key are required")
48 +
	}
49 +
	if strings.TrimSpace(endpoint) == "" {
50 +
		return nil, fmt.Errorf("S3 endpoint is required (set S3_ENDPOINT or R2_ACCOUNT_ID)")
51 +
	}
52 +
	if region == "" {
53 +
		region = "auto"
54 +
	}
55 +
	cfg := aws.Config{
56 +
		Region:      region,
57 +
		Credentials: credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
58 +
	}
59 +
	client := s3.NewFromConfig(cfg, func(o *s3.Options) {
60 +
		o.BaseEndpoint = aws.String(endpoint)
61 +
		o.UsePathStyle = true
62 +
	})
63 +
	if presignTTL <= 0 {
64 +
		presignTTL = time.Hour
65 +
	}
66 +
	return &S3Client{
67 +
		s3:         client,
68 +
		presigner:  s3.NewPresignClient(client),
69 +
		publicURLs: publicURLs,
70 +
		presignTTL: presignTTL,
71 +
	}, nil
72 +
}
73 +
74 +
func (c *S3Client) ListBuckets(ctx context.Context) ([]BucketInfo, error) {
75 +
	out, err := c.s3.ListBuckets(ctx, &s3.ListBucketsInput{})
76 +
	if err != nil {
77 +
		return nil, err
78 +
	}
79 +
	buckets := make([]BucketInfo, 0, len(out.Buckets))
80 +
	for _, b := range out.Buckets {
81 +
		bi := BucketInfo{Name: aws.ToString(b.Name)}
82 +
		if b.CreationDate != nil {
83 +
			bi.CreationDate = b.CreationDate.UTC().Format("2006-01-02 15:04:05")
84 +
		}
85 +
		buckets = append(buckets, bi)
86 +
	}
87 +
	return buckets, nil
88 +
}
89 +
90 +
func (c *S3Client) List(ctx context.Context, bucket, prefix string) ([]string, []ObjectInfo, error) {
91 +
	folders := []string{}
92 +
	files := []ObjectInfo{}
93 +
	var token *string
94 +
	for {
95 +
		out, err := c.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
96 +
			Bucket:            aws.String(bucket),
97 +
			Prefix:            aws.String(prefix),
98 +
			Delimiter:         aws.String("/"),
99 +
			ContinuationToken: token,
100 +
		})
101 +
		if err != nil {
102 +
			return nil, nil, err
103 +
		}
104 +
		for _, cp := range out.CommonPrefixes {
105 +
			folders = append(folders, aws.ToString(cp.Prefix))
106 +
		}
107 +
		for _, obj := range out.Contents {
108 +
			key := aws.ToString(obj.Key)
109 +
			if key == prefix {
110 +
				continue
111 +
			}
112 +
			name := strings.TrimPrefix(key, prefix)
113 +
			oi := ObjectInfo{
114 +
				Key:  key,
115 +
				Name: name,
116 +
				ETag: strings.Trim(aws.ToString(obj.ETag), `"`),
117 +
			}
118 +
			if obj.Size != nil {
119 +
				oi.Size = *obj.Size
120 +
			}
121 +
			if obj.LastModified != nil {
122 +
				oi.LastModified = obj.LastModified.UTC().Format("2006-01-02 15:04:05")
123 +
			}
124 +
			files = append(files, oi)
125 +
		}
126 +
		if out.IsTruncated == nil || !*out.IsTruncated {
127 +
			break
128 +
		}
129 +
		token = out.NextContinuationToken
130 +
	}
131 +
	return folders, files, nil
132 +
}
133 +
134 +
func (c *S3Client) Head(ctx context.Context, bucket, key string) (*ObjectMeta, error) {
135 +
	out, err := c.s3.HeadObject(ctx, &s3.HeadObjectInput{
136 +
		Bucket: aws.String(bucket),
137 +
		Key:    aws.String(key),
138 +
	})
139 +
	if err != nil {
140 +
		return nil, err
141 +
	}
142 +
	m := &ObjectMeta{
143 +
		Key:         key,
144 +
		ContentType: aws.ToString(out.ContentType),
145 +
		ETag:        strings.Trim(aws.ToString(out.ETag), `"`),
146 +
	}
147 +
	if out.ContentLength != nil {
148 +
		m.Size = *out.ContentLength
149 +
	}
150 +
	if out.LastModified != nil {
151 +
		m.LastModified = out.LastModified.UTC().Format("2006-01-02 15:04:05")
152 +
	}
153 +
	return m, nil
154 +
}
155 +
156 +
func (c *S3Client) Get(ctx context.Context, bucket, key string) (io.ReadCloser, *ObjectMeta, error) {
157 +
	out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
158 +
		Bucket: aws.String(bucket),
159 +
		Key:    aws.String(key),
160 +
	})
161 +
	if err != nil {
162 +
		return nil, nil, err
163 +
	}
164 +
	m := &ObjectMeta{
165 +
		Key:         key,
166 +
		ContentType: aws.ToString(out.ContentType),
167 +
		ETag:        strings.Trim(aws.ToString(out.ETag), `"`),
168 +
	}
169 +
	if out.ContentLength != nil {
170 +
		m.Size = *out.ContentLength
171 +
	}
172 +
	if out.LastModified != nil {
173 +
		m.LastModified = out.LastModified.UTC().Format("2006-01-02 15:04:05")
174 +
	}
175 +
	return out.Body, m, nil
176 +
}
177 +
178 +
func (c *S3Client) Put(ctx context.Context, bucket, key, contentType string, body io.Reader, size int64) error {
179 +
	in := &s3.PutObjectInput{
180 +
		Bucket: aws.String(bucket),
181 +
		Key:    aws.String(key),
182 +
		Body:   body,
183 +
	}
184 +
	if contentType != "" {
185 +
		in.ContentType = aws.String(contentType)
186 +
	}
187 +
	if size > 0 {
188 +
		in.ContentLength = aws.Int64(size)
189 +
	}
190 +
	_, err := c.s3.PutObject(ctx, in)
191 +
	return err
192 +
}
193 +
194 +
func (c *S3Client) Delete(ctx context.Context, bucket, key string) error {
195 +
	_, err := c.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
196 +
		Bucket: aws.String(bucket),
197 +
		Key:    aws.String(key),
198 +
	})
199 +
	return err
200 +
}
201 +
202 +
func (c *S3Client) PresignGet(ctx context.Context, bucket, key string) (string, error) {
203 +
	out, err := c.presigner.PresignGetObject(ctx, &s3.GetObjectInput{
204 +
		Bucket: aws.String(bucket),
205 +
		Key:    aws.String(key),
206 +
	}, s3.WithPresignExpires(c.presignTTL))
207 +
	if err != nil {
208 +
		return "", err
209 +
	}
210 +
	return out.URL, nil
211 +
}
212 +
213 +
func (c *S3Client) PublicURL(bucket, key string) (string, bool) {
214 +
	prefix, ok := c.publicURLs[bucket]
215 +
	if !ok || prefix == "" {
216 +
		return "", false
217 +
	}
218 +
	return strings.TrimRight(prefix, "/") + "/" + url.PathEscape(key), true
219 +
}
220 +
221 +
var _ s3types.Object
apps/blobs/static/app.css (added) +173 −0
1 +
/* blobs — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
body {
6 +
  overflow-wrap: anywhere;
7 +
  word-break: break-word;
8 +
}
9 +
10 +
.admin-list-title,
11 +
.admin-list-meta,
12 +
.admin-list-info {
13 +
  min-width: 0;
14 +
  overflow-wrap: anywhere;
15 +
  word-break: break-word;
16 +
}
17 +
18 +
.admin-list-item {
19 +
  min-width: 0;
20 +
}
21 +
22 +
code {
23 +
  overflow-wrap: anywhere;
24 +
  word-break: break-all;
25 +
}
26 +
27 +
.crumbs {
28 +
  display: flex;
29 +
  flex-wrap: wrap;
30 +
  align-items: center;
31 +
  gap: 0.25rem;
32 +
  font-size: 12px;
33 +
  opacity: 0.7;
34 +
  margin-bottom: 1rem;
35 +
  overflow-wrap: anywhere;
36 +
  word-break: break-all;
37 +
}
38 +
39 +
.crumbs a {
40 +
  color: inherit;
41 +
  text-decoration: none;
42 +
}
43 +
44 +
.crumbs a:hover {
45 +
  opacity: 0.7;
46 +
}
47 +
48 +
.crumb-sep {
49 +
  opacity: 0.5;
50 +
}
51 +
52 +
.search-row {
53 +
  display: flex;
54 +
  align-items: center;
55 +
  gap: 0.75rem;
56 +
  margin-bottom: 1rem;
57 +
}
58 +
59 +
.search-row input[type="search"] {
60 +
  flex: 1;
61 +
  min-width: 0;
62 +
}
63 +
64 +
.filter-count {
65 +
  font-size: 12px;
66 +
  opacity: 0.5;
67 +
  white-space: nowrap;
68 +
}
69 +
70 +
.actions-row {
71 +
  display: flex;
72 +
  flex-wrap: wrap;
73 +
  gap: 1.5rem;
74 +
  margin-bottom: 1.25rem;
75 +
  padding-bottom: 1rem;
76 +
  border-bottom: 1px solid #333;
77 +
}
78 +
79 +
.inline-actions {
80 +
  display: flex;
81 +
  flex-direction: column;
82 +
  gap: 0.4rem;
83 +
  min-width: 240px;
84 +
}
85 +
86 +
.file-thumbnail {
87 +
  width: 48px;
88 +
  height: 48px;
89 +
  object-fit: cover;
90 +
  border: 1px solid #333;
91 +
}
92 +
93 +
.admin-list-item img.file-thumbnail {
94 +
  margin-right: 0.75rem;
95 +
}
96 +
97 +
.detail-preview {
98 +
  margin: 1rem 0;
99 +
  border: 1px solid #333;
100 +
  padding: 0.5rem;
101 +
  display: inline-block;
102 +
  max-width: 100%;
103 +
}
104 +
105 +
.detail-preview img {
106 +
  display: block;
107 +
  max-width: 100%;
108 +
  max-height: 60vh;
109 +
  height: auto;
110 +
}
111 +
112 +
.detail-meta {
113 +
  display: grid;
114 +
  grid-template-columns: max-content 1fr;
115 +
  gap: 0.4rem 1rem;
116 +
  margin: 1rem 0;
117 +
  font-size: 13px;
118 +
}
119 +
120 +
.detail-meta dt {
121 +
  opacity: 0.5;
122 +
  text-transform: lowercase;
123 +
}
124 +
125 +
.detail-meta dd {
126 +
  margin: 0;
127 +
  word-break: break-all;
128 +
}
129 +
130 +
.detail-meta code {
131 +
  font-size: 12px;
132 +
  background: #1e1c1f;
133 +
  padding: 1px 4px;
134 +
}
135 +
136 +
.detail-links,
137 +
.detail-actions {
138 +
  margin-top: 1.5rem;
139 +
  padding-top: 1rem;
140 +
  border-top: 1px solid #333;
141 +
}
142 +
143 +
.detail-links h3,
144 +
.detail-actions h3 {
145 +
  font-size: 14px;
146 +
  margin-bottom: 0.75rem;
147 +
  opacity: 0.7;
148 +
  text-transform: lowercase;
149 +
}
150 +
151 +
.link-row {
152 +
  display: flex;
153 +
  flex-wrap: wrap;
154 +
  align-items: center;
155 +
  gap: 0.75rem;
156 +
  margin-bottom: 0.5rem;
157 +
}
158 +
159 +
.link-row a {
160 +
  overflow-wrap: anywhere;
161 +
  word-break: break-all;
162 +
  min-width: 0;
163 +
  flex: 1 1 200px;
164 +
}
165 +
166 +
.detail-actions .form {
167 +
  margin-bottom: 1rem;
168 +
}
169 +
170 +
button.danger,
171 +
.link-button.danger {
172 +
  opacity: 0.8;
173 +
}
apps/blobs/static/favicon.svg (added) +5 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2 +
  <rect width="32" height="32" fill="#121113"/>
3 +
  <text x="50%" y="50%" text-anchor="middle" dominant-baseline="central"
4 +
        font-family="monospace" font-size="18" font-weight="700" fill="#ffffff">b</text>
5 +
</svg>
apps/blobs/templates/base.html (added) +26 −0
1 +
{{define "base.html"}}<!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>{{block "title" .}}blobs{{end}}</title>
7 +
  <link rel="icon" href="/static/favicon.svg">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
10 +
  <link rel="stylesheet" href="/static/app.css">
11 +
</head>
12 +
<body>
13 +
  <header class="header">
14 +
    <a href="/buckets" class="logo">BLOBS</a>
15 +
    <nav class="links">
16 +
      <a href="/buckets">buckets</a>
17 +
      <form method="POST" action="/logout" class="inline-form">
18 +
        <button type="submit" class="link-button">logout</button>
19 +
      </form>
20 +
    </nav>
21 +
  </header>
22 +
  <main>
23 +
    {{block "content" .}}{{end}}
24 +
  </main>
25 +
</body>
26 +
</html>{{end}}
apps/blobs/templates/browse.html (added) +89 −0
1 +
{{define "browse.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Bucket}}/{{.Prefix}}{{end}}
3 +
{{define "content"}}
4 +
  <nav class="crumbs">
5 +
    <a href="/buckets">buckets</a>
6 +
    {{range .Crumbs}}<span class="crumb-sep">/</span><a href="{{.Href}}">{{.Label}}</a>{{end}}
7 +
    <span class="crumb-sep">/</span>
8 +
  </nav>
9 +
10 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
11 +
  {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
12 +
13 +
  <div class="search-row">
14 +
    <input type="search" id="filter" placeholder="filter folders + files in this view…" autocomplete="off">
15 +
    <span class="filter-count" id="filter-count"></span>
16 +
  </div>
17 +
18 +
  <div class="actions-row">
19 +
    <form method="POST" action="/b/{{.Bucket}}/upload" enctype="multipart/form-data" class="form inline-actions">
20 +
      <input type="hidden" name="prefix" value="{{.Prefix}}">
21 +
      <label for="file">upload (max {{.MaxUploadMB}}MB, multiple allowed)</label>
22 +
      <input type="file" id="file" name="file" multiple required>
23 +
      <button type="submit">upload</button>
24 +
    </form>
25 +
26 +
    <form method="POST" action="/b/{{.Bucket}}/mkdir" class="form inline-actions">
27 +
      <input type="hidden" name="prefix" value="{{.Prefix}}">
28 +
      <label for="name">new folder</label>
29 +
      <input type="text" id="name" name="name" placeholder="folder-name" required>
30 +
      <button type="submit">create</button>
31 +
    </form>
32 +
  </div>
33 +
34 +
  {{if and (not .Folders) (not .Files) (not .ParentHref)}}
35 +
    <p class="empty">empty bucket</p>
36 +
  {{end}}
37 +
38 +
  <div class="admin-list" id="entry-list">
39 +
    {{if .ParentHref}}
40 +
      <a href="{{.ParentHref}}" class="admin-list-item" data-parent="1">
41 +
        <div class="admin-list-info">
42 +
          <span class="admin-list-title">../</span>
43 +
        </div>
44 +
      </a>
45 +
    {{end}}
46 +
    {{range .Folders}}
47 +
      <a href="{{.Href}}" class="admin-list-item" data-name="{{.Name}}">
48 +
        <div class="admin-list-info">
49 +
          <span class="admin-list-title">{{.Name}}/</span>
50 +
        </div>
51 +
      </a>
52 +
    {{end}}
53 +
    {{range .Files}}
54 +
      <a href="{{.DetailHref}}" class="admin-list-item" data-name="{{.Name}}">
55 +
        {{if .IsImage}}<img src="{{.PreviewSrc}}" class="file-thumbnail" alt="{{.Name}}" loading="lazy">{{end}}
56 +
        <div class="admin-list-info">
57 +
          <span class="admin-list-title">{{.Name}}</span>
58 +
          <div class="admin-list-meta">
59 +
            <span class="admin-list-date">{{.SizeHuman}}</span>
60 +
            <span class="admin-list-date">{{.LastModified}}</span>
61 +
          </div>
62 +
        </div>
63 +
      </a>
64 +
    {{end}}
65 +
  </div>
66 +
67 +
  <script>
68 +
    (function () {
69 +
      var input = document.getElementById('filter');
70 +
      var list = document.getElementById('entry-list');
71 +
      var count = document.getElementById('filter-count');
72 +
      if (!input || !list) return;
73 +
      var items = list.querySelectorAll('.admin-list-item[data-name]');
74 +
      var total = items.length;
75 +
      function apply() {
76 +
        var q = input.value.trim().toLowerCase();
77 +
        var shown = 0;
78 +
        items.forEach(function (el) {
79 +
          var name = (el.getAttribute('data-name') || '').toLowerCase();
80 +
          var match = q === '' || name.indexOf(q) !== -1;
81 +
          el.style.display = match ? '' : 'none';
82 +
          if (match) shown++;
83 +
        });
84 +
        count.textContent = q ? shown + ' / ' + total : '';
85 +
      }
86 +
      input.addEventListener('input', apply);
87 +
    })();
88 +
  </script>
89 +
{{end}}
apps/blobs/templates/buckets.html (added) +24 −0
1 +
{{define "buckets.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}blobs — buckets{{end}}
3 +
{{define "content"}}
4 +
  <div class="admin-toolbar">
5 +
    <h2>buckets</h2>
6 +
  </div>
7 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
8 +
  {{if not .Buckets}}
9 +
    <p class="empty">no buckets visible to these credentials</p>
10 +
  {{else}}
11 +
    <div class="admin-list">
12 +
      {{range .Buckets}}
13 +
        <a href="/b/{{.Name}}/browse/" class="admin-list-item">
14 +
          <div class="admin-list-info">
15 +
            <span class="admin-list-title">{{.Name}}/</span>
16 +
            <div class="admin-list-meta">
17 +
              <span class="admin-list-date">{{.CreationDate}}</span>
18 +
            </div>
19 +
          </div>
20 +
        </a>
21 +
      {{end}}
22 +
    </div>
23 +
  {{end}}
24 +
{{end}}
apps/blobs/templates/detail.html (added) +69 −0
1 +
{{define "detail.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Name}}{{end}}
3 +
{{define "content"}}
4 +
  <nav class="crumbs">
5 +
    <a href="/buckets">buckets</a>
6 +
    {{range .Crumbs}}<span class="crumb-sep">/</span><a href="{{.Href}}">{{.Label}}</a>{{end}}
7 +
    <span class="crumb-sep">/</span>
8 +
    <span>{{.Name}}</span>
9 +
  </nav>
10 +
11 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
12 +
13 +
  <h2>{{.Name}}</h2>
14 +
15 +
  {{if .IsImage}}
16 +
    <div class="detail-preview">
17 +
      <img src="{{.PreviewSrc}}" alt="{{.Name}}">
18 +
    </div>
19 +
  {{end}}
20 +
21 +
  <dl class="detail-meta">
22 +
    <dt>key</dt><dd><code>{{.Key}}</code></dd>
23 +
    <dt>type</dt><dd>{{.ContentType}}</dd>
24 +
    <dt>size</dt><dd>{{.SizeHuman}} ({{.Size}} bytes)</dd>
25 +
    <dt>modified</dt><dd>{{.LastModified}}</dd>
26 +
    <dt>etag</dt><dd><code>{{.ETag}}</code></dd>
27 +
  </dl>
28 +
29 +
  <section class="detail-links">
30 +
    <h3>links</h3>
31 +
    {{if .PresignedURL}}
32 +
      <div class="link-row">
33 +
        <a href="{{.PresignedURL}}" target="_blank" rel="noopener">presigned download</a>
34 +
        <button type="button" class="link-button"
35 +
          onclick="navigator.clipboard.writeText('{{.PresignedURL}}');this.textContent='copied!'">
36 +
          copy
37 +
        </button>
38 +
      </div>
39 +
    {{end}}
40 +
    {{if .HasPublic}}
41 +
      <div class="link-row">
42 +
        <a href="{{.PublicURL}}" target="_blank" rel="noopener">public url</a>
43 +
        <button type="button" class="link-button"
44 +
          onclick="navigator.clipboard.writeText('{{.PublicURL}}');this.textContent='copied!'">
45 +
          copy
46 +
        </button>
47 +
      </div>
48 +
    {{else}}
49 +
      <p class="empty">no public url configured for this bucket (set BLOBS_PUBLIC_URLS)</p>
50 +
    {{end}}
51 +
  </section>
52 +
53 +
  <section class="detail-actions">
54 +
    <h3>actions</h3>
55 +
    <form method="POST" action="/b/{{.Bucket}}/replace" enctype="multipart/form-data" class="form">
56 +
      <input type="hidden" name="key" value="{{.Key}}">
57 +
      <label for="file">replace file (keeps same key)</label>
58 +
      <input type="file" id="file" name="file" required>
59 +
      <button type="submit">replace</button>
60 +
    </form>
61 +
62 +
    <form method="POST" action="/b/{{.Bucket}}/delete" class="form"
63 +
          onsubmit="return confirm('Delete {{.Name}}?');">
64 +
      <input type="hidden" name="key" value="{{.Key}}">
65 +
      <input type="hidden" name="returnTo" value="{{.ParentHref}}">
66 +
      <button type="submit" class="danger">delete</button>
67 +
    </form>
68 +
  </section>
69 +
{{end}}
apps/blobs/templates/login.html (added) +25 −0
1 +
{{define "login.html"}}<!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>blobs — login</title>
7 +
  <link rel="icon" href="/static/favicon.svg">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
10 +
  <link rel="stylesheet" href="/static/app.css">
11 +
</head>
12 +
<body>
13 +
  <header class="header">
14 +
    <span class="logo">BLOBS</span>
15 +
  </header>
16 +
  <main>
17 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
18 +
    <form method="POST" action="/login" class="form">
19 +
      <label for="password">password</label>
20 +
      <input type="password" id="password" name="password" autofocus required>
21 +
      <button type="submit">login</button>
22 +
    </form>
23 +
  </main>
24 +
</body>
25 +
</html>{{end}}
apps/blobs/util.go (added) +162 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/url"
5 +
	"path"
6 +
	"strings"
7 +
)
8 +
9 +
func isImageName(name string) bool {
10 +
	switch strings.ToLower(path.Ext(name)) {
11 +
	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp", ".ico":
12 +
		return true
13 +
	}
14 +
	return false
15 +
}
16 +
17 +
func humanSize(n int64) string {
18 +
	const k = 1024
19 +
	if n < k {
20 +
		return formatInt(n) + " B"
21 +
	}
22 +
	v := float64(n) / float64(k)
23 +
	if v < k {
24 +
		return formatFloat1(v) + " KB"
25 +
	}
26 +
	v /= k
27 +
	if v < k {
28 +
		return formatFloat1(v) + " MB"
29 +
	}
30 +
	v /= k
31 +
	return formatFloat1(v) + " GB"
32 +
}
33 +
34 +
func formatInt(n int64) string {
35 +
	if n == 0 {
36 +
		return "0"
37 +
	}
38 +
	neg := n < 0
39 +
	if neg {
40 +
		n = -n
41 +
	}
42 +
	buf := [24]byte{}
43 +
	i := len(buf)
44 +
	for n > 0 {
45 +
		i--
46 +
		buf[i] = byte('0' + n%10)
47 +
		n /= 10
48 +
	}
49 +
	if neg {
50 +
		i--
51 +
		buf[i] = '-'
52 +
	}
53 +
	return string(buf[i:])
54 +
}
55 +
56 +
func formatFloat1(v float64) string {
57 +
	scaled := int64(v*10 + 0.5)
58 +
	whole := scaled / 10
59 +
	frac := scaled % 10
60 +
	return formatInt(whole) + "." + string(byte('0'+frac))
61 +
}
62 +
63 +
// escapeKeyPath URL-escapes each path segment of an S3 key for use in app routes.
64 +
// Slashes are preserved as separators.
65 +
func escapeKeyPath(key string) string {
66 +
	parts := strings.Split(key, "/")
67 +
	for i, p := range parts {
68 +
		parts[i] = url.PathEscape(p)
69 +
	}
70 +
	return strings.Join(parts, "/")
71 +
}
72 +
73 +
// browseHref builds /b/{bucket}/browse/{prefix} (trailing slash preserved).
74 +
func browseHref(bucket, prefix string) string {
75 +
	if prefix == "" {
76 +
		return "/b/" + url.PathEscape(bucket) + "/browse/"
77 +
	}
78 +
	return "/b/" + url.PathEscape(bucket) + "/browse/" + escapeKeyPath(prefix)
79 +
}
80 +
81 +
func objectHref(bucket, key string) string {
82 +
	return "/b/" + url.PathEscape(bucket) + "/object/" + escapeKeyPath(key)
83 +
}
84 +
85 +
func previewHref(bucket, key string) string {
86 +
	return "/b/" + url.PathEscape(bucket) + "/preview/" + escapeKeyPath(key)
87 +
}
88 +
89 +
// buildCrumbs builds breadcrumb entries for the given bucket + prefix.
90 +
// The bucket crumb links to the bucket root; each segment links to its
91 +
// prefix browse URL.
92 +
func buildCrumbs(bucket, prefix string) []crumb {
93 +
	out := []crumb{{Label: bucket, Href: browseHref(bucket, "")}}
94 +
	if prefix == "" {
95 +
		return out
96 +
	}
97 +
	trimmed := strings.TrimSuffix(prefix, "/")
98 +
	parts := strings.Split(trimmed, "/")
99 +
	acc := ""
100 +
	for _, p := range parts {
101 +
		if p == "" {
102 +
			continue
103 +
		}
104 +
		acc += p + "/"
105 +
		out = append(out, crumb{Label: p, Href: browseHref(bucket, acc)})
106 +
	}
107 +
	return out
108 +
}
109 +
110 +
// parentPrefix returns the prefix one level up from the given prefix (which
111 +
// is expected to end in "/" or be empty). For object keys, use parentOfKey.
112 +
func parentPrefix(prefix string) string {
113 +
	trimmed := strings.TrimSuffix(prefix, "/")
114 +
	if trimmed == "" {
115 +
		return ""
116 +
	}
117 +
	i := strings.LastIndex(trimmed, "/")
118 +
	if i < 0 {
119 +
		return ""
120 +
	}
121 +
	return trimmed[:i+1]
122 +
}
123 +
124 +
func parentOfKey(key string) string {
125 +
	i := strings.LastIndex(key, "/")
126 +
	if i < 0 {
127 +
		return ""
128 +
	}
129 +
	return key[:i+1]
130 +
}
131 +
132 +
func nameOfKey(key string) string {
133 +
	i := strings.LastIndex(key, "/")
134 +
	if i < 0 {
135 +
		return key
136 +
	}
137 +
	return key[i+1:]
138 +
}
139 +
140 +
// parsePublicURLs parses a "bucket=url,bucket=url" string into a map.
141 +
func parsePublicURLs(raw string) map[string]string {
142 +
	out := map[string]string{}
143 +
	if strings.TrimSpace(raw) == "" {
144 +
		return out
145 +
	}
146 +
	for _, pair := range strings.Split(raw, ",") {
147 +
		pair = strings.TrimSpace(pair)
148 +
		if pair == "" {
149 +
			continue
150 +
		}
151 +
		eq := strings.IndexByte(pair, '=')
152 +
		if eq <= 0 {
153 +
			continue
154 +
		}
155 +
		k := strings.TrimSpace(pair[:eq])
156 +
		v := strings.TrimSpace(pair[eq+1:])
157 +
		if k != "" && v != "" {
158 +
			out[k] = v
159 +
		}
160 +
	}
161 +
	return out
162 +
}
docker-compose.yml +13 −0
96 96
      - easel_data:/data
97 97
    env_file: apps/easel/.env
98 98
99 +
  blobs:
100 +
    image: ghcr.io/stevedylandev/andromeda/blobs:latest
101 +
    restart: unless-stopped
102 +
    ports:
103 +
      - "3838:3000"
104 +
    volumes:
105 +
      - blobs_data:/data
106 +
    env_file: apps/blobs/.env
107 +
99 108
  backup:
100 109
    image: ghcr.io/stevedylandev/andromeda/backup:latest
101 110
    volumes:
107 116
      - library_data:/data/library:ro
108 117
      - bookmarks_data:/data/bookmarks:ro
109 118
      - easel_data:/data/easel:ro
119 +
      - blobs_data:/data/blobs:ro
110 120
    env_file: apps/backup/.env
111 121
    restart: unless-stopped
112 122
138 148
  easel_data:
139 149
    external: true
140 150
    name: easel_easel-data
151 +
  blobs_data:
152 +
    external: true
153 +
    name: blobs_blobs-data