feat: init blobs
ef7a5f7b
26 file(s) · +1739 −4
| 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 |
|
| 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 |
|
| 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= |
| 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" |
| 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"] |
| 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 | |
| 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 | + | } |
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 |
| 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 | + | } |
| 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> |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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 | + | } |
| 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 |
|