Compare commits

...
11 Commits
Author SHA1 Message Date
aneurinandClaude Sonnet 5 1964fda05e Add configurable Cache-Control headers for statics, files, and API
PR Checks / checks (pull_request) Successful in 1m40s
Build / build-and-push (push) Successful in 1m16s
Send our own Cache-Control headers instead of relying entirely on
front-proxy configuration, so browsers cache appropriately even when
Zampler is reached directly. --static-max-age, --file-max-age, and
--api-max-age (all cobra Duration flags, 0 disables caching) cover the
three response categories independently; --no-immutable turns off the
immutable directive on served files, which is on by default since a
file's content never changes for a given ID. The HTML shell always
gets no-cache regardless of --static-max-age, since it isn't
content-hashed like the rest of the Vite build and must keep
revalidating so a new deploy's asset references are picked up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 11:48:53 +01:00
aneurinandClaude Sonnet 5 60ecec609d Batch scan writes into transactions instead of one per file
Build / build-and-push (push) Successful in 6m18s
Previously each scanned file was its own autocommit transaction, so a
large library meant tens of thousands of individually journaled (and,
outside WAL mode, fsynced) commits back to back. Grouping them into
batches cuts that by ~2-3 orders of magnitude and shrinks the window
during which the scanner holds the write lock, part of the fix for #17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 09:42:13 +00:00
aneurinandClaude Sonnet 5 e9542dfda5 Harden DSN construction against an already-parameterized --sqlite path
PR Checks / checks (pull_request) Successful in 1m55s
Build / build-and-push (push) Successful in 6m52s
Address review feedback on !18: appending "?..." unconditionally broke
if the --sqlite value already carried its own query string, since the
driver treats the first "?" as the path/query boundary. Join with "&"
when one is already present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 10:33:19 +01:00
aneurinandClaude Sonnet 5 520bf1c186 Tune SQLite connection: WAL mode, busy_timeout, single writer
Build / build-and-push (push) Skipped
PR Checks / checks (pull_request) Successful in 9m14s
Fixes SQLITE_BUSY errors seen with a large library: the connection
previously used SQLite's default journal mode (which blocks readers
during a write) and a zero busy_timeout (any lock contention failed
immediately instead of retrying), while an uncapped connection pool
let multiple connections contend for the single writer lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 10:27:43 +01:00
aneurin a02e96ceec QOL patch: only show filenames
PR Checks / checks (pull_request) Successful in 1m15s
Build / build-and-push (push) Successful in 1m43s
Full directories in the real world are a lot longer than I realised. I will come up with a better way to show them. For now, filenames only, just so the page doesn't break
2026-09-08 12:08:10 +01:00
aneurinandClaude Sonnet 5 7a6e1aa6a8 fix middleware races, ReadFile error paths, and lib.Coalesce
PR Checks / checks (pull_request) Successful in 1m36s
Build / build-and-push (push) Successful in 1m13s
Closes #8, #9, #10; partially addresses #11.

middlewares (#8):
- Log: log once from the Middleware closure after the handler returns
  instead of spawning a goroutine per Write (data race on the shared
  LogWriter, multiple log lines per request). Bodiless responses
  (204/304/redirects) are now logged too, and the status-class switch has
  a default branch so a stray sub-100 status can't nil-deref the event.
- logWriter forwards http.Flusher / http.Hijacker / io.ReaderFrom so
  streaming handlers and the file server keep their fast paths.
- Both middlewares use the closure idiom rather than storing next on a
  shared instance, so they're safe to register on more than one chain.
- CorsOrigin now honours its argument: a comma-separated allow-list,
  with "*" meaning allow-any. Wired to a new --allow-origin flag that
  defaults to "*" (unchanged behaviour out of the box).

ReadFile (#9):
- Add the missing returns after GetFile / open failures (the old code
  fell through to os.ReadFile(nil path) and wrote a second body).
- Hand off to http.ServeContent for Content-Type, Range and
  If-Modified-Since, and set Content-Disposition: inline so the embedded
  audio player streams instead of downloading (the download button
  already uses the HTML download attribute).

lib.Coalesce (#10):
- Constrain to comparable and compare against a fresh zero value. The
  old IsZero type switch compared an any against untyped constants, so
  every narrow numeric type (int8..int64, uint*, float32) reported
  non-zero and never fell back. IsZero is removed (was unused elsewhere).

tests (#11, high-value/easy set):
- lib.Coalesce table across every numeric width, string, bool, pointer.
- database.Migrate against a temp sqlite: schema created, recorded once,
  second call is a no-op.
- types.NewFile: id = sha256(path), hash = sha256(contents), scan fields
  copied, and the missing-file path still yields an id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:22:00 +01:00
claudeandClaude Sonnet 5 800404203a replace archived code.aneur.in/go/* dependencies
PR Checks / checks (pull_request) Successful in 1m43s
Build / build-and-push (push) Successful in 1m8s
Everything under code.aneur.in/go/* except validate was archived. Swap
each for its replacement; validate stays.

- rest -> internal/rest: the JSON response/error helpers zampler actually
  uses (~110 lines), same {"message","data"} wire shape and status codes.
- sqan -> internal/scan: a filepath.WalkDir walk. zampler only filtered
  by extension; sqan's path semantics (absolute root, rooted RelativePath,
  "/" RelativeDir for root-level files) are kept.
- sqimple + sqaffold query building -> github.com/doug-martin/goqu/v9,
  which also does the row scanning that record + sqaffold provided. A
  fileRow struct with db tags maps to types.File (millis <-> time.Time).
- sqaffold.Migrate + version.Migration -> a ~40-line migrator in
  internal/database/migration.go tracking applied names in a
  schema_migrations table. goose was disproportionate for one migration.
- version -> gone (only its Migration helper was used).

Net dependency change: -6 code.aneur.in/go modules, +1 (goqu).

go build / go vet / go test ./... green; manual run verified migration,
scan+insert, list/get/search/sort/paging, 404 and 400 shapes, and
rescan idempotency (on conflict replace).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:00:45 +01:00
claudeandClaude Sonnet 5 55d14d3193 serve /robots.txt with opt-in search indexing
Build / build-and-push (push) Successful in 6m34s
Adds a --allow-indexing / -i flag (default false). The server now handles
GET /robots.txt itself instead of relying on a static file:

  - default: block every crawler (User-agent: * / Disallow: /)
  - --allow-indexing: permissive (Disallow: empty)

Generating it in the handler means the policy is set per host at run time.
Search indexing is opt-in, as requested on the issue.

Closes #5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:42:26 +00:00
claudeandClaude Sonnet 5 2a79eecbff cli: read the bind address from the right flag
PR Checks / checks (pull_request) Successful in 2m57s
Build / build-and-push (push) Successful in 6m4s
RunE looked up flags.GetString("host"), but the flag is registered as
"address" (-a), so --address was silently ignored and the server always
bound to all interfaces. Read "address" instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:36:58 +01:00
aneurin 7f57e25dcf Build a multi-arch (amd64 + arm64) image (!4)
Build / build-and-push (push) Successful in 6m7s
Co-authored-by: Aneurin Barker Snook <aneurin@aneur.in>
2026-09-07 00:42:58 +00:00
aneurinandClaude Sonnet 5 2c288ca1e2 add PR checks workflow
PR Checks / checks (pull_request) Successful in 2m14s
Build / build-and-push (push) Successful in 1m26s
Runs on pull requests to main: frontend type-check and build, then
gofmt, go mod tidy verification, go vet, go build, and go test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:27:22 +01:00
28 changed files with 1120 additions and 372 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
# Build context only needs the prebuilt ./zampler binary and the Dockerfile.
# Build context only needs the prebuilt per-arch binaries and the Dockerfile.
*
!zampler
!dist/zampler-linux-amd64
!dist/zampler-linux-arm64
+23 -11
View File
@@ -12,9 +12,10 @@ jobs:
image: golang:1.26-alpine
env:
CGO_ENABLED: "0"
IMAGE: code.aneur.in/${{ gitea.repository }}
steps:
- name: Install toolchain deps
run: apk add --no-cache nodejs npm docker-cli git tar
run: apk add --no-cache nodejs npm docker-cli docker-cli-buildx git tar
- uses: actions/checkout@v4
@@ -42,8 +43,17 @@ jobs:
npm ci
npx vite build --outDir ../internal/server/static --emptyOutDir
- name: Build binary
run: go build -ldflags="-s -w" -o zampler .
- name: Cross-compile binaries
run: |
for arch in amd64 arm64; do
GOOS=linux GOARCH="$arch" \
go build -ldflags="-s -w" -o "dist/zampler-linux-$arch" .
done
- name: Set up Docker buildx
run: |
docker buildx rm multi 2>/dev/null || true
docker buildx create --name multi --driver docker-container --use
- name: Log in to the container registry
env:
@@ -52,14 +62,16 @@ jobs:
- name: Promote current "latest" to "previous"
run: |
IMAGE="code.aneur.in/${{ gitea.repository }}"
if docker pull "$IMAGE:latest"; then
docker tag "$IMAGE:latest" "$IMAGE:previous"
docker push "$IMAGE:previous"
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
docker buildx imagetools create -t "$IMAGE:previous" "$IMAGE:latest"
else
echo "No existing :latest to promote; skipping."
fi
- name: Build and push "latest"
- name: Build and push multi-arch "latest"
run: |
IMAGE="code.aneur.in/${{ gitea.repository }}:latest"
docker build -t "$IMAGE" .
docker push "$IMAGE"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag "$IMAGE:latest" \
--push \
.
+70
View File
@@ -0,0 +1,70 @@
name: PR Checks
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
checks:
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
env:
CGO_ENABLED: "0"
steps:
- name: Install toolchain deps
run: apk add --no-cache nodejs npm git tar
- uses: actions/checkout@v4
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-
- name: Cache Go modules and build cache
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go') }}
restore-keys: |
go-${{ hashFiles('go.sum') }}-
go-
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Type-check and build frontend
working-directory: frontend
run: |
npx vue-tsc -b
npx vite build --outDir ../internal/server/static --emptyOutDir
- name: Check Go formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-formatted:"
echo "$unformatted"
exit 1
fi
- name: Verify go.mod is tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test ./...
+1
View File
@@ -4,3 +4,4 @@
zampler.files
zampler.sqlite3
internal/server/static
dist
+9 -5
View File
@@ -1,12 +1,16 @@
# The zampler binary is built in CI (.gitea/workflows/build.yml) so the compile
# can use a persistent Go module + build cache. This image is just the runtime
# wrapper around the prebuilt static binary.
# The zampler binaries are cross-compiled in CI (.gitea/workflows/build.yml) so
# the compile can use a persistent Go module + build cache and avoid emulation.
# This image is just the runtime wrapper around the prebuilt static binary;
# buildx sets TARGETARCH per platform and we copy the matching binary.
#
# For a local build: `go build -o zampler .` first, then `docker build .`.
# For a local build:
# GOOS=linux GOARCH=amd64 go build -o dist/zampler-linux-amd64 .
# docker build --build-arg TARGETARCH=amd64 .
FROM alpine:3.24
COPY zampler /usr/local/bin/zampler
ARG TARGETARCH
COPY dist/zampler-linux-${TARGETARCH} /usr/local/bin/zampler
ENTRYPOINT ["zampler"]
CMD ["-s", "/db/zampler.sqlite3", "/data"]
+3 -2
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
const props = defineProps<{
file: dto.File
file: dto.File,
full?: boolean
}>()
</script>
<template>
<span class="file-path">
<span class="dirname">{{ file.relativeDir }}</span>
<span v-if="full" class="dirname">{{ file.relativeDir }}</span>
<span class="filename">{{ file.filename }}</span>
</span>
</template>
+2 -7
View File
@@ -3,13 +3,8 @@ module code.aneur.in/zampler/zampler
go 1.25.7
require (
code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4
code.aneur.in/go/rest v0.0.0-20260710144250-d3e2b415e4ac
code.aneur.in/go/sqaffold v0.0.0-20260901002927-0d73340982a0
code.aneur.in/go/sqan v0.0.0-20260831231908-19f540e808b6
code.aneur.in/go/sqimple v0.0.0-20260710160754-2a04163d6eff
code.aneur.in/go/validate v0.0.0-20260705223845-8fab67ff77fe
code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9
github.com/doug-martin/goqu/v9 v9.19.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/rs/zerolog v1.35.1
@@ -19,13 +14,13 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gobwas/glob v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+24 -20
View File
@@ -1,28 +1,18 @@
code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4 h1:cE8tCjhHePkEMBEQbkuTy/NQ5DlAoDwRPQERqcZK5DM=
code.aneur.in/go/record v0.0.0-20260704022026-db3f74ccdeb4/go.mod h1:nIqf1d1YWOPSVrbBFJY2ZBEMMFSfQCgXL7nFVXOzVqo=
code.aneur.in/go/rest v0.0.0-20260710144250-d3e2b415e4ac h1:rmdYhq+CsQzPqK/tQ6G/DtWIS+IIME47Draei/mR9BM=
code.aneur.in/go/rest v0.0.0-20260710144250-d3e2b415e4ac/go.mod h1:HtXodmPBkxQyYkXn4UHURJEs44a+Jm+4tUaKXj246uE=
code.aneur.in/go/sqaffold v0.0.0-20260901002927-0d73340982a0 h1:WzuDzWiq8A/9VR7jRT8m58H7sn1BpW79+L+KygN4QWE=
code.aneur.in/go/sqaffold v0.0.0-20260901002927-0d73340982a0/go.mod h1:00LMGxPUE9cMpMzW/M2DZ6FNTVpn7Eh1jak2mgfI08g=
code.aneur.in/go/sqan v0.0.0-20260831231908-19f540e808b6 h1:zuxg66WgJpxH0dHemFJasJ2i82jnSxL8c3FJdOwQInA=
code.aneur.in/go/sqan v0.0.0-20260831231908-19f540e808b6/go.mod h1:0o6TJqTFx7qPLHiUtUxeN2cLR/xylxtJZyghXamEfy4=
code.aneur.in/go/sqimple v0.0.0-20260710160754-2a04163d6eff h1:DAZPseBNreEYH4XQG087NfQ+PpHIlpYVThRXKmRkupc=
code.aneur.in/go/sqimple v0.0.0-20260710160754-2a04163d6eff/go.mod h1:WT64312MyLtL4EiJWRyBFh+Tl//ls2+B+YsFV+BkPJ0=
code.aneur.in/go/validate v0.0.0-20260705223845-8fab67ff77fe h1:jGSnKHtPM6mmcBjucsqduISqzhnPwZ7s1pTA6D7RWxg=
code.aneur.in/go/validate v0.0.0-20260705223845-8fab67ff77fe/go.mod h1:xGuqmQj25KOUB+vD2L3Z+EDmdhZZB8ZRJK4Csj+LW6U=
code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9 h1:OquK0VYWHUts7u/23A6IiZ0PkznhIihj9U0DBSXfeM0=
code.aneur.in/go/version v0.0.0-20260706210436-9f229dde97c9/go.mod h1:G26bUe3Jroua1UDRzJO12ssn6OPcTsizffKJ+5/FK7U=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -31,14 +21,16 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -53,18 +45,30 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
+3 -4
View File
@@ -4,22 +4,21 @@ import (
"net/http"
"strconv"
"code.aneur.in/go/rest"
"code.aneur.in/zampler/zampler/internal/database"
"code.aneur.in/zampler/zampler/internal/rest"
"github.com/gorilla/mux"
)
func (api *API) GetFile(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
id := vars["id"]
r, err := api.db.Select("files").Where("id = ?", id).QueryRecord()
file, err := api.db.GetFile(vars["id"])
if err != nil {
rest.WriteErrorJSON(w, err)
return
}
rest.WriteResponseJSON(w, http.StatusOK, database.FileFromRecord(r))
rest.WriteResponseJSON(w, http.StatusOK, file)
}
func (api *API) ListFiles(w http.ResponseWriter, req *http.Request) {
+68 -13
View File
@@ -3,13 +3,16 @@ package cli
import (
"context"
"database/sql"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"code.aneur.in/go/sqan"
"code.aneur.in/zampler/zampler/internal/api"
"code.aneur.in/zampler/zampler/internal/database"
"code.aneur.in/zampler/zampler/internal/scan"
"code.aneur.in/zampler/zampler/internal/server"
"code.aneur.in/zampler/zampler/internal/server/middlewares"
"code.aneur.in/zampler/zampler/internal/types"
@@ -39,12 +42,21 @@ func (cli *CLI) Cmd() *cobra.Command {
f.IntP("port", "p", 7777, "HTTP listen port")
f.StringP("url", "u", "", "frontend URL")
f.String("allow-origin", "*", "comma-separated CORS allow-list of origins, or * for any")
f.StringP("sqlite", "s", "zampler.sqlite3", "path to SQLite database")
f.StringP("log-level", "l", "info", "logging level (trace, debug, info, warn, error)")
f.BoolP("no-scan", "n", false, "disable root directory scanner (only use database)")
f.BoolP("allow-indexing", "i", false, "let search engines index the site (serves a permissive /robots.txt); default blocks all crawlers")
f.Duration("static-max-age", 30*24*time.Hour, "Cache-Control max-age for static frontend assets (0 disables caching)")
f.Duration("file-max-age", 30*24*time.Hour, "Cache-Control max-age for served audio files (0 disables caching)")
f.Bool("no-immutable", false, "omit the Cache-Control immutable directive on served audio files (immutable by default: a file's content never changes for a given ID)")
f.Duration("api-max-age", time.Hour, "Cache-Control max-age for API responses (0 disables caching)")
return cmd
}
@@ -66,65 +78,108 @@ func (cli *CLI) RunE(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
dbFile, _ := flags.GetString("sqlite")
host, _ := flags.GetString("host")
address, _ := flags.GetString("address")
port, _ := flags.GetInt("port")
url, _ := flags.GetString("url")
allowOrigin, _ := flags.GetString("allow-origin")
noScan, _ := flags.GetBool("no-scan")
allowIndexing, _ := flags.GetBool("allow-indexing")
staticMaxAge, _ := flags.GetDuration("static-max-age")
fileMaxAge, _ := flags.GetDuration("file-max-age")
noImmutable, _ := flags.GetBool("no-immutable")
apiMaxAge, _ := flags.GetDuration("api-max-age")
rootDir := args[0]
// Init database
conn, err := sql.Open("sqlite", dbFile)
// Init database. WAL lets reads proceed while a write is in progress
// (rather than the default journal mode, which blocks readers), and
// busy_timeout makes SQLite retry for a bit on lock contention instead
// of failing immediately with SQLITE_BUSY. synchronous=NORMAL is safe
// under WAL and avoids an fsync per commit, which matters on the slow
// storage this typically runs on (e.g. a Raspberry Pi's SD card).
// --sqlite may already carry its own DSN query string (e.g. a user
// passing extra pragmas), so join with "&" in that case rather than
// starting a second "?", which the driver would otherwise fold into
// the path.
sep := "?"
if strings.Contains(dbFile, "?") {
sep = "&"
}
dsn := fmt.Sprintf("%s%s_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000", dbFile, sep)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return err
}
// SQLite allows only one writer at a time; capping the pool to a single
// connection avoids the pool opening concurrent connections that would
// otherwise contend for that same lock.
conn.SetMaxOpenConns(1)
cli.db = database.New(ctx, cli.log, conn)
if err := cli.db.Migrate(); err != nil {
return err
}
router := mux.NewRouter()
router.Use(middlewares.NewCorsOrigin("*").Middleware)
router.Use(middlewares.NewCorsOrigin(allowOrigin).Middleware)
router.Use(mux.CORSMethodMiddleware(router))
router.Use(middlewares.NewLog(cli.log).Middleware)
// Set up standard HTTP endpoints
srv := server.NewServer(cli.db, cli.log, url)
srv := server.NewServer(cli.db, cli.log, url, allowIndexing, int(fileMaxAge.Seconds()), !noImmutable)
srv.ConfigureRouter(router)
// Set up API endpoints
api := api.NewAPI(cli.db, cli.log)
apiRouter := router.PathPrefix("/api/").Subrouter()
apiRouter.Use(middlewares.NewCacheControl(int(apiMaxAge.Seconds()), false).Middleware)
api.ConfigureRouter(apiRouter)
// Set up static assets
srv.ConfigureStaticAssets(router)
srv.ConfigureStaticAssets(router, int(staticMaxAge.Seconds()))
errch := make(chan error)
// Start HTTP server
go func() {
addr := net.JoinHostPort(host, strconv.Itoa(port))
addr := net.JoinHostPort(address, strconv.Itoa(port))
errch <- http.ListenAndServe(addr, router)
}()
// Initial scan of root directories
// Initial scan of root directories. Files are saved in batches rather
// than one at a time, so a scan of a large library doesn't turn into
// one commit per file.
if !noScan {
go func() {
scanner := sqan.NewScanner(rootDir).WithExtensions("aac", "flac", "m4a", "mp3", "ogg", "wav")
const scanBatchSize = 500
scanner := scan.New(rootDir).WithExtensions("aac", "flac", "m4a", "mp3", "ogg", "wav")
batch := make([]*types.File, 0, scanBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
if err := cli.db.UpdateFiles(batch); err != nil {
cli.log.Err(err).Msg("Failed to save scanned files")
}
batch = batch[:0]
}
for simpleFile := range scanner.Scan() {
file, err := types.NewFile(simpleFile)
if err != nil {
cli.log.Err(err).Send()
continue
}
if err := cli.db.UpdateFile(file); err != nil {
cli.log.Err(err).Send()
continue
batch = append(batch, file)
if len(batch) >= scanBatchSize {
flush()
}
cli.log.Trace().Msgf("Scanned %s", file.RelativePath)
}
flush()
}()
}
+5 -41
View File
@@ -4,9 +4,8 @@ import (
"context"
"database/sql"
"code.aneur.in/go/record"
"code.aneur.in/go/sqaffold"
"code.aneur.in/go/sqimple"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/rs/zerolog"
)
@@ -15,49 +14,14 @@ type DB struct {
log zerolog.Logger
conn *sql.DB
scaffold *sqaffold.Scaffold
}
func (db *DB) Delete(from string) *sqimple.DeleteStatement {
return db.scaffold.Delete(from)
}
func (db *DB) Exec(query string, args ...any) (sql.Result, error) {
return db.scaffold.Exec(query, args...)
}
func (db *DB) Insert(into string) *sqimple.InsertStatement {
return db.scaffold.Insert(into)
}
func (db *DB) Migrate() error {
return db.scaffold.Migrate()
}
func (db *DB) Query(query string, args ...any) ([]record.Record, error) {
return db.scaffold.Query(query, args...)
}
func (db *DB) QueryOne(query string, args ...any) (record.Record, error) {
return db.scaffold.QueryOne(query, args...)
}
func (db *DB) Select(from string) *sqimple.SelectQuery {
return db.scaffold.Select(from)
}
func (db *DB) Update(table string) *sqimple.UpdateStatement {
return db.scaffold.Update(table)
g *goqu.Database
}
func New(ctx context.Context, log zerolog.Logger, conn *sql.DB) *DB {
db := &DB{
return &DB{
ctx: ctx,
log: log,
conn: conn,
g: goqu.New("sqlite3", conn),
}
db.scaffold = sqaffold.NewScaffold(db.ctx, conn).WithApp("zampler", db.migration())
return db
}
+40 -30
View File
@@ -3,40 +3,50 @@ package database
import (
"time"
"code.aneur.in/go/record"
"code.aneur.in/zampler/zampler/internal/types"
)
func FileFromRecord(r record.Record) *types.File {
f := &types.File{
ID: r.String("id"),
Hash: r.String("hash"),
RootDir: r.String("root_dir"),
AbsolutePath: r.String("abs_path"),
RelativeDir: r.String("rel_dir"),
RelativePath: r.String("rel_path"),
Filename: r.String("filename"),
Extension: r.String("ext"),
Size: r.Int64("size"),
Modified: time.UnixMilli(r.Int64("modified")),
}
return f
// fileRow is the files table shape for goqu scanning. modified is stored
// as Unix milliseconds; types.File exposes it as a time.Time.
type fileRow struct {
ID string `db:"id"`
Hash string `db:"hash"`
RootDir string `db:"root_dir"`
AbsolutePath string `db:"abs_path"`
RelativeDir string `db:"rel_dir"`
RelativePath string `db:"rel_path"`
Filename string `db:"filename"`
Extension string `db:"ext"`
Size int64 `db:"size"`
Modified int64 `db:"modified"`
}
func FileToRecord(f *types.File) record.Record {
r := record.Record{
"id": f.ID,
"hash": f.Hash,
"root_dir": f.RootDir,
"abs_path": f.AbsolutePath,
"rel_dir": f.RelativeDir,
"rel_path": f.RelativePath,
"filename": f.Filename,
"ext": f.Extension,
"size": f.Size,
"modified": f.Modified.UnixMilli(),
func (r fileRow) toFile() *types.File {
return &types.File{
ID: r.ID,
Hash: r.Hash,
RootDir: r.RootDir,
AbsolutePath: r.AbsolutePath,
RelativeDir: r.RelativeDir,
RelativePath: r.RelativePath,
Filename: r.Filename,
Extension: r.Extension,
Size: r.Size,
Modified: time.UnixMilli(r.Modified),
}
}
func fileRowFromFile(f *types.File) fileRow {
return fileRow{
ID: f.ID,
Hash: f.Hash,
RootDir: f.RootDir,
AbsolutePath: f.AbsolutePath,
RelativeDir: f.RelativeDir,
RelativePath: f.RelativePath,
Filename: f.Filename,
Extension: f.Extension,
Size: f.Size,
Modified: f.Modified.UnixMilli(),
}
return r
}
+60 -45
View File
@@ -3,11 +3,11 @@ package database
import (
"fmt"
"code.aneur.in/go/rest"
"code.aneur.in/go/sqimple"
"code.aneur.in/go/validate"
"code.aneur.in/zampler/zampler/internal/lib"
"code.aneur.in/zampler/zampler/internal/rest"
"code.aneur.in/zampler/zampler/internal/types"
"github.com/doug-martin/goqu/v9"
)
var (
@@ -29,12 +29,16 @@ type SearchFilesInput struct {
}
func (db *DB) GetFile(id string) (*types.File, error) {
r, err := db.Select("files").Where("id = ?", id).QueryRecord()
var row fileRow
found, err := db.g.From("files").Where(goqu.Ex{"id": id}).ScanStructContext(db.ctx, &row)
if err != nil {
return nil, rest.ErrNotFound.WithError(err)
return nil, err
}
if !found {
return nil, rest.ErrNotFound.WithError(fmt.Errorf("no file with id %q", id))
}
return FileFromRecord(r), nil
return row.toFile(), nil
}
func (db *DB) SearchFiles(input *SearchFilesInput) (*List[*types.File], error) {
@@ -52,49 +56,44 @@ func (db *DB) SearchFiles(input *SearchFilesInput) (*List[*types.File], error) {
return nil, rest.ErrBadRequest.WithError(err).WithValue("param", "limit")
}
// Total count query
countQuery := db.Select("files").Columns("count(*)")
if input.Filename != "" {
countQuery.Where("filename like ?", fmt.Sprintf("%%%s%%", input.Filename))
} else if input.Path != "" {
countQuery.Where("rel_path like ?", fmt.Sprintf("%%%s%%", input.Path))
}
countRow := countQuery.QueryRow()
if err := countRow.Err(); err != nil {
return nil, err
ds := db.g.From("files")
switch {
case input.Filename != "":
ds = ds.Where(goqu.C("filename").Like("%" + input.Filename + "%"))
case input.Path != "":
ds = ds.Where(goqu.C("rel_path").Like("%" + input.Path + "%"))
}
total := 0
countRow.Scan(&total)
// Actual query
query := db.Select("files")
if input.Filename != "" {
query.Where("filename like ?", fmt.Sprintf("%%%s%%", input.Filename))
} else if input.Path != "" {
query.Where("rel_path like ?", fmt.Sprintf("%%%s%%", input.Path))
}
orderBy := fmt.Sprintf("%s %s", lib.Coalesce(input.Sort, "rel_path"), lib.Coalesce(input.Direction, "asc"))
query.OrderBy(orderBy)
query.Limit(input.Limit)
if input.Offset > 0 {
query.Offset(input.Offset)
}
records, err := query.QueryRecords()
total, err := ds.CountContext(db.ctx)
if err != nil {
return nil, err
}
files := []*types.File{}
for _, record := range records {
files = append(files, FileFromRecord(record))
// A zero limit means "no rows" (as LIMIT 0 did); skip the query.
if input.Limit > 0 {
sortColumn := lib.Coalesce(input.Sort, "rel_path")
order := goqu.C(sortColumn).Asc()
if lib.Coalesce(input.Direction, "asc") == "desc" {
order = goqu.C(sortColumn).Desc()
}
query := ds.Order(order).Limit(uint(input.Limit))
if input.Offset > 0 {
query = query.Offset(uint(input.Offset))
}
var rows []fileRow
if err := query.ScanStructsContext(db.ctx, &rows); err != nil {
return nil, err
}
for _, row := range rows {
files = append(files, row.toFile())
}
}
l := NewList(files...).
WithTotalCount(total).
WithTotalCount(int(total)).
WithSort(input.Sort).
WithOffset(input.Offset).
WithLimit(input.Limit)
@@ -102,13 +101,29 @@ func (db *DB) SearchFiles(input *SearchFilesInput) (*List[*types.File], error) {
return l, nil
}
func (db *DB) UpdateFile(file *types.File) error {
r := FileToRecord(file)
// UpdateFiles upserts files in a single transaction, rather than one
// transaction per file. Callers doing a large scan should batch calls to
// this rather than calling it once per file, since each call commits (and,
// outside WAL mode, fsyncs) once regardless of how many files it contains.
func (db *DB) UpdateFiles(files []*types.File) error {
if len(files) == 0 {
return nil
}
_, err := db.Insert("files").
Columns("id", "hash", "root_dir", "abs_path", "rel_dir", "rel_path", "filename", "ext", "size", "modified").
Values(sqimple.Values(r)).
Exec()
rows := make([]any, len(files))
for i, f := range files {
rows[i] = fileRowFromFile(f)
}
return err
tx, err := db.g.BeginTx(db.ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Insert("files").Rows(rows...).Executor().ExecContext(db.ctx); err != nil {
return err
}
return tx.Commit()
}
+60 -26
View File
@@ -1,33 +1,67 @@
package database
import "code.aneur.in/go/version"
import (
"fmt"
"time"
)
func (db *DB) migration() version.Migration {
return version.Migration{
"0.1.0": version.Patch{
Up: func() error {
_, err := db.Exec(`
create table files (
id text not null,
hash text,
root_dir text not null,
abs_path text not null,
rel_dir text not null,
rel_path text not null,
filename text not null,
ext text not null,
size integer not null,
modified integer not null,
constraint samples__unq__id unique (id) on conflict replace
)`)
// migration is one forward schema change, applied once and then recorded
// in schema_migrations by name.
type migration struct {
name string
sql string
}
return err
},
var migrations = []migration{
{
name: "0.1.0_create_files",
sql: `create table files (
id text not null,
hash text,
root_dir text not null,
abs_path text not null,
rel_dir text not null,
rel_path text not null,
filename text not null,
ext text not null,
size integer not null,
modified integer not null,
constraint samples__unq__id unique (id) on conflict replace
)`,
},
}
Down: func() error {
_, err := db.Exec("drop table files")
return err
},
},
// Migrate applies any migrations not yet recorded in schema_migrations, in
// order. It is safe to call on every start.
func (db *DB) Migrate() error {
if _, err := db.conn.ExecContext(db.ctx, `create table if not exists schema_migrations (
name text not null primary key,
applied_at integer not null
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, m := range migrations {
var applied int
if err := db.conn.QueryRowContext(db.ctx,
`select count(*) from schema_migrations where name = ?`, m.name).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", m.name, err)
}
if applied > 0 {
continue
}
if _, err := db.conn.ExecContext(db.ctx, m.sql); err != nil {
return fmt.Errorf("apply migration %s: %w", m.name, err)
}
if _, err := db.conn.ExecContext(db.ctx,
`insert into schema_migrations (name, applied_at) values (?, ?)`,
m.name, time.Now().UnixMilli()); err != nil {
return fmt.Errorf("record migration %s: %w", m.name, err)
}
db.log.Info().Str("migration", m.name).Msg("Applied database migration")
}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
package database
import (
"context"
"database/sql"
"path/filepath"
"testing"
"github.com/rs/zerolog"
_ "modernc.org/sqlite"
)
func testDB(t *testing.T) *DB {
t.Helper()
conn, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.sqlite3"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { conn.Close() })
return New(context.Background(), zerolog.Nop(), conn)
}
func TestMigrate(t *testing.T) {
db := testDB(t)
if err := db.Migrate(); err != nil {
t.Fatalf("first Migrate: %v", err)
}
// The files table exists and is queryable.
var n int
if err := db.conn.QueryRow(`select count(*) from files`).Scan(&n); err != nil {
t.Fatalf("query files after migrate: %v", err)
}
// The migration is recorded exactly once.
if err := db.conn.QueryRow(
`select count(*) from schema_migrations where name = ?`, "0.1.0_create_files",
).Scan(&n); err != nil {
t.Fatalf("query schema_migrations: %v", err)
}
if n != 1 {
t.Fatalf("schema_migrations has %d rows for 0.1.0_create_files, want 1", n)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
db := testDB(t)
if err := db.Migrate(); err != nil {
t.Fatalf("first Migrate: %v", err)
}
// A second call must be a no-op: re-running "create table files" would
// error, so a clean return proves the already-applied migration is skipped.
if err := db.Migrate(); err != nil {
t.Fatalf("second Migrate: %v", err)
}
var n int
if err := db.conn.QueryRow(`select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatalf("query schema_migrations: %v", err)
}
if n != len(migrations) {
t.Fatalf("schema_migrations has %d rows, want %d", n, len(migrations))
}
}
+8 -37
View File
@@ -1,44 +1,15 @@
package lib
func Coalesce[T any](value, fallback T) T {
if IsZero(value) {
// Coalesce returns value unless it is the zero value for its type, in which
// case it returns fallback. T is constrained to comparable so the zero check
// is a plain == against a fresh zero value, which is correct for every
// numeric width, string, bool and pointer (the old reflect-free type switch
// silently missed int8..int64, uint* and float32).
func Coalesce[T comparable](value, fallback T) T {
var zero T
if value == zero {
return fallback
}
return value
}
func IsZero(value any) bool {
switch value.(type) {
case bool:
return value == false
case float32:
return value == 0.0
case float64:
return value == 0.0
case int:
return value == 0
case int8:
return value == 0
case int16:
return value == 0
case int32:
return value == 0
case int64:
return value == 0
case uint:
return value == 0
case uint8:
return value == 0
case uint16:
return value == 0
case uint32:
return value == 0
case uint64:
return value == 0
case string:
return value == ""
default:
return value == nil
}
}
+37
View File
@@ -0,0 +1,37 @@
package lib
import "testing"
// check exercises Coalesce for one concrete type: a zero value must fall back,
// a non-zero value must pass through.
func check[T comparable](t *testing.T, name string, zero, nonZero, fallback T) {
t.Helper()
if got := Coalesce(zero, fallback); got != fallback {
t.Errorf("%s: Coalesce(zero, fallback) = %v, want fallback %v", name, got, fallback)
}
if got := Coalesce(nonZero, fallback); got != nonZero {
t.Errorf("%s: Coalesce(nonZero, fallback) = %v, want %v", name, got, nonZero)
}
}
func TestCoalesce(t *testing.T) {
check(t, "int", 0, 7, 42)
check(t, "int8", int8(0), int8(7), int8(42))
check(t, "int16", int16(0), int16(7), int16(42))
check(t, "int32", int32(0), int32(7), int32(42))
check(t, "int64", int64(0), int64(7), int64(42))
check(t, "uint", uint(0), uint(7), uint(42))
check(t, "uint8", uint8(0), uint8(7), uint8(42))
check(t, "uint16", uint16(0), uint16(7), uint16(42))
check(t, "uint32", uint32(0), uint32(7), uint32(42))
check(t, "uint64", uint64(0), uint64(7), uint64(42))
check(t, "float32", float32(0), float32(1.5), float32(42))
check(t, "float64", float64(0), float64(1.5), float64(42))
check(t, "string", "", "value", "fallback")
check(t, "bool", false, true, true)
// Pointer: nil falls back, non-nil passes through.
a, b := 1, 2
check(t, "pointer", (*int)(nil), &a, &b)
}
+111
View File
@@ -0,0 +1,111 @@
// Package rest holds the small JSON response and error helpers the HTTP
// handlers share. It replaces the archived code.aneur.in/go/rest with just
// the pieces zampler uses, keeping the same {"message", "data"} wire shape.
package rest
import (
"encoding/json"
"errors"
"net/http"
)
// REST API errors. Err matches any error produced here (errors.Is).
var (
Err = Error{}
ErrBadRequest = NewError(http.StatusBadRequest, "")
ErrNotFound = NewError(http.StatusNotFound, "")
ErrInternalServerError = NewError(http.StatusInternalServerError, "")
)
// Error is a JSON-serialisable API error with an HTTP status code and
// optional structured data.
type Error struct {
StatusCode int `json:"-"`
Message string `json:"message"`
Data map[string]any `json:"data,omitempty"`
}
func (e Error) Error() string { return e.Message }
// Is reports whether target is an Error with the same status code, or an
// empty Error (which matches any).
func (e Error) Is(target error) bool {
t, ok := target.(Error)
if !ok {
return false
}
return t.StatusCode == e.StatusCode || t.StatusCode == 0
}
// WithData returns a copy with data merged in.
func (e Error) WithData(data map[string]any) Error {
merged := map[string]any{}
for k, v := range e.Data {
merged[k] = v
}
for k, v := range data {
merged[k] = v
}
e.Data = merged
return e
}
// WithError returns a copy with err as the message if there is none yet,
// otherwise attached under data.error.
func (e Error) WithError(err error) Error {
if e.Message == "" {
return e.WithMessage(err.Error())
}
return e.WithData(map[string]any{"error": err.Error()})
}
// WithMessage returns a copy with the given message.
func (e Error) WithMessage(message string) Error {
e.Message = message
return e
}
// WithValue returns a copy with a single data value added.
func (e Error) WithValue(name string, value any) Error {
return e.WithData(map[string]any{name: value})
}
// WriteJSON writes the error to the response as JSON.
func (e Error) WriteJSON(w http.ResponseWriter) error {
if e.StatusCode == 0 {
e.StatusCode = http.StatusOK
}
return WriteResponseJSON(w, e.StatusCode, e)
}
// NewError creates an Error, defaulting the message to the standard status
// text when empty.
func NewError(statusCode int, message string) Error {
if message == "" {
message = http.StatusText(statusCode)
}
return Error{StatusCode: statusCode, Message: message}
}
// WriteResponseJSON marshals data and writes it with the given status code.
func WriteResponseJSON(w http.ResponseWriter, statusCode int, data any) error {
b, err := json.Marshal(data)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, err = w.Write(b)
return err
}
// WriteErrorJSON writes err as JSON. An Error is written as-is; anything
// else is wrapped as a 500 with the message under data.error.
func WriteErrorJSON(w http.ResponseWriter, err error) error {
var e Error
if errors.As(err, &e) {
return e.WriteJSON(w)
}
return ErrInternalServerError.WithError(err).WriteJSON(w)
}
+121
View File
@@ -0,0 +1,121 @@
// Package scan walks a directory tree and yields the audio files under it.
// It replaces the archived code.aneur.in/go/sqan with a plain
// filepath.WalkDir; zampler only ever filtered by extension.
package scan
import (
"io/fs"
"iter"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// File describes one scanned file. Paths mirror sqan: RootDir and
// AbsolutePath are absolute, RelativePath is rooted with a leading slash
// ("/album/track.mp3"), RelativeDir is its directory ("/album", or "/" for
// a file directly under the root).
type File struct {
RootDir string
AbsolutePath string
RelativeDir string
RelativePath string
Filename string
Extension string
Size int64
Modified time.Time
}
// Scanner walks a single root directory.
type Scanner struct {
root string
extensions []string
onError func(error)
}
// New creates a Scanner for root. A relative root is resolved against the
// working directory.
func New(root string) *Scanner {
if !filepath.IsAbs(root) {
if cwd, err := os.Getwd(); err == nil {
root = filepath.Join(cwd, root)
}
}
return &Scanner{root: root, onError: func(error) {}}
}
// WithExtensions restricts the scan to files with one of the given
// extensions (without the dot), compared case-insensitively.
func (s *Scanner) WithExtensions(exts ...string) *Scanner {
for _, ext := range exts {
s.extensions = append(s.extensions, strings.ToLower(ext))
}
return s
}
// OnError registers a callback for errors hit while walking. Unset, they
// are ignored and the walk continues.
func (s *Scanner) OnError(fn func(error)) *Scanner {
s.onError = fn
return s
}
// Scan walks the tree, yielding one *File per matching file. Stopping the
// range stops the walk.
func (s *Scanner) Scan() iter.Seq[*File] {
return func(yield func(*File) bool) {
filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
s.onError(err)
return nil
}
if d.IsDir() {
return nil
}
name := d.Name()
ext := ""
if i := strings.LastIndexByte(name, '.'); i > -1 {
ext = strings.ToLower(name[i+1:])
}
if len(s.extensions) > 0 && !slices.Contains(s.extensions, ext) {
return nil
}
rel, relErr := filepath.Rel(s.root, path)
if relErr != nil {
s.onError(relErr)
return nil
}
rel = "/" + filepath.ToSlash(rel)
relDir := "/"
if i := strings.LastIndexByte(rel, '/'); i > 0 {
relDir = rel[:i]
}
info, infoErr := d.Info()
if infoErr != nil {
s.onError(infoErr)
return nil
}
file := &File{
RootDir: s.root,
AbsolutePath: path,
RelativeDir: relDir,
RelativePath: rel,
Filename: name,
Extension: ext,
Size: info.Size(),
Modified: info.ModTime(),
}
if !yield(file) {
return filepath.SkipAll
}
return nil
})
}
}
+25 -9
View File
@@ -1,31 +1,47 @@
package server
import (
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"code.aneur.in/go/rest"
"code.aneur.in/zampler/zampler/internal/rest"
"github.com/gorilla/mux"
)
func (srv *Server) ReadFile(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
id := mux.Vars(req)["id"]
id := vars["id"]
file, err := srv.db.GetFile(id)
if err != nil {
rest.WriteErrorJSON(w, err)
return
}
data, err := os.ReadFile(file.AbsolutePath)
f, err := os.Open(file.AbsolutePath)
if err != nil {
srv.log.Err(err).Msg("Failed to read file")
if errors.Is(err, fs.ErrNotExist) {
rest.WriteErrorJSON(w, rest.ErrNotFound.WithError(err))
return
}
srv.log.Err(err).Str("path", file.AbsolutePath).Msg("Failed to open file")
rest.WriteErrorJSON(w, err)
return
}
defer f.Close()
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte{})
info, err := f.Stat()
if err != nil {
srv.log.Err(err).Str("path", file.AbsolutePath).Msg("Failed to stat file")
rest.WriteErrorJSON(w, err)
return
}
w.Header().Set("content-disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Filename))
w.Write(data)
// inline (not attachment) so the embedded <audio> player can stream it;
// the download button uses the HTML download attribute to save instead.
// http.ServeContent adds Content-Type, Range and If-Modified-Since.
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", file.Filename))
http.ServeContent(w, req, file.Filename, info.ModTime(), f)
}
@@ -0,0 +1,40 @@
package middlewares
import (
"fmt"
"net/http"
)
// CacheControl sets a fixed Cache-Control header on every response through
// it.
type CacheControl struct {
value string
}
// NewCacheControl builds a CacheControl for maxAge seconds. maxAge <= 0
// disables caching (no-store) rather than being silently treated as unset,
// so operators can turn a category off entirely. immutable adds the
// immutable directive, appropriate for content that never changes for a
// given URL (e.g. content-addressed audio files).
func NewCacheControl(maxAge int, immutable bool) *CacheControl {
if maxAge <= 0 {
return &CacheControl{value: "no-store"}
}
value := fmt.Sprintf("public, max-age=%d", maxAge)
if immutable {
value += ", immutable"
}
return &CacheControl{value: value}
}
// Middleware wraps next, adding the Cache-Control header per response.
//
// router.Use(middlewares.NewCacheControl(3600, false).Middleware)
func (c *CacheControl) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", c.value)
next.ServeHTTP(w, req)
})
}
+39 -25
View File
@@ -2,36 +2,50 @@ package middlewares
import (
"net/http"
"slices"
"strings"
)
// CorsOrigin is a middleware to set allowed origins.
// CorsOrigin sets Access-Control-Allow-Origin. It is configured with a
// comma-separated origin list; "*" (the default in cmd wiring) allows any
// origin, otherwise the request Origin is echoed back only when it is on the
// list.
type CorsOrigin struct {
origin string
next http.Handler
origins []string
allowAll bool
}
// Middleware function for easy setup in mux.
//
// router.Use(middlewares.CorsOrigin().Middleware)
func (c *CorsOrigin) Middleware(next http.Handler) http.Handler {
// TODO rewrite this - need to use an anon function, not store next on the instance
// See mux.CORSMethodMiddleware for example
c.next = next
return c
}
func (c *CorsOrigin) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// For development purposes I am just allowing all origins
// This should be updated before deployment to ALLOW (not force) origin restrictions
w.Header().Set("Access-Control-Allow-Origin", "*")
c.next.ServeHTTP(w, req)
}
func NewCorsOrigin(origin string) *CorsOrigin {
c := &CorsOrigin{
origin: origin,
// NewCorsOrigin parses a comma-separated origin list. Whitespace around each
// entry is trimmed; an entry of "*" switches on allow-any and the rest of the
// list is ignored.
func NewCorsOrigin(origins string) *CorsOrigin {
c := &CorsOrigin{}
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
switch {
case o == "":
continue
case o == "*":
c.allowAll = true
default:
c.origins = append(c.origins, o)
}
}
return c
}
// Middleware wraps next, adding the CORS header per request.
//
// router.Use(middlewares.NewCorsOrigin("*").Middleware)
func (c *CorsOrigin) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if c.allowAll {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else if origin := req.Header.Get("Origin"); origin != "" && slices.Contains(c.origins, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
next.ServeHTTP(w, req)
})
}
+103 -88
View File
@@ -1,6 +1,10 @@
package middlewares
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"time"
@@ -8,99 +12,110 @@ import (
"github.com/rs/zerolog"
)
// Log is an HTTP logging middleware.
// It logs client errors (4XX) as warnings, server errors (5XX) as errors, and everything else as trace data.
// Log is an HTTP logging middleware. It emits exactly one line per request
// after the handler returns: 4XX as warnings, 5XX as errors, everything else
// (including bodiless 2XX/3XX responses) as trace data.
type Log struct {
log zerolog.Logger
next http.Handler
}
// Middleware function for easy setup in mux.
//
// router.Use(middlewares.Log(log).Middleware)
func (l *Log) Middleware(next http.Handler) http.Handler {
// TODO rewrite this - need to use an anon function, not store next on the instance
// See mux.CORSMethodMiddleware for example
l.next = next
return l
}
func (l *Log) ServeHTTP(w http.ResponseWriter, req *http.Request) {
u, _ := uuid.NewV7()
lw := &LogWriter{
id: u.String(),
log: l.log.With().Logger(),
next: w,
req: req,
start: time.Now(),
}
l.next.ServeHTTP(lw, req)
}
// LogWriter implements and wraps http.ResponseWriter to track and log each individual request.
// This is only used internally by Log.
type LogWriter struct {
id string
log zerolog.Logger
next http.ResponseWriter
req *http.Request
start time.Time
statusCode int
}
func (w *LogWriter) Header() http.Header {
return w.next.Header()
}
func (w *LogWriter) Write(b []byte) (int, error) {
bytes, err := w.next.Write(b)
if w.statusCode == 0 {
w.statusCode = 200
}
go w.logRequest(bytes)
return bytes, err
}
func (w *LogWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.next.WriteHeader(statusCode)
}
func (w *LogWriter) Written() bool {
return w.statusCode > 0
}
func (w *LogWriter) logRequest(bytes int) {
var e *zerolog.Event
if w.statusCode >= 100 && w.statusCode < 400 {
e = w.log.Trace()
} else if w.statusCode >= 400 && w.statusCode < 500 {
e = w.log.Warn()
} else if w.statusCode >= 500 {
e = w.log.Error()
}
duration := time.Since(w.start)
e.Str("id", w.id).
Str("remoteAddr", w.req.RemoteAddr).
Int("bytes", bytes).
Dur("duration", duration).
Int("statusCode", w.statusCode).
Str("method", w.req.Method).
Msg(w.req.URL.Path)
log zerolog.Logger
}
// NewLog builds a Log writing to a child of log.
func NewLog(log zerolog.Logger) *Log {
l := &Log{
return &Log{
log: log.With().Str("namespace", "server.middlewares.Log").Logger(),
}
}
return l
// Middleware wraps next.
//
// router.Use(middlewares.NewLog(log).Middleware)
func (l *Log) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
u, _ := uuid.NewV7()
lw := &logWriter{ResponseWriter: w}
start := time.Now()
next.ServeHTTP(lw, req)
status := lw.statusCode
if status == 0 {
// Handler returned without ever touching the writer.
status = http.StatusOK
}
var e *zerolog.Event
switch {
case status >= 500:
e = l.log.Error()
case status >= 400:
e = l.log.Warn()
default:
e = l.log.Trace()
}
e.Str("id", u.String()).
Str("remoteAddr", req.RemoteAddr).
Int("bytes", lw.bytes).
Dur("duration", time.Since(start)).
Int("statusCode", status).
Str("method", req.Method).
Msg(req.URL.Path)
})
}
// logWriter wraps http.ResponseWriter to capture the status code and byte
// count for a single request. It forwards the optional Flusher / Hijacker /
// ReaderFrom behaviours so streaming handlers and the file server keep working.
type logWriter struct {
http.ResponseWriter
statusCode int
bytes int
wroteHeader bool
}
func (w *logWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.statusCode = code
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}
func (w *logWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (w *logWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *logWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijack")
}
func (w *logWriter) ReadFrom(r io.Reader) (int64, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
n, err := rf.ReadFrom(r)
w.bytes += int(n)
return n, err
}
n, err := io.Copy(w.ResponseWriter, r)
w.bytes += int(n)
return n, err
}
+27
View File
@@ -0,0 +1,27 @@
package server
import (
"net/http"
)
// robots.txt bodies. The permissive one leaves Disallow empty (the
// canonical "index everything"); the restrictive one blocks every crawler
// from the whole site.
const (
robotsAllowAll = "User-agent: *\nDisallow:\n"
robotsBlockAll = "User-agent: *\nDisallow: /\n"
robotsContentType = "text/plain; charset=utf-8"
)
// GetRobots serves /robots.txt. Search indexing is opt-in: unless the host
// runs with --allow-indexing, this blocks all crawlers. Generating the file
// here rather than shipping a static asset lets the policy be set per host.
func (srv *Server) GetRobots(w http.ResponseWriter, req *http.Request) {
body := robotsBlockAll
if srv.allowIndexing {
body = robotsAllowAll
}
w.Header().Set("Content-Type", robotsContentType)
w.Write([]byte(body))
}
+49
View File
@@ -0,0 +1,49 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
)
func TestGetRobots(t *testing.T) {
tests := []struct {
name string
allowIndexing bool
want string
}{
{"blocks all crawlers by default", false, "User-agent: *\nDisallow: /\n"},
{"permissive when indexing is allowed", true, "User-agent: *\nDisallow:\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := NewServer(nil, zerolog.Nop(), "", tt.allowIndexing, 0, true)
router := mux.NewRouter()
srv.ConfigureRouter(router)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if ct := res.Header.Get("Content-Type"); ct != robotsContentType {
t.Errorf("Content-Type = %q, want %q", ct, robotsContentType)
}
body, _ := io.ReadAll(res.Body)
if string(body) != tt.want {
t.Errorf("body = %q, want %q", string(body), tt.want)
}
})
}
}
+38 -5
View File
@@ -6,9 +6,10 @@ import (
"net/http"
"strings"
"code.aneur.in/go/rest"
"code.aneur.in/zampler/zampler/internal/database"
"code.aneur.in/zampler/zampler/internal/dto"
"code.aneur.in/zampler/zampler/internal/rest"
"code.aneur.in/zampler/zampler/internal/server/middlewares"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
)
@@ -16,27 +17,55 @@ import (
//go:embed static
var staticFiles embed.FS
// htmlCacheControl is fixed rather than driven by --static-max-age: it
// covers index.html, which is not content-hashed (unlike the Vite build's
// /assets/* files) and must always be revalidated so a deploy's new,
// hashed asset references are picked up promptly.
const htmlCacheControl = "no-cache"
type Server struct {
url string
allowIndexing bool
db *database.DB
log zerolog.Logger
static http.Handler
staticFiles fs.FS
fileCache *middlewares.CacheControl
}
func (srv *Server) ConfigureRouter(r *mux.Router) {
r.Path("/config.json").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.GetConfig)
r.Path("/file/{id}").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.ReadFile)
r.Path("/robots.txt").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.GetRobots)
r.Path("/file/{id}").Methods(http.MethodGet, http.MethodOptions).Handler(srv.fileCache.Middleware(http.HandlerFunc(srv.ReadFile)))
}
func (srv *Server) ConfigureStaticAssets(r *mux.Router) {
func (srv *Server) ConfigureStaticAssets(r *mux.Router, maxAge int) {
staticFiles, _ := fs.Sub(staticFiles, "static")
srv.staticFiles = staticFiles
srv.static = http.FileServerFS(staticFiles)
r.Methods(http.MethodGet, http.MethodOptions).Handler(srv.static)
assetHandler := middlewares.NewCacheControl(maxAge, false).Middleware(srv.static)
r.Methods(http.MethodGet, http.MethodOptions).Handler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if isHTMLPath(req.URL.Path) {
w.Header().Set("Cache-Control", htmlCacheControl)
srv.static.ServeHTTP(w, req)
return
}
assetHandler.ServeHTTP(w, req)
}))
}
// isHTMLPath reports whether p resolves to an HTML document rather than a
// hashed build asset: the root (served as index.html by http.FileServerFS)
// or anything ending in .html.
func isHTMLPath(p string) bool {
return p == "/" || p == "" || strings.HasSuffix(p, ".html")
}
func (srv *Server) GetConfig(w http.ResponseWriter, req *http.Request) {
@@ -45,12 +74,16 @@ func (srv *Server) GetConfig(w http.ResponseWriter, req *http.Request) {
})
}
func NewServer(db *database.DB, log zerolog.Logger, url string) *Server {
func NewServer(db *database.DB, log zerolog.Logger, url string, allowIndexing bool, fileMaxAge int, fileImmutable bool) *Server {
srv := &Server{
url: url,
allowIndexing: allowIndexing,
db: db,
log: log.With().Str("namespace", "server.Server").Logger(),
fileCache: middlewares.NewCacheControl(fileMaxAge, fileImmutable),
}
return srv
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"os"
"time"
"code.aneur.in/go/sqan"
"code.aneur.in/zampler/zampler/internal/scan"
)
type File struct {
@@ -23,7 +23,7 @@ type File struct {
Modified time.Time `json:"modified"`
}
func NewFile(other *sqan.File) (*File, error) {
func NewFile(other *scan.File) (*File, error) {
f := &File{
RootDir: other.RootDir,
AbsolutePath: other.AbsolutePath,
+80
View File
@@ -0,0 +1,80 @@
package types
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
"code.aneur.in/zampler/zampler/internal/scan"
)
func TestNewFile(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "kick.wav")
contents := []byte("not really a wav")
if err := os.WriteFile(abs, contents, 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
modified := time.Now().Add(-time.Hour).Truncate(time.Second)
src := &scan.File{
RootDir: dir,
AbsolutePath: abs,
RelativeDir: "/",
RelativePath: "/kick.wav",
Filename: "kick.wav",
Extension: "wav",
Size: int64(len(contents)),
Modified: modified,
}
f, err := NewFile(src)
if err != nil {
t.Fatalf("NewFile: %v", err)
}
wantID := hex.EncodeToString(sha256Sum([]byte(abs)))
if f.ID != wantID {
t.Errorf("ID = %q, want %q (sha256 of absolute path)", f.ID, wantID)
}
wantHash := hex.EncodeToString(sha256Sum(contents))
if f.Hash != wantHash {
t.Errorf("Hash = %q, want %q (sha256 of contents)", f.Hash, wantHash)
}
if f.RootDir != src.RootDir || f.AbsolutePath != src.AbsolutePath ||
f.RelativeDir != src.RelativeDir || f.RelativePath != src.RelativePath ||
f.Filename != src.Filename || f.Extension != src.Extension || f.Size != src.Size {
t.Errorf("scan fields not copied through: %+v", f)
}
if !f.Modified.Equal(modified) {
t.Errorf("Modified = %v, want %v", f.Modified, modified)
}
}
func TestNewFileMissingFile(t *testing.T) {
abs := filepath.Join(t.TempDir(), "gone.mp3")
f, err := NewFile(&scan.File{AbsolutePath: abs, Filename: "gone.mp3"})
if err == nil {
t.Fatal("NewFile on a missing file: want error, got nil")
}
// The ID is derived from the path, so it is still set; the content hash
// is not.
if f.ID != hex.EncodeToString(sha256Sum([]byte(abs))) {
t.Errorf("ID = %q, want path hash even on read failure", f.ID)
}
if f.Hash != "" {
t.Errorf("Hash = %q, want empty on read failure", f.Hash)
}
}
func sha256Sum(b []byte) []byte {
sum := sha256.Sum256(b)
return sum[:]
}