Compare commits
11
Commits
a8e31c29d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1964fda05e | ||
|
|
60ecec609d | ||
|
|
e9542dfda5 | ||
|
|
520bf1c186 | ||
|
|
a02e96ceec | ||
|
|
7a6e1aa6a8 | ||
|
|
800404203a | ||
|
|
55d14d3193 | ||
|
|
2a79eecbff | ||
|
|
7f57e25dcf | ||
|
|
2c288ca1e2 |
+3
-2
@@ -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
@@ -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 \
|
||||
.
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -4,3 +4,4 @@
|
||||
zampler.files
|
||||
zampler.sqlite3
|
||||
internal/server/static
|
||||
dist
|
||||
|
||||
+9
-5
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[:]
|
||||
}
|
||||
Reference in New Issue
Block a user