A drop-in, single-binary reimplementation of the caronc/apprise-api HTTP API in Go.
0

Configure Feed

Select the types of activity you want to include in your feed.

stateless

Kohei Watanabe (Jun 7, 2026, 9:05 PM +0900) e9505877 f96d5d8d

+319 -1484
-4
.dockerignore
··· 1 1 .git 2 2 .github 3 - bin 4 3 apprize 5 - apprize.db 6 - *.db 7 - tmp
-5
.gitignore
··· 1 1 # Build output 2 2 /apprize 3 3 /apprize.exe 4 - 5 - # Runtime SQLite databases (and WAL/SHM sidecars) 6 - *.db 7 - *.db-wal 8 - *.db-shm
-3
Dockerfile
··· 15 15 COPY --from=build /out/apprize /usr/local/bin/apprize 16 16 17 17 ENV APPRIZE_BIND=:8000 18 - ENV APPRIZE_DB_PATH=/data/apprize.db 19 - 20 - VOLUME ["/data"] 21 18 EXPOSE 8000 22 19 23 20 ENTRYPOINT ["/usr/local/bin/apprize"]
+21 -42
README.md
··· 1 1 # Apprize 2 2 3 - A drop-in, single-binary reimplementation of the [caronc/apprise-api](https://github.com/caronc/apprise-api) 4 - HTTP API in Go. It speaks the same routes and response shapes as Apprise API 5 - `swagger.yaml` v1.5.0, backed by [unraid/apprise-go](https://github.com/unraid/apprise-go) 6 - for delivery and [modernc.org/sqlite](https://modernc.org/sqlite) for storage — 7 - pure Go, no cgo, no Python runtime. 3 + A drop-in, single-binary reimplementation of the [caronc/apprise-api](https://github.com/caronc/apprise-api) HTTP API in Go. Speaks the same stateless routes and response shapes as Apprise API `swagger.yaml` v1.5.0, backed by [unraid/apprise-go](https://github.com/unraid/apprise-go) for delivery — pure Go, no cgo, no Python runtime. 8 4 9 5 ## Build & run 10 6 ··· 12 8 13 9 ```sh 14 10 go build 15 - ./apprize --bind :8000 --db ./apprize.db 16 - ``` 17 - 18 - Ephemeral (no file persistence): 19 - 20 - ```sh 21 - ./apprize --db :memory: 11 + ./apprize --bind :8000 22 12 ``` 23 13 24 14 ### Docker 25 15 26 16 ```sh 27 17 docker build -t apprize . 28 - docker run --rm -p 8000:8000 -v "$PWD/data:/data" apprize 18 + docker run --rm -p 8000:8000 apprize 29 19 ``` 30 20 31 21 The image runs `apprize` directly and defaults to: 32 22 33 23 - `APPRIZE_BIND=:8000` 34 - - `APPRIZE_DB_PATH=/data/apprize.db` 35 24 36 25 ## Configuration 37 26 38 27 Flags override environment variables. Names use the `APPRIZE_` prefix; the 39 28 upstream `HTTP_PORT` is also honoured for the listen port. 40 29 41 - | Env | Flag | Default | Purpose | 42 - | ------------------------------- | --------------------- | -------------- | --------------------------------------------- | 43 - | `APPRIZE_BIND` (or `HTTP_PORT`) | `--bind` | `:8000` | Listen address | 44 - | `APPRIZE_DB_PATH` | `--db` | `./apprize.db` | SQLite path; `:memory:` for ephemeral | 45 - | `APPRIZE_API_KEY` | `--api-key` | _(none)_ | Enables simple auth only when set | 46 - | `APPRIZE_STATELESS_URLS` | — | _(none)_ | Default URLs for `POST /notify` | 47 - | `APPRIZE_CONFIG_LOCK` | — | `no` | Reject config writes with `403` | 48 - | `APPRIZE_ADMIN` | — | `no` | Allow `GET /cfg` listing | 49 - | `APPRIZE_RECURSION_MAX` | — | `1` | Inbound recursion limit | 50 - | `APPRIZE_DENY_SERVICES` | — | _(none)_ | Schemas to reject (comma/space separated) | 51 - | `APPRIZE_ALLOW_SERVICES` | — | _(none)_ | Allow-list of schemas (exclusive when set) | 52 - | `APPRIZE_CONFIG_MAX_LENGTH` | `--config-max-length` | `512` (KB) | Request body limit | 53 - | `APPRIZE_DEFAULT_CONFIG_ID` | `--default-config-id` | `apprise` | Default key used by keyless persistent routes | 30 + | Env | Flag | Default | Purpose | 31 + | ------------------------------- | ----------- | -------- | ------------------------------------------ | 32 + | `APPRIZE_BIND` (or `HTTP_PORT`) | `--bind` | `:8000` | Listen address | 33 + | `APPRIZE_API_KEY` | `--api-key` | _(none)_ | Enables simple auth only when set | 34 + | `APPRIZE_STATELESS_URLS` | — | _(none)_ | Default URLs for `POST /notify` | 35 + | `APPRIZE_RECURSION_MAX` | — | `1` | Inbound recursion limit | 36 + | `APPRIZE_DENY_SERVICES` | — | _(none)_ | Schemas to reject (comma/space separated) | 37 + | `APPRIZE_ALLOW_SERVICES` | — | _(none)_ | Allow-list of schemas (exclusive when set) | 54 38 55 39 ## API 56 40 57 - Routes match the Apprise API contract (`testdata/swagger.yaml`): 58 - 59 - | Method & path | Purpose | 60 - | ----------------------------------------------------- | ------------------------------------------------------ | 61 - | `GET /status` | Server status | 62 - | `GET /details` | Version and supported schemas | 63 - | `POST /notify` | Stateless notification | 64 - | `POST /add/{key}` | Store a named configuration | 65 - | `POST /del/{key}` | Delete a configuration | 66 - | `POST /get/{key}` · `POST /cfg/{key}` | Fetch a configuration | 67 - | `POST /add` · `POST /del` · `POST /get` · `POST /cfg` | Same as keyed routes using `APPRIZE_DEFAULT_CONFIG_ID` | 68 - | `GET /cfg` | List configuration keys (requires `APPRIZE_ADMIN`) | 69 - | `POST /notify/{key}` | Notify using a stored configuration | 70 - | `GET /json/urls/{key}` | List a configuration's URLs as JSON | 41 + | Method & path | Purpose | 42 + | -------------- | ----------------------------- | 43 + | `GET /status` | Server status | 44 + | `GET /details` | Version and supported schemas | 45 + | `POST /notify` | Stateless notification | 71 46 72 47 ## Limitations 73 48 74 - apprize intentionally diverges from upstream where apprise-go cannot match it: 49 + apprize intentionally diverges from upstream where apprise-go cannot match it, 50 + and omits the persistent-configuration half of the API entirely: 75 51 52 + - **No persistent config endpoints** — `/add`, `/del`, `/get`, `/cfg`, 53 + `POST /notify/{key}`, and `GET /json/urls/{key}` are not implemented. 54 + There is no storage layer; the server is fully stateless. 76 55 - **Attachments are not delivered** — accepted then logged as unsupported; 77 56 `/status` reports `attach_lock=true`. 78 57 - **No recursion-header propagation** — inbound `X-Apprise-Recursion-Count` is
-10
go.mod
··· 6 6 github.com/getkin/kin-openapi v0.140.0 7 7 github.com/unraid/apprise-go v0.2.6 8 8 gopkg.in/yaml.v3 v3.0.1 9 - modernc.org/sqlite v1.52.0 10 9 ) 11 10 12 11 require ( 13 - github.com/dustin/go-humanize v1.0.1 // indirect 14 12 github.com/go-openapi/jsonpointer v0.22.5 // indirect 15 13 github.com/go-openapi/swag/jsonname v0.25.5 // indirect 16 14 github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df // indirect 17 - github.com/google/uuid v1.6.0 // indirect 18 15 github.com/kr/pretty v0.3.1 // indirect 19 - github.com/mattn/go-isatty v0.0.20 // indirect 20 - github.com/ncruces/go-strftime v1.0.0 // indirect 21 16 github.com/oasdiff/yaml v0.1.0 // indirect 22 17 github.com/oasdiff/yaml3 v0.0.13 // indirect 23 - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 24 18 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect 25 19 golang.org/x/crypto v0.51.0 // indirect 26 20 golang.org/x/net v0.54.0 // indirect 27 - golang.org/x/sys v0.44.0 // indirect 28 21 golang.org/x/text v0.37.0 // indirect 29 - modernc.org/libc v1.72.3 // indirect 30 - modernc.org/mathutil v1.7.1 // indirect 31 - modernc.org/memory v1.11.0 // indirect 32 22 )
-51
go.sum
··· 3 3 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 4 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 5 5 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 6 - github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 7 - github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 8 6 github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= 9 7 github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= 10 8 github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= ··· 15 13 github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= 16 14 github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao= 17 15 github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 18 - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 19 - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 20 - github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 21 - github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 22 - github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 23 - github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 24 16 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 17 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 18 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 19 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 - github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 - github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 - github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 31 - github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 32 20 github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= 33 21 github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= 34 22 github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= ··· 36 24 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 37 25 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 26 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 40 - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 41 27 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 42 28 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 43 29 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= ··· 48 34 github.com/unraid/apprise-go v0.2.6/go.mod h1:GC0RrJAgW5/giJaJwP8FV9Dd/XMBc5BbQE2wCmDoj+w= 49 35 golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= 50 36 golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 51 - golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= 52 - golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 53 37 golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= 54 38 golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 55 - golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 56 - golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 57 - golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 - golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= 59 - golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 60 39 golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 61 40 golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 62 - golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= 63 - golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 64 41 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 42 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 66 43 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 67 44 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 45 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 69 - modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= 70 - modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= 71 - modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= 72 - modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= 73 - modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= 74 - modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 75 - modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 76 - modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 77 - modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= 78 - modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 79 - modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 80 - modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 81 - modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= 82 - modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 83 - modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 84 - modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 85 - modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 86 - modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 87 - modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= 88 - modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 89 - modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 90 - modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 91 - modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= 92 - modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 93 - modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 94 - modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 95 - modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 96 - modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+13 -43
internal/config/config.go
··· 10 10 11 11 // Config is the resolved server configuration. 12 12 type Config struct { 13 - Bind string 14 - DBPath string 15 - APIKey string 16 - ConfigMaxKB int64 17 - DefaultConfigID string 18 - StatelessURLs []string 19 - ConfigLock bool 20 - Admin bool 21 - RecursionMax int 22 - DenyServices []string 23 - AllowServices []string 13 + Bind string 14 + APIKey string 15 + StatelessURLs []string 16 + RecursionMax int 17 + DenyServices []string 18 + AllowServices []string 24 19 } 25 20 26 21 // FromEnv builds a Config from environment variables, applying defaults. 27 22 func FromEnv() Config { 28 - c := Config{ 29 - Bind: firstNonEmpty(os.Getenv("APPRIZE_BIND"), portToBind(os.Getenv("HTTP_PORT")), ":8000"), 30 - DBPath: firstNonEmpty(os.Getenv("APPRIZE_DB_PATH"), "./apprize.db"), 31 - APIKey: os.Getenv("APPRIZE_API_KEY"), 32 - ConfigMaxKB: parsePositiveInt64(os.Getenv("APPRIZE_CONFIG_MAX_LENGTH"), 512), 33 - DefaultConfigID: firstNonEmpty(os.Getenv("APPRIZE_DEFAULT_CONFIG_ID"), "apprise"), 34 - StatelessURLs: splitList(os.Getenv("APPRIZE_STATELESS_URLS")), 35 - ConfigLock: parseBool(os.Getenv("APPRIZE_CONFIG_LOCK"), false), 36 - Admin: parseBool(os.Getenv("APPRIZE_ADMIN"), false), 37 - RecursionMax: parseInt(os.Getenv("APPRIZE_RECURSION_MAX"), 1), 38 - DenyServices: splitList(os.Getenv("APPRIZE_DENY_SERVICES")), 39 - AllowServices: splitList(os.Getenv("APPRIZE_ALLOW_SERVICES")), 23 + return Config{ 24 + Bind: firstNonEmpty(os.Getenv("APPRIZE_BIND"), portToBind(os.Getenv("HTTP_PORT")), ":8000"), 25 + APIKey: os.Getenv("APPRIZE_API_KEY"), 26 + StatelessURLs: splitList(os.Getenv("APPRIZE_STATELESS_URLS")), 27 + RecursionMax: parseInt(os.Getenv("APPRIZE_RECURSION_MAX"), 1), 28 + DenyServices: splitList(os.Getenv("APPRIZE_DENY_SERVICES")), 29 + AllowServices: splitList(os.Getenv("APPRIZE_ALLOW_SERVICES")), 40 30 } 41 - return c 42 31 } 43 32 44 33 func firstNonEmpty(vals ...string) string { ··· 74 63 return out 75 64 } 76 65 77 - func parseBool(s string, def bool) bool { 78 - switch strings.ToLower(strings.TrimSpace(s)) { 79 - case "yes", "y", "true", "1", "on": 80 - return true 81 - case "no", "n", "false", "0", "off": 82 - return false 83 - default: 84 - return def 85 - } 86 - } 87 - 88 66 func parseInt(s string, def int) int { 89 67 if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { 90 68 return n 91 69 } 92 70 return def 93 71 } 94 - 95 - func parsePositiveInt64(s string, def int64) int64 { 96 - n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) 97 - if err != nil || n <= 0 { 98 - return def 99 - } 100 - return n 101 - }
+14 -28
internal/config/config_test.go
··· 2 2 3 3 import "testing" 4 4 5 - func TestFromEnv_ConfigMaxLengthDefault(t *testing.T) { 6 - t.Setenv("APPRIZE_CONFIG_MAX_LENGTH", "") 7 - cfg := FromEnv() 8 - if cfg.ConfigMaxKB != 512 { 9 - t.Fatalf("ConfigMaxKB = %d, want 512", cfg.ConfigMaxKB) 10 - } 11 - } 12 - 13 - func TestFromEnv_ConfigMaxLengthFromEnv(t *testing.T) { 14 - t.Setenv("APPRIZE_CONFIG_MAX_LENGTH", "1024") 15 - cfg := FromEnv() 16 - if cfg.ConfigMaxKB != 1024 { 17 - t.Fatalf("ConfigMaxKB = %d, want 1024", cfg.ConfigMaxKB) 18 - } 19 - } 20 - 21 - func TestFromEnv_ConfigMaxLengthInvalidFallsBack(t *testing.T) { 22 - t.Setenv("APPRIZE_CONFIG_MAX_LENGTH", "0") 5 + func TestFromEnv_BindDefault(t *testing.T) { 6 + t.Setenv("APPRIZE_BIND", "") 7 + t.Setenv("HTTP_PORT", "") 23 8 cfg := FromEnv() 24 - if cfg.ConfigMaxKB != 512 { 25 - t.Fatalf("ConfigMaxKB = %d, want 512", cfg.ConfigMaxKB) 9 + if cfg.Bind != ":8000" { 10 + t.Fatalf("Bind = %q, want %q", cfg.Bind, ":8000") 26 11 } 27 12 } 28 13 29 - func TestFromEnv_DefaultConfigIDDefault(t *testing.T) { 30 - t.Setenv("APPRIZE_DEFAULT_CONFIG_ID", "") 14 + func TestFromEnv_BindFromHTTPPort(t *testing.T) { 15 + t.Setenv("APPRIZE_BIND", "") 16 + t.Setenv("HTTP_PORT", "9000") 31 17 cfg := FromEnv() 32 - if cfg.DefaultConfigID != "apprise" { 33 - t.Fatalf("DefaultConfigID = %q, want %q", cfg.DefaultConfigID, "apprise") 18 + if cfg.Bind != ":9000" { 19 + t.Fatalf("Bind = %q, want %q", cfg.Bind, ":9000") 34 20 } 35 21 } 36 22 37 - func TestFromEnv_DefaultConfigIDFromEnv(t *testing.T) { 38 - t.Setenv("APPRIZE_DEFAULT_CONFIG_ID", "my-default") 23 + func TestFromEnv_RecursionMaxDefault(t *testing.T) { 24 + t.Setenv("APPRIZE_RECURSION_MAX", "") 39 25 cfg := FromEnv() 40 - if cfg.DefaultConfigID != "my-default" { 41 - t.Fatalf("DefaultConfigID = %q, want %q", cfg.DefaultConfigID, "my-default") 26 + if cfg.RecursionMax != 1 { 27 + t.Fatalf("RecursionMax = %d, want 1", cfg.RecursionMax) 42 28 } 43 29 }
+15 -244
internal/server/conformance_cases_test.go
··· 12 12 "testing" 13 13 14 14 "git.fogtype.com/nebel/apprize/internal/server" 15 - "git.fogtype.com/nebel/apprize/internal/store" 16 15 ) 17 16 18 17 const ( ··· 85 84 t.Errorf("text status body = %q, want to contain OK", rec.Body.String()) 86 85 } 87 86 }}, 88 - {"status/417-text-store-error", "/status", "GET", func(t *testing.T) { 89 - rec := req{method: "GET", path: "/status", accept: ctText}. 90 - do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 91 - wantStatus(t, rec, 417) 92 - wantContentType(t, rec, ctText) 93 - if !strings.Contains(rec.Body.String(), "CONFIG_PERMISSION_ISSUE") { 94 - t.Errorf("text status body = %q, want to contain CONFIG_PERMISSION_ISSUE", rec.Body.String()) 95 - } 96 - }}, 97 - {"status/417-json-store-error", "/status", "GET", func(t *testing.T) { 98 - rec := req{method: "GET", path: "/status", accept: ctJSON}. 99 - do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 100 - wantStatus(t, rec, 417) 101 - wantContentType(t, rec, ctJSON) 102 - var obj map[string]any 103 - if err := json.Unmarshal(rec.Body.Bytes(), &obj); err != nil { 104 - t.Fatalf("status body is not JSON: %v", err) 105 - } 106 - status, _ := obj["status"].(map[string]any) 107 - if ok, _ := status["persistent_storage"].(bool); ok { 108 - t.Fatalf("status.persistent_storage = %v, want false", status["persistent_storage"]) 109 - } 110 - if can, _ := status["can_write_config"].(bool); can { 111 - t.Fatalf("status.can_write_config = %v, want false", status["can_write_config"]) 112 - } 113 - }}, 114 87 115 88 // ------------------------------------------------------------------- 116 89 // GET /details ··· 188 161 }}, 189 162 190 163 // ------------------------------------------------------------------- 191 - // GET /cfg 164 + // Persistent endpoints — intentionally omitted, 404 via the default mux. 192 165 // ------------------------------------------------------------------- 193 - {"cfg/403-no-admin", "/cfg", "GET", func(t *testing.T) { 194 - rec := req{method: "GET", path: "/cfg"}.do(newServer(func(d *server.Deps) { d.Admin = false })) 195 - wantStatus(t, rec, 403) 196 - }}, 197 - {"cfg/200-admin-json", "/cfg", "GET", func(t *testing.T) { 198 - st := store.NewMemory() 199 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 200 - rec := req{method: "GET", path: "/cfg", accept: ctJSON}. 201 - do(newServer(func(d *server.Deps) { d.Admin = true; d.Store = st })) 202 - wantStatus(t, rec, 200) 203 - wantContentType(t, rec, ctJSON) 204 - validateSchema(t, "/cfg", "GET", "200", ctJSON, rec.Body.Bytes()) 205 - var keys []string 206 - _ = json.Unmarshal(rec.Body.Bytes(), &keys) 207 - if len(keys) == 0 || keys[0] != "mykey" { 208 - t.Errorf("keys = %v, want [mykey]", keys) 209 - } 210 - }}, 211 - {"cfg/200-admin-html", "/cfg", "GET", func(t *testing.T) { 212 - rec := req{method: "GET", path: "/cfg", accept: ctHTML}.do(newServer(func(d *server.Deps) { d.Admin = true })) 213 - wantStatus(t, rec, 200) 214 - wantContentType(t, rec, ctHTML) 215 - }}, 216 - 217 - // ------------------------------------------------------------------- 218 - // POST /add/{key} 219 - // ------------------------------------------------------------------- 220 - {"add/200-json-config", "/add/{key}", "POST", func(t *testing.T) { 221 - body := jsonBody(t, map[string]any{"config": "gotify://host/token", "format": "text"}) 222 - rec := req{method: "POST", path: "/add/mykey", contentType: ctJSON, accept: ctJSON, body: body}.do(newServer()) 223 - wantStatus(t, rec, 200) 224 - validateSchema(t, "/add/{key}", "POST", "200", ctJSON, rec.Body.Bytes()) 225 - }}, 226 - {"add/200-form-urls", "/add/{key}", "POST", func(t *testing.T) { 227 - body := formBody(map[string]string{"urls": "gotify://host/token"}) 228 - rec := req{method: "POST", path: "/add/mykey", contentType: ctForm, accept: ctJSON, body: body}.do(newServer()) 229 - wantStatus(t, rec, 200) 166 + {"cfg/404", "/cfg", "GET", func(t *testing.T) { 167 + wantStatus(t, req{method: "GET", path: "/cfg"}.do(newServer()), 404) 230 168 }}, 231 - {"add/400-invalid", "/add/{key}", "POST", func(t *testing.T) { 232 - body := jsonBody(t, map[string]any{"config": "urls: [unterminated", "format": "yaml"}) 233 - rec := req{method: "POST", path: "/add/mykey", contentType: ctJSON, body: body}.do(newServer()) 234 - wantStatus(t, rec, 400) 169 + {"add/404", "/add/{key}", "POST", func(t *testing.T) { 170 + wantStatus(t, req{method: "POST", path: "/add/mykey"}.do(newServer()), 404) 235 171 }}, 236 - {"add/403-lock", "/add/{key}", "POST", func(t *testing.T) { 237 - body := jsonBody(t, map[string]any{"config": "gotify://host/token", "format": "text"}) 238 - rec := req{method: "POST", path: "/add/mykey", contentType: ctJSON, body: body}. 239 - do(newServer(func(d *server.Deps) { d.ConfigLock = true })) 240 - wantStatus(t, rec, 403) 172 + {"del/404", "/del/{key}", "POST", func(t *testing.T) { 173 + wantStatus(t, req{method: "POST", path: "/del/mykey"}.do(newServer()), 404) 241 174 }}, 242 - {"add/431-large", "/add/{key}", "POST", func(t *testing.T) { 243 - big := strings.Repeat("gotify://host/token\n", 1000) 244 - body := jsonBody(t, map[string]any{"config": big, "format": "text"}) 245 - rec := req{method: "POST", path: "/add/mykey", contentType: ctJSON, body: body}. 246 - do(newServer(func(d *server.Deps) { d.MaxBodyBytes = 64 })) 247 - wantStatus(t, rec, 431) 175 + {"get/404", "/get/{key}", "POST", func(t *testing.T) { 176 + wantStatus(t, req{method: "POST", path: "/get/mykey"}.do(newServer()), 404) 248 177 }}, 249 - {"add/500-store-error", "/add/{key}", "POST", func(t *testing.T) { 250 - body := jsonBody(t, map[string]any{"config": "gotify://host/token", "format": "text"}) 251 - rec := req{method: "POST", path: "/add/mykey", contentType: ctJSON, body: body}. 252 - do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 253 - wantStatus(t, rec, 500) 178 + {"cfgkey/404", "/cfg/{key}", "POST", func(t *testing.T) { 179 + wantStatus(t, req{method: "POST", path: "/cfg/mykey"}.do(newServer()), 404) 254 180 }}, 255 - 256 - // ------------------------------------------------------------------- 257 - // POST /del/{key} 258 - // ------------------------------------------------------------------- 259 - {"del/200-exists", "/del/{key}", "POST", func(t *testing.T) { 260 - st := store.NewMemory() 261 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 262 - rec := req{method: "POST", path: "/del/mykey"}.do(newServer(func(d *server.Deps) { d.Store = st })) 263 - wantStatus(t, rec, 200) 181 + {"notifykey/404", "/notify/{key}", "POST", func(t *testing.T) { 182 + wantStatus(t, req{method: "POST", path: "/notify/mykey"}.do(newServer()), 404) 264 183 }}, 265 - {"del/204-absent", "/del/{key}", "POST", func(t *testing.T) { 266 - rec := req{method: "POST", path: "/del/missing"}.do(newServer()) 267 - wantStatus(t, rec, 204) 268 - }}, 269 - {"del/403-lock", "/del/{key}", "POST", func(t *testing.T) { 270 - rec := req{method: "POST", path: "/del/mykey"}.do(newServer(func(d *server.Deps) { d.ConfigLock = true })) 271 - wantStatus(t, rec, 403) 272 - }}, 273 - {"del/500-error", "/del/{key}", "POST", func(t *testing.T) { 274 - rec := req{method: "POST", path: "/del/mykey"}.do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 275 - wantStatus(t, rec, 500) 276 - }}, 277 - 278 - // ------------------------------------------------------------------- 279 - // POST /get/{key} 280 - // ------------------------------------------------------------------- 281 - {"get/200-json", "/get/{key}", "POST", func(t *testing.T) { 282 - st := store.NewMemory() 283 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 284 - rec := req{method: "POST", path: "/get/mykey", accept: ctJSON}.do(newServer(func(d *server.Deps) { d.Store = st })) 285 - wantStatus(t, rec, 200) 286 - wantContentType(t, rec, ctJSON) 287 - validateSchema(t, "/get/{key}", "POST", "200", ctJSON, rec.Body.Bytes()) 288 - }}, 289 - {"get/200-text", "/get/{key}", "POST", func(t *testing.T) { 290 - st := store.NewMemory() 291 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 292 - rec := req{method: "POST", path: "/get/mykey", accept: ctText}.do(newServer(func(d *server.Deps) { d.Store = st })) 293 - wantStatus(t, rec, 200) 294 - wantContentType(t, rec, ctText) 295 - if !strings.Contains(rec.Body.String(), "gotify://host/token") { 296 - t.Errorf("text config body = %q", rec.Body.String()) 297 - } 298 - }}, 299 - {"get/204-absent", "/get/{key}", "POST", func(t *testing.T) { 300 - rec := req{method: "POST", path: "/get/missing"}.do(newServer()) 301 - wantStatus(t, rec, 204) 302 - }}, 303 - {"get/403-lock", "/get/{key}", "POST", func(t *testing.T) { 304 - rec := req{method: "POST", path: "/get/mykey"}.do(newServer(func(d *server.Deps) { d.ConfigLock = true })) 305 - wantStatus(t, rec, 403) 306 - }}, 307 - {"get/500-error", "/get/{key}", "POST", func(t *testing.T) { 308 - rec := req{method: "POST", path: "/get/mykey"}.do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 309 - wantStatus(t, rec, 500) 310 - }}, 311 - 312 - // ------------------------------------------------------------------- 313 - // POST /cfg/{key} (alias for /get/{key}) 314 - // ------------------------------------------------------------------- 315 - {"cfgkey/200-json", "/cfg/{key}", "POST", func(t *testing.T) { 316 - st := store.NewMemory() 317 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 318 - rec := req{method: "POST", path: "/cfg/mykey", accept: ctJSON}.do(newServer(func(d *server.Deps) { d.Store = st })) 319 - wantStatus(t, rec, 200) 320 - validateSchema(t, "/cfg/{key}", "POST", "200", ctJSON, rec.Body.Bytes()) 321 - }}, 322 - {"cfgkey/204-absent", "/cfg/{key}", "POST", func(t *testing.T) { 323 - rec := req{method: "POST", path: "/cfg/missing"}.do(newServer()) 324 - wantStatus(t, rec, 204) 325 - }}, 326 - {"cfgkey/403-lock", "/cfg/{key}", "POST", func(t *testing.T) { 327 - rec := req{method: "POST", path: "/cfg/mykey"}.do(newServer(func(d *server.Deps) { d.ConfigLock = true })) 328 - wantStatus(t, rec, 403) 329 - }}, 330 - {"cfgkey/500-error", "/cfg/{key}", "POST", func(t *testing.T) { 331 - rec := req{method: "POST", path: "/cfg/mykey"}.do(newServer(func(d *server.Deps) { d.Store = errStore{} })) 332 - wantStatus(t, rec, 500) 333 - }}, 334 - 335 - // ------------------------------------------------------------------- 336 - // POST /notify/{key} 337 - // ------------------------------------------------------------------- 338 - {"notifykey/200", "/notify/{key}", "POST", func(t *testing.T) { 339 - st := store.NewMemory() 340 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 341 - body := jsonBody(t, map[string]any{"body": "hi"}) 342 - rec := req{method: "POST", path: "/notify/mykey", contentType: ctJSON, accept: ctJSON, body: body}. 343 - do(newServer(func(d *server.Deps) { d.Store = st })) 344 - wantStatus(t, rec, 200) 345 - validateSchema(t, "/notify/{key}", "POST", "200", ctJSON, rec.Body.Bytes()) 346 - }}, 347 - {"notifykey/204-absent", "/notify/{key}", "POST", func(t *testing.T) { 348 - body := jsonBody(t, map[string]any{"body": "hi"}) 349 - rec := req{method: "POST", path: "/notify/missing", contentType: ctJSON, body: body}.do(newServer()) 350 - wantStatus(t, rec, 204) 351 - }}, 352 - {"notifykey/406-recursion", "/notify/{key}", "POST", func(t *testing.T) { 353 - st := store.NewMemory() 354 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 355 - body := jsonBody(t, map[string]any{"body": "hi"}) 356 - rec := req{method: "POST", path: "/notify/mykey", contentType: ctJSON, body: body, 357 - headers: map[string]string{"X-Apprise-Recursion-Count": "5"}}. 358 - do(newServer(func(d *server.Deps) { d.Store = st })) 359 - wantStatus(t, rec, 406) 360 - }}, 361 - {"notifykey/424-failure", "/notify/{key}", "POST", func(t *testing.T) { 362 - st := store.NewMemory() 363 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 364 - body := jsonBody(t, map[string]any{"body": "hi"}) 365 - rec := req{method: "POST", path: "/notify/mykey", contentType: ctJSON, accept: ctJSON, body: body}. 366 - do(newServer(func(d *server.Deps) { d.Store = st; d.Notifier = failNotifier() })) 367 - wantStatus(t, rec, 424) 368 - }}, 369 - {"notifykey/431-large", "/notify/{key}", "POST", func(t *testing.T) { 370 - st := store.NewMemory() 371 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/token"}) 372 - big := strings.Repeat("x", 4096) 373 - body := jsonBody(t, map[string]any{"body": big}) 374 - rec := req{method: "POST", path: "/notify/mykey", contentType: ctJSON, body: body}. 375 - do(newServer(func(d *server.Deps) { d.Store = st; d.MaxBodyBytes = 64 })) 376 - wantStatus(t, rec, 431) 377 - }}, 378 - 379 - // ------------------------------------------------------------------- 380 - // GET /json/urls/{key} 381 - // ------------------------------------------------------------------- 382 - {"jsonurls/200", "/json/urls/{key}", "GET", func(t *testing.T) { 383 - st := store.NewMemory() 384 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "alerts=gotify://host/token"}) 385 - rec := req{method: "GET", path: "/json/urls/mykey", accept: ctJSON}.do(newServer(func(d *server.Deps) { d.Store = st })) 386 - wantStatus(t, rec, 200) 387 - wantContentType(t, rec, ctJSON) 388 - validateSchema(t, "/json/urls/{key}", "GET", "200", ctJSON, rec.Body.Bytes()) 389 - }}, 390 - {"jsonurls/200-privacy", "/json/urls/{key}", "GET", func(t *testing.T) { 391 - st := store.NewMemory() 392 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: "gotify://host/SECRETTOKEN"}) 393 - rec := req{method: "GET", path: "/json/urls/mykey?privacy=1", accept: ctJSON}. 394 - do(newServer(func(d *server.Deps) { d.Store = st })) 395 - wantStatus(t, rec, 200) 396 - if strings.Contains(rec.Body.String(), "SECRETTOKEN") { 397 - t.Error("privacy=1 should redact secrets, but SECRETTOKEN is present") 398 - } 399 - }}, 400 - {"jsonurls/200-tagfilter", "/json/urls/{key}", "GET", func(t *testing.T) { 401 - st := store.NewMemory() 402 - cfg := "alerts=gotify://host/token1\nteam=discord://id/token2" 403 - seed(t, st, store.Entry{Key: "mykey", Format: "text", Config: cfg}) 404 - rec := req{method: "GET", path: "/json/urls/mykey?tag=alerts", accept: ctJSON}. 405 - do(newServer(func(d *server.Deps) { d.Store = st })) 406 - wantStatus(t, rec, 200) 407 - body := rec.Body.String() 408 - if !strings.Contains(body, "token1") || strings.Contains(body, "token2") { 409 - t.Errorf("tag=alerts should return only the alerts URL; body=%q", truncate(body, 300)) 410 - } 411 - }}, 412 - {"jsonurls/204-absent", "/json/urls/{key}", "GET", func(t *testing.T) { 413 - rec := req{method: "GET", path: "/json/urls/missing", accept: ctJSON}.do(newServer()) 414 - wantStatus(t, rec, 204) 184 + {"jsonurls/404", "/json/urls/{key}", "GET", func(t *testing.T) { 185 + wantStatus(t, req{method: "GET", path: "/json/urls/mykey"}.do(newServer()), 404) 415 186 }}, 416 187 }
-24
internal/server/conformance_test.go
··· 15 15 "bytes" 16 16 "context" 17 17 "encoding/json" 18 - "errors" 19 18 "io" 20 19 "mime/multipart" 21 20 "net/http" ··· 25 24 26 25 "git.fogtype.com/nebel/apprize/internal/notify" 27 26 "git.fogtype.com/nebel/apprize/internal/server" 28 - "git.fogtype.com/nebel/apprize/internal/store" 29 27 "github.com/getkin/kin-openapi/openapi3" 30 28 ) 31 29 ··· 76 74 }) 77 75 } 78 76 79 - var errBoom = errors.New("boom") 80 - 81 - // errStore fails every operation (drives the 500 path). 82 - type errStore struct{} 83 - 84 - func (errStore) Get(context.Context, string) (store.Entry, bool, error) { 85 - return store.Entry{}, false, errBoom 86 - } 87 - func (errStore) Put(context.Context, store.Entry) error { return errBoom } 88 - func (errStore) Delete(context.Context, string) (bool, error) { return false, errBoom } 89 - func (errStore) List(context.Context) ([]string, error) { return nil, errBoom } 90 - func (errStore) Close() error { return nil } 91 - 92 77 // --------------------------------------------------------------------------- 93 78 // Server construction 94 79 // --------------------------------------------------------------------------- ··· 97 82 func baseDeps() server.Deps { 98 83 return server.Deps{ 99 84 Notifier: okNotifier(), 100 - Store: store.NewMemory(), 101 85 RecursionMax: 1, 102 86 Version: "test", 103 87 } ··· 110 94 m(&d) 111 95 } 112 96 return server.New(d) 113 - } 114 - 115 - // seed stores an entry in the given store, failing the test on error. 116 - func seed(t *testing.T, st store.Store, e store.Entry) { 117 - t.Helper() 118 - if err := st.Put(context.Background(), e); err != nil { 119 - t.Fatalf("seeding store: %v", err) 120 - } 121 97 } 122 98 123 99 // ---------------------------------------------------------------------------
-52
internal/server/default_config_id_test.go
··· 1 - package server_test 2 - 3 - import ( 4 - "strings" 5 - "testing" 6 - 7 - "git.fogtype.com/nebel/apprize/internal/server" 8 - ) 9 - 10 - func TestDefaultConfigID_KeylessPersistentRoutes(t *testing.T) { 11 - body := jsonBody(t, map[string]any{"config": "gotify://host/token", "format": "text"}) 12 - h := newServer(func(d *server.Deps) { 13 - d.DefaultConfigID = "mydefault" 14 - }) 15 - 16 - addRec := req{method: "POST", path: "/add", contentType: ctJSON, accept: ctJSON, body: body}.do(h) 17 - wantStatus(t, addRec, 200) 18 - 19 - getRec := req{method: "POST", path: "/get", accept: ctText}.do(h) 20 - wantStatus(t, getRec, 200) 21 - if !strings.Contains(getRec.Body.String(), "gotify://host/token") { 22 - t.Fatalf("keyless /get response = %q", getRec.Body.String()) 23 - } 24 - 25 - cfgRec := req{method: "POST", path: "/cfg", accept: ctText}.do(h) 26 - wantStatus(t, cfgRec, 200) 27 - if !strings.Contains(cfgRec.Body.String(), "gotify://host/token") { 28 - t.Fatalf("keyless /cfg response = %q", cfgRec.Body.String()) 29 - } 30 - 31 - delRec := req{method: "POST", path: "/del"}.do(h) 32 - wantStatus(t, delRec, 200) 33 - 34 - getAfterDelete := req{method: "POST", path: "/get"}.do(h) 35 - wantStatus(t, getAfterDelete, 204) 36 - } 37 - 38 - func TestDefaultConfigID_UsesFallbackWhenUnset(t *testing.T) { 39 - body := jsonBody(t, map[string]any{"config": "gotify://host/token", "format": "text"}) 40 - h := newServer(func(d *server.Deps) { 41 - d.DefaultConfigID = "" 42 - }) 43 - 44 - addRec := req{method: "POST", path: "/add", contentType: ctJSON, body: body}.do(h) 45 - wantStatus(t, addRec, 200) 46 - 47 - getRec := req{method: "POST", path: "/get", accept: ctText}.do(h) 48 - wantStatus(t, getRec, 200) 49 - if !strings.Contains(getRec.Body.String(), "gotify://host/token") { 50 - t.Fatalf("keyless /get response = %q", getRec.Body.String()) 51 - } 52 - }
+8 -43
internal/server/meta.go
··· 5 5 "html" 6 6 "net/http" 7 7 "strings" 8 - 9 - "git.fogtype.com/nebel/apprize/internal/notify" 10 8 ) 11 9 12 10 // GET /status 13 11 func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { 14 12 mt := negotiate(r, mediaJSON, mediaText) 15 13 16 - // Build status response. Attachments are always unsupported in this build, 17 - // so attach_lock is effectively always true regardless of external config. 18 - attachLock := true 19 - persistentStorage := true 20 - canWriteConfig := !s.ConfigLock 21 - canWriteAttach := false 22 - 23 - // Check if store is accessible (simple check - try listing) 24 - var storeErr error 25 - if s.Store == nil { 26 - storeErr = fmt.Errorf("no store configured") 27 - } else { 28 - _, storeErr = s.Store.List(r.Context()) 29 - } 30 - if storeErr != nil { 31 - err := storeErr 32 - if mt == mediaText { 33 - writeText(w, http.StatusExpectationFailed, "CONFIG_PERMISSION_ISSUE") 34 - } else { 35 - writeJSON(w, http.StatusExpectationFailed, map[string]any{ 36 - "attach_lock": attachLock, 37 - "config_lock": s.ConfigLock, 38 - "status": map[string]any{ 39 - "persistent_storage": false, 40 - "can_write_config": false, 41 - "can_write_attach": false, 42 - "details": []string{"store error: " + err.Error()}, 43 - }, 44 - }) 45 - } 46 - return 47 - } 48 - 49 14 if mt == mediaText { 50 15 writeText(w, http.StatusOK, "OK") 51 16 return 52 17 } 53 18 54 19 writeJSON(w, http.StatusOK, map[string]any{ 55 - "attach_lock": attachLock, 56 - "config_lock": s.ConfigLock, 20 + "attach_lock": true, 21 + "config_lock": false, 57 22 "status": map[string]any{ 58 - "persistent_storage": persistentStorage, 59 - "can_write_config": canWriteConfig, 60 - "can_write_attach": canWriteAttach, 61 - "details": []string{fmt.Sprintf("apprize %s", s.Version), fmt.Sprintf("schemas: %d", len(notify.SupportedSchemas()))}, 23 + "persistent_storage": false, 24 + "can_write_config": false, 25 + "can_write_attach": false, 26 + "details": []string{fmt.Sprintf("apprize %s", s.Version), fmt.Sprintf("schemas: %d", len(s.schemas))}, 62 27 }, 63 28 }) 64 29 } ··· 71 36 var b strings.Builder 72 37 b.WriteString("<html><head><title>Apprise Details</title></head><body>") 73 38 b.WriteString("<h1>Supported Notification Services</h1><ul>") 74 - for _, schema := range notify.SupportedSchemas() { 39 + for _, schema := range s.schemas { 75 40 fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(schema)) 76 41 } 77 42 b.WriteString("</ul></body></html>") ··· 81 46 82 47 writeJSON(w, http.StatusOK, map[string]any{ 83 48 "version": s.Version, 84 - "schemas": notify.SupportedSchemas(), 49 + "schemas": s.schemas, 85 50 }) 86 51 }
+214
internal/server/notify.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "html" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "git.fogtype.com/nebel/apprize/internal/appcfg" 12 + "git.fogtype.com/nebel/apprize/internal/notify" 13 + ) 14 + 15 + type notifyPayload struct { 16 + Body string `json:"body"` 17 + Title string `json:"title"` 18 + Type string `json:"type"` 19 + Format string `json:"format"` 20 + Tag string `json:"tag"` 21 + URLs any `json:"urls"` 22 + } 23 + 24 + func (s *server) handleNotify(w http.ResponseWriter, r *http.Request) { 25 + payload, err := parseNotifyPayload(r) 26 + if err != nil { 27 + http.Error(w, err.Error(), http.StatusBadRequest) 28 + return 29 + } 30 + if strings.TrimSpace(payload.Body) == "" { 31 + http.Error(w, "body is required", http.StatusBadRequest) 32 + return 33 + } 34 + 35 + urls := normalizeURLs(payload.URLs) 36 + if len(urls) == 0 { 37 + urls = append(urls, s.StatelessURLs...) 38 + } 39 + urls = s.filterURLs(urls) 40 + if len(urls) == 0 { 41 + w.WriteHeader(http.StatusNoContent) 42 + return 43 + } 44 + s.respondNotify(w, r, payload, urls) 45 + } 46 + 47 + func (s *server) respondNotify(w http.ResponseWriter, r *http.Request, payload notifyPayload, urls []string) { 48 + opts := notify.Options{ 49 + Title: strings.TrimSpace(payload.Title), 50 + Type: strings.TrimSpace(payload.Type), 51 + InputFormat: strings.TrimSpace(payload.Format), 52 + } 53 + results, err := s.Notifier.Notify(r.Context(), urls, payload.Body, opts) 54 + if err != nil { 55 + results = append(results, notify.Result{ 56 + URL: "", 57 + Service: "", 58 + OK: false, 59 + Message: err.Error(), 60 + }) 61 + } 62 + 63 + now := time.Now().Format("2006-01-02 15:04:05,000") 64 + 65 + details := make([][]string, 0, len(results)) 66 + failed := false 67 + for _, res := range results { 68 + if res.OK { 69 + details = append(details, []string{"info", now, "Sent " + res.URL}) 70 + continue 71 + } 72 + failed = true 73 + msg := strings.TrimSpace(res.Message) 74 + if msg == "" { 75 + msg = "failed" 76 + } 77 + if res.URL != "" { 78 + msg = "Failed " + res.URL + ": " + msg 79 + } 80 + details = append(details, []string{"error", now, msg}) 81 + } 82 + 83 + resp := map[string]any{ 84 + "error": nil, 85 + "details": details, 86 + } 87 + status := http.StatusOK 88 + if failed { 89 + status = http.StatusFailedDependency 90 + resp["error"] = "One or more notifications could not be sent" 91 + } 92 + 93 + mt := negotiate(r, mediaJSON, mediaText, mediaHTML) 94 + switch mt { 95 + case mediaText: 96 + lines := make([]string, 0, len(details)) 97 + for _, d := range details { 98 + if len(d) >= 3 { 99 + lines = append(lines, fmt.Sprintf("%s - %s - %s", d[1], strings.ToUpper(d[0]), d[2])) 100 + } else { 101 + lines = append(lines, strings.Join(d, ": ")) 102 + } 103 + } 104 + writeText(w, status, strings.Join(lines, "\n")) 105 + case mediaHTML: 106 + var b strings.Builder 107 + b.WriteString("<html><body><ul>") 108 + for _, d := range details { 109 + if len(d) >= 3 { 110 + fmt.Fprintf(&b, "<li>%s - %s - %s</li>", html.EscapeString(d[1]), html.EscapeString(strings.ToUpper(d[0])), html.EscapeString(d[2])) 111 + } else { 112 + fmt.Fprintf(&b, "<li>%s</li>", html.EscapeString(strings.Join(d, ": "))) 113 + } 114 + } 115 + b.WriteString("</ul></body></html>") 116 + writeHTML(w, status, b.String()) 117 + default: 118 + writeJSON(w, status, resp) 119 + } 120 + } 121 + 122 + func parseNotifyPayload(r *http.Request) (notifyPayload, error) { 123 + var p notifyPayload 124 + ct := strings.ToLower(r.Header.Get("Content-Type")) 125 + switch { 126 + case strings.HasPrefix(ct, "application/json"): 127 + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { 128 + return p, fmt.Errorf("invalid json") 129 + } 130 + case strings.HasPrefix(ct, "application/x-www-form-urlencoded"), strings.HasPrefix(ct, "multipart/form-data"): 131 + var err error 132 + if strings.HasPrefix(ct, "multipart/form-data") { 133 + err = r.ParseMultipartForm(32 << 20) 134 + } else { 135 + err = r.ParseForm() 136 + } 137 + if err != nil { 138 + return p, fmt.Errorf("invalid form") 139 + } 140 + p.Body = r.FormValue("body") 141 + p.Title = r.FormValue("title") 142 + p.Type = r.FormValue("type") 143 + p.Format = r.FormValue("format") 144 + p.Tag = r.FormValue("tag") 145 + p.URLs = r.FormValue("urls") 146 + default: 147 + return p, fmt.Errorf("unsupported content type") 148 + } 149 + return p, nil 150 + } 151 + 152 + func normalizeURLs(raw any) []string { 153 + switch v := raw.(type) { 154 + case nil: 155 + return nil 156 + case []string: 157 + out := make([]string, 0, len(v)) 158 + for _, s := range v { 159 + out = append(out, splitList(s)...) 160 + } 161 + return out 162 + case []any: 163 + out := make([]string, 0, len(v)) 164 + for _, it := range v { 165 + if s, ok := it.(string); ok { 166 + out = append(out, splitList(s)...) 167 + } 168 + } 169 + return out 170 + case string: 171 + return splitList(v) 172 + default: 173 + return nil 174 + } 175 + } 176 + 177 + func splitList(s string) []string { 178 + if strings.TrimSpace(s) == "" { 179 + return nil 180 + } 181 + parts := strings.FieldsFunc(s, func(r rune) bool { 182 + return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' 183 + }) 184 + out := make([]string, 0, len(parts)) 185 + for _, p := range parts { 186 + if p = strings.TrimSpace(p); p != "" { 187 + out = append(out, p) 188 + } 189 + } 190 + return out 191 + } 192 + 193 + func (s *server) filterURLs(urls []string) []string { 194 + if len(urls) == 0 { 195 + return nil 196 + } 197 + if len(s.allowSet) == 0 && len(s.denySet) == 0 { 198 + return urls 199 + } 200 + filtered := make([]string, 0, len(urls)) 201 + for _, raw := range urls { 202 + sc := appcfg.Scheme(raw) 203 + if len(s.allowSet) > 0 { 204 + if _, ok := s.allowSet[sc]; !ok { 205 + continue 206 + } 207 + } 208 + if _, blocked := s.denySet[sc]; blocked { 209 + continue 210 + } 211 + filtered = append(filtered, raw) 212 + } 213 + return filtered 214 + }
-573
internal/server/persistent.go
··· 1 - package server 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "html" 7 - "net/http" 8 - "regexp" 9 - "sort" 10 - "strings" 11 - "time" 12 - 13 - "git.fogtype.com/nebel/apprize/internal/appcfg" 14 - "git.fogtype.com/nebel/apprize/internal/notify" 15 - "git.fogtype.com/nebel/apprize/internal/store" 16 - ) 17 - 18 - var keyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) 19 - 20 - type configPayload struct { 21 - Config string `json:"config"` 22 - Format string `json:"format"` 23 - URLs any `json:"urls"` 24 - } 25 - 26 - type notifyPayload struct { 27 - Body string `json:"body"` 28 - Title string `json:"title"` 29 - Type string `json:"type"` 30 - Format string `json:"format"` 31 - Tag string `json:"tag"` 32 - URLs any `json:"urls"` 33 - } 34 - 35 - type parsedConfigEntry struct { 36 - URL string 37 - Tags []string 38 - } 39 - 40 - func (s *server) handleListConfigs(w http.ResponseWriter, r *http.Request) { 41 - if !s.Admin { 42 - http.Error(w, "forbidden", http.StatusForbidden) 43 - return 44 - } 45 - keys, err := s.Store.List(r.Context()) 46 - if err != nil { 47 - http.Error(w, "store error", http.StatusInternalServerError) 48 - return 49 - } 50 - if negotiate(r, mediaJSON, mediaHTML) == mediaHTML { 51 - var b strings.Builder 52 - b.WriteString("<html><body><ul>") 53 - for _, k := range keys { 54 - fmt.Fprintf(&b, "<li>%s</li>", html.EscapeString(k)) 55 - } 56 - b.WriteString("</ul></body></html>") 57 - writeHTML(w, http.StatusOK, b.String()) 58 - return 59 - } 60 - writeJSON(w, http.StatusOK, keys) 61 - } 62 - 63 - func (s *server) handleAddConfig(w http.ResponseWriter, r *http.Request) { 64 - if s.ConfigLock { 65 - http.Error(w, "forbidden", http.StatusForbidden) 66 - return 67 - } 68 - key := s.configKey(r) 69 - if !keyPattern.MatchString(key) { 70 - http.Error(w, "invalid key", http.StatusBadRequest) 71 - return 72 - } 73 - 74 - cfg, format, err := parseAddPayload(r) 75 - if err != nil { 76 - http.Error(w, err.Error(), http.StatusBadRequest) 77 - return 78 - } 79 - if err := validateConfig(format, cfg); err != nil { 80 - http.Error(w, err.Error(), http.StatusBadRequest) 81 - return 82 - } 83 - 84 - if err := s.Store.Put(r.Context(), store.Entry{ 85 - Key: key, 86 - Format: format, 87 - Config: cfg, 88 - UpdatedAt: time.Now().Unix(), 89 - }); err != nil { 90 - http.Error(w, "store error", http.StatusInternalServerError) 91 - return 92 - } 93 - 94 - if negotiate(r, mediaJSON, mediaText) == mediaJSON { 95 - writeJSON(w, http.StatusOK, map[string]any{"error": nil}) 96 - return 97 - } 98 - writeText(w, http.StatusOK, "Successfully saved configuration") 99 - } 100 - 101 - func (s *server) handleDeleteConfig(w http.ResponseWriter, r *http.Request) { 102 - if s.ConfigLock { 103 - http.Error(w, "forbidden", http.StatusForbidden) 104 - return 105 - } 106 - ok, err := s.Store.Delete(r.Context(), s.configKey(r)) 107 - if err != nil { 108 - http.Error(w, "store error", http.StatusInternalServerError) 109 - return 110 - } 111 - if !ok { 112 - w.WriteHeader(http.StatusNoContent) 113 - return 114 - } 115 - w.WriteHeader(http.StatusOK) 116 - } 117 - 118 - func (s *server) handleGetConfig(w http.ResponseWriter, r *http.Request) { 119 - if s.ConfigLock { 120 - http.Error(w, "forbidden", http.StatusForbidden) 121 - return 122 - } 123 - entry, ok, err := s.Store.Get(r.Context(), s.configKey(r)) 124 - if err != nil { 125 - http.Error(w, "store error", http.StatusInternalServerError) 126 - return 127 - } 128 - if !ok || strings.TrimSpace(entry.Config) == "" { 129 - w.WriteHeader(http.StatusNoContent) 130 - return 131 - } 132 - 133 - mt := negotiate(r, mediaJSON, mediaText) 134 - if mt == mediaText { 135 - if strings.EqualFold(entry.Format, "yaml") { 136 - w.Header().Set("Content-Type", "text/yaml; charset=utf-8") 137 - w.WriteHeader(http.StatusOK) 138 - _, _ = w.Write([]byte(entry.Config)) 139 - return 140 - } 141 - writeText(w, http.StatusOK, entry.Config) 142 - return 143 - } 144 - writeJSON(w, http.StatusOK, map[string]string{ 145 - "format": entry.Format, 146 - "config": entry.Config, 147 - }) 148 - } 149 - 150 - func (s *server) handleNotifyByKey(w http.ResponseWriter, r *http.Request) { 151 - payload, err := parseNotifyPayload(r) 152 - if err != nil { 153 - http.Error(w, err.Error(), http.StatusBadRequest) 154 - return 155 - } 156 - if strings.TrimSpace(payload.Body) == "" { 157 - http.Error(w, "body is required", http.StatusBadRequest) 158 - return 159 - } 160 - 161 - entry, ok, err := s.Store.Get(r.Context(), s.configKey(r)) 162 - if err != nil { 163 - http.Error(w, "store error", http.StatusInternalServerError) 164 - return 165 - } 166 - if !ok { 167 - w.WriteHeader(http.StatusNoContent) 168 - return 169 - } 170 - entries, err := parseStoredConfig(entry.Format, entry.Config) 171 - if err != nil { 172 - http.Error(w, "invalid stored config", http.StatusInternalServerError) 173 - return 174 - } 175 - tagExpr := payload.Tag 176 - if strings.TrimSpace(tagExpr) == "" { 177 - tagExpr = "all" 178 - } 179 - urls := make([]string, 0, len(entries)) 180 - for _, e := range entries { 181 - if matchTagExpr(tagExpr, e.Tags) { 182 - urls = append(urls, e.URL) 183 - } 184 - } 185 - urls = s.filterURLs(urls) 186 - if len(urls) == 0 { 187 - w.WriteHeader(http.StatusNoContent) 188 - return 189 - } 190 - s.respondNotify(w, r, payload, urls) 191 - } 192 - 193 - func (s *server) handleJSONURLs(w http.ResponseWriter, r *http.Request) { 194 - entry, ok, err := s.Store.Get(r.Context(), s.configKey(r)) 195 - if err != nil { 196 - http.Error(w, "store error", http.StatusInternalServerError) 197 - return 198 - } 199 - if !ok { 200 - w.WriteHeader(http.StatusNoContent) 201 - return 202 - } 203 - entries, err := parseStoredConfig(entry.Format, entry.Config) 204 - if err != nil || len(entries) == 0 { 205 - w.WriteHeader(http.StatusNoContent) 206 - return 207 - } 208 - 209 - tagExpr := r.URL.Query().Get("tag") 210 - if strings.TrimSpace(tagExpr) == "" { 211 - tagExpr = "all" 212 - } 213 - privacy := r.URL.Query().Get("privacy") == "1" 214 - 215 - filtered := make([]parsedConfigEntry, 0, len(entries)) 216 - tagSet := map[string]struct{}{} 217 - for _, e := range entries { 218 - if !matchTagExpr(tagExpr, e.Tags) { 219 - continue 220 - } 221 - filtered = append(filtered, e) 222 - for _, t := range e.Tags { 223 - tagSet[t] = struct{}{} 224 - } 225 - } 226 - if len(filtered) == 0 { 227 - w.WriteHeader(http.StatusNoContent) 228 - return 229 - } 230 - 231 - tags := make([]string, 0, len(tagSet)) 232 - for t := range tagSet { 233 - tags = append(tags, t) 234 - } 235 - sort.Strings(tags) 236 - 237 - urlRows := make([]map[string]any, 0, len(filtered)) 238 - for _, e := range filtered { 239 - u := e.URL 240 - if privacy { 241 - u = maskURL(u) 242 - } 243 - tagDetails := make([]map[string]string, 0, len(e.Tags)) 244 - for _, t := range e.Tags { 245 - tagDetails = append(tagDetails, map[string]string{"name": t}) 246 - } 247 - urlRows = append(urlRows, map[string]any{ 248 - "id": appcfg.URLID(e.URL), 249 - "service_name": appcfg.ServiceName(e.URL), 250 - "enabled": true, 251 - "url": u, 252 - "tags": e.Tags, 253 - "tag_details": tagDetails, 254 - }) 255 - } 256 - 257 - writeJSON(w, http.StatusOK, map[string]any{ 258 - "tags": tags, 259 - "urls": urlRows, 260 - }) 261 - } 262 - 263 - func (s *server) handleNotify(w http.ResponseWriter, r *http.Request) { 264 - payload, err := parseNotifyPayload(r) 265 - if err != nil { 266 - http.Error(w, err.Error(), http.StatusBadRequest) 267 - return 268 - } 269 - if strings.TrimSpace(payload.Body) == "" { 270 - http.Error(w, "body is required", http.StatusBadRequest) 271 - return 272 - } 273 - 274 - urls := normalizeURLs(payload.URLs) 275 - if len(urls) == 0 { 276 - urls = append(urls, s.StatelessURLs...) 277 - } 278 - urls = s.filterURLs(urls) 279 - if len(urls) == 0 { 280 - w.WriteHeader(http.StatusNoContent) 281 - return 282 - } 283 - s.respondNotify(w, r, payload, urls) 284 - } 285 - 286 - func (s *server) respondNotify(w http.ResponseWriter, r *http.Request, payload notifyPayload, urls []string) { 287 - opts := notify.Options{ 288 - Title: strings.TrimSpace(payload.Title), 289 - Type: strings.TrimSpace(payload.Type), 290 - InputFormat: strings.TrimSpace(payload.Format), 291 - } 292 - results, err := s.Notifier.Notify(r.Context(), urls, payload.Body, opts) 293 - if err != nil { 294 - results = append(results, notify.Result{ 295 - URL: "", 296 - Service: "", 297 - OK: false, 298 - Message: err.Error(), 299 - }) 300 - } 301 - 302 - now := time.Now().Format("2006-01-02 15:04:05,000") 303 - 304 - details := make([][]string, 0, len(results)) 305 - failed := false 306 - for _, res := range results { 307 - if res.OK { 308 - details = append(details, []string{"info", now, "Sent " + res.URL}) 309 - continue 310 - } 311 - failed = true 312 - msg := strings.TrimSpace(res.Message) 313 - if msg == "" { 314 - msg = "failed" 315 - } 316 - if res.URL != "" { 317 - msg = "Failed " + res.URL + ": " + msg 318 - } 319 - details = append(details, []string{"error", now, msg}) 320 - } 321 - 322 - resp := map[string]any{ 323 - "error": nil, 324 - "details": details, 325 - } 326 - status := http.StatusOK 327 - if failed { 328 - status = http.StatusFailedDependency 329 - resp["error"] = "One or more notifications could not be sent" 330 - } 331 - 332 - mt := negotiate(r, mediaJSON, mediaText, mediaHTML) 333 - switch mt { 334 - case mediaText: 335 - lines := make([]string, 0, len(details)) 336 - for _, d := range details { 337 - if len(d) >= 3 { 338 - lines = append(lines, fmt.Sprintf("%s - %s - %s", d[1], strings.ToUpper(d[0]), d[2])) 339 - } else { 340 - lines = append(lines, strings.Join(d, ": ")) 341 - } 342 - } 343 - writeText(w, status, strings.Join(lines, "\n")) 344 - case mediaHTML: 345 - var b strings.Builder 346 - b.WriteString("<html><body><ul>") 347 - for _, d := range details { 348 - if len(d) >= 3 { 349 - fmt.Fprintf(&b, "<li>%s - %s - %s</li>", html.EscapeString(d[1]), html.EscapeString(strings.ToUpper(d[0])), html.EscapeString(d[2])) 350 - } else { 351 - fmt.Fprintf(&b, "<li>%s</li>", html.EscapeString(strings.Join(d, ": "))) 352 - } 353 - } 354 - b.WriteString("</ul></body></html>") 355 - writeHTML(w, status, b.String()) 356 - default: 357 - writeJSON(w, status, resp) 358 - } 359 - } 360 - 361 - func parseAddPayload(r *http.Request) (cfg string, format string, err error) { 362 - format = "text" 363 - ct := strings.ToLower(r.Header.Get("Content-Type")) 364 - switch { 365 - case strings.HasPrefix(ct, "application/json"): 366 - var p configPayload 367 - if err = json.NewDecoder(r.Body).Decode(&p); err != nil { 368 - return "", "", fmt.Errorf("invalid json") 369 - } 370 - cfg = strings.TrimSpace(p.Config) 371 - format = normalizeFormat(p.Format) 372 - if cfg == "" { 373 - cfg = urlsToConfig(normalizeURLs(p.URLs)) 374 - } 375 - case strings.HasPrefix(ct, "application/x-www-form-urlencoded"), strings.HasPrefix(ct, "multipart/form-data"): 376 - if strings.HasPrefix(ct, "multipart/form-data") { 377 - err = r.ParseMultipartForm(32 << 20) 378 - } else { 379 - err = r.ParseForm() 380 - } 381 - if err != nil { 382 - return "", "", fmt.Errorf("invalid form") 383 - } 384 - cfg = strings.TrimSpace(r.FormValue("config")) 385 - format = normalizeFormat(r.FormValue("format")) 386 - if cfg == "" { 387 - cfg = urlsToConfig(normalizeURLs(r.FormValue("urls"))) 388 - } 389 - default: 390 - return "", "", fmt.Errorf("unsupported content type") 391 - } 392 - if cfg == "" { 393 - return "", "", fmt.Errorf("config is required") 394 - } 395 - if format == "auto" { 396 - format = detectFormat(cfg) 397 - } 398 - if format == "" { 399 - format = "text" 400 - } 401 - return cfg, format, nil 402 - } 403 - 404 - func parseNotifyPayload(r *http.Request) (notifyPayload, error) { 405 - var p notifyPayload 406 - ct := strings.ToLower(r.Header.Get("Content-Type")) 407 - switch { 408 - case strings.HasPrefix(ct, "application/json"): 409 - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { 410 - return p, fmt.Errorf("invalid json") 411 - } 412 - case strings.HasPrefix(ct, "application/x-www-form-urlencoded"), strings.HasPrefix(ct, "multipart/form-data"): 413 - var err error 414 - if strings.HasPrefix(ct, "multipart/form-data") { 415 - err = r.ParseMultipartForm(32 << 20) 416 - } else { 417 - err = r.ParseForm() 418 - } 419 - if err != nil { 420 - return p, fmt.Errorf("invalid form") 421 - } 422 - p.Body = r.FormValue("body") 423 - p.Title = r.FormValue("title") 424 - p.Type = r.FormValue("type") 425 - p.Format = r.FormValue("format") 426 - p.Tag = r.FormValue("tag") 427 - p.URLs = r.FormValue("urls") 428 - default: 429 - return p, fmt.Errorf("unsupported content type") 430 - } 431 - return p, nil 432 - } 433 - 434 - func normalizeURLs(raw any) []string { 435 - switch v := raw.(type) { 436 - case nil: 437 - return nil 438 - case []string: 439 - return splitList(strings.Join(v, ",")) 440 - case []any: 441 - items := make([]string, 0, len(v)) 442 - for _, it := range v { 443 - if s, ok := it.(string); ok { 444 - items = append(items, s) 445 - } 446 - } 447 - return splitList(strings.Join(items, ",")) 448 - case string: 449 - return splitList(v) 450 - default: 451 - return nil 452 - } 453 - } 454 - 455 - func splitList(s string) []string { 456 - if strings.TrimSpace(s) == "" { 457 - return nil 458 - } 459 - parts := strings.FieldsFunc(s, func(r rune) bool { 460 - return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' ' 461 - }) 462 - out := make([]string, 0, len(parts)) 463 - for _, p := range parts { 464 - if p = strings.TrimSpace(p); p != "" { 465 - out = append(out, p) 466 - } 467 - } 468 - return out 469 - } 470 - 471 - func urlsToConfig(urls []string) string { 472 - if len(urls) == 0 { 473 - return "" 474 - } 475 - return strings.Join(urls, "\n") 476 - } 477 - 478 - func normalizeFormat(s string) string { 479 - switch strings.ToLower(strings.TrimSpace(s)) { 480 - case "yaml", "text", "auto": 481 - return strings.ToLower(strings.TrimSpace(s)) 482 - case "": 483 - return "" 484 - default: 485 - return "" 486 - } 487 - } 488 - 489 - func detectFormat(cfg string) string { 490 - for _, ln := range strings.Split(cfg, "\n") { 491 - ln = strings.TrimSpace(ln) 492 - if ln == "" || strings.HasPrefix(ln, "#") { 493 - continue 494 - } 495 - if strings.Contains(ln, ":") && !strings.Contains(ln, "://") { 496 - return "yaml" 497 - } 498 - break 499 - } 500 - return "text" 501 - } 502 - 503 - func validateConfig(format, cfg string) error { 504 - if format != "text" && format != "yaml" { 505 - return fmt.Errorf("invalid format") 506 - } 507 - entries, err := appcfg.Parse(format, cfg) 508 - if err != nil || len(entries) == 0 { 509 - return fmt.Errorf("invalid %s", format) 510 - } 511 - return nil 512 - } 513 - 514 - func parseStoredConfig(format, cfg string) ([]parsedConfigEntry, error) { 515 - entries, err := appcfg.Parse(format, cfg) 516 - if err != nil { 517 - return nil, err 518 - } 519 - out := make([]parsedConfigEntry, 0, len(entries)) 520 - for _, e := range entries { 521 - out = append(out, parsedConfigEntry{URL: e.URL, Tags: e.Tags}) 522 - } 523 - return out, nil 524 - } 525 - 526 - func matchTagExpr(expr string, tags []string) bool { 527 - return appcfg.Match(appcfg.ParseTagExpr(expr), tags) 528 - } 529 - 530 - func maskURL(raw string) string { 531 - return appcfg.MaskURL(raw) 532 - } 533 - 534 - func (s *server) filterURLs(urls []string) []string { 535 - if len(urls) == 0 { 536 - return nil 537 - } 538 - allow := make(map[string]struct{}, len(s.AllowServices)) 539 - for _, sc := range s.AllowServices { 540 - allow[strings.ToLower(strings.TrimSpace(sc))] = struct{}{} 541 - } 542 - deny := make(map[string]struct{}, len(s.DenyServices)) 543 - for _, sc := range s.DenyServices { 544 - deny[strings.ToLower(strings.TrimSpace(sc))] = struct{}{} 545 - } 546 - 547 - filtered := make([]string, 0, len(urls)) 548 - for _, raw := range urls { 549 - sc := appcfg.Scheme(raw) 550 - if len(allow) > 0 { 551 - if _, ok := allow[sc]; !ok { 552 - continue 553 - } 554 - } 555 - if _, blocked := deny[sc]; blocked { 556 - continue 557 - } 558 - filtered = append(filtered, raw) 559 - } 560 - return filtered 561 - } 562 - 563 - func (s *server) configKey(r *http.Request) string { 564 - key := strings.TrimSpace(r.PathValue("key")) 565 - if key != "" { 566 - return key 567 - } 568 - key = strings.TrimSpace(s.DefaultConfigID) 569 - if key == "" { 570 - return "apprise" 571 - } 572 - return key 573 - }
+27 -34
internal/server/server.go
··· 3 3 4 4 import ( 5 5 "net/http" 6 + "strings" 6 7 7 8 "git.fogtype.com/nebel/apprize/internal/notify" 8 - "git.fogtype.com/nebel/apprize/internal/store" 9 9 ) 10 10 11 11 // Deps holds everything a server instance needs. Tests construct it directly 12 - // with a fake Notifier and an in-memory Store. 12 + // with a fake Notifier. 13 13 type Deps struct { 14 14 Notifier notify.Notifier 15 - Store store.Store 16 15 17 16 // Behaviour toggles (mirrors the relevant apprise-api env vars). 18 - StatelessURLs []string // APPRIZE_STATELESS_URLS 19 - ConfigLock bool // APPRIZE_CONFIG_LOCK 20 - Admin bool // APPRIZE_ADMIN 21 - RecursionMax int // APPRIZE_RECURSION_MAX 22 - DenyServices []string // APPRIZE_DENY_SERVICES 23 - AllowServices []string // APPRIZE_ALLOW_SERVICES 24 - APIKey string // optional simple API secret (empty = no auth) 25 - MaxBodyBytes int64 // request body limit; 0 = sensible default 26 - DefaultConfigID string // APPRIZE_DEFAULT_CONFIG_ID 17 + StatelessURLs []string // APPRIZE_STATELESS_URLS 18 + RecursionMax int // APPRIZE_RECURSION_MAX 19 + DenyServices []string // APPRIZE_DENY_SERVICES 20 + AllowServices []string // APPRIZE_ALLOW_SERVICES 21 + APIKey string // optional simple API secret (empty = no auth) 22 + MaxBodyBytes int64 // request body limit; 0 = sensible default; test-only 27 23 28 24 Version string 29 25 } ··· 31 27 // server is the concrete handler holding dependencies. 32 28 type server struct { 33 29 Deps 30 + allowSet map[string]struct{} 31 + denySet map[string]struct{} 32 + schemas []string 34 33 } 35 34 36 35 // New returns the HTTP handler implementing the Apprise API. 37 36 func New(d Deps) http.Handler { 38 - s := &server{Deps: d} 37 + s := &server{ 38 + Deps: d, 39 + allowSet: buildServiceSet(d.AllowServices), 40 + denySet: buildServiceSet(d.DenyServices), 41 + schemas: notify.SupportedSchemas(), 42 + } 39 43 mux := http.NewServeMux() 40 44 41 - // Meta — P1 42 45 mux.HandleFunc("GET /status", s.handleStatus) 43 46 mux.HandleFunc("GET /details", s.handleDetails) 44 - 45 - // Stateless — P2+ 46 47 mux.HandleFunc("POST /notify", s.handleNotify) 47 48 48 - // Persistent — P4-P5 49 - mux.HandleFunc("GET /cfg", s.handleListConfigs) 50 - mux.HandleFunc("POST /cfg", s.handleGetConfig) 51 - mux.HandleFunc("POST /add", s.handleAddConfig) 52 - mux.HandleFunc("POST /del", s.handleDeleteConfig) 53 - mux.HandleFunc("POST /get", s.handleGetConfig) 54 - mux.HandleFunc("POST /add/{key}", s.handleAddConfig) 55 - mux.HandleFunc("POST /del/{key}", s.handleDeleteConfig) 56 - mux.HandleFunc("POST /get/{key}", s.handleGetConfig) 57 - mux.HandleFunc("POST /cfg/{key}", s.handleGetConfig) 58 - mux.HandleFunc("POST /notify/{key}", s.handleNotifyByKey) 59 - mux.HandleFunc("GET /json/urls/{key}", s.handleJSONURLs) 60 - 61 - return s.withMiddleware(mux) 49 + return withMiddlewareChain(s, mux) 62 50 } 63 51 64 - // withMiddleware composes the cross-cutting middleware chain. 65 - // P1 wires the full stack: requestLog → recover → apiKey → recursion → bodyLimit → handler. 66 - func (s *server) withMiddleware(h http.Handler) http.Handler { 67 - return withMiddlewareChain(s, h) 52 + func buildServiceSet(services []string) map[string]struct{} { 53 + if len(services) == 0 { 54 + return nil 55 + } 56 + m := make(map[string]struct{}, len(services)) 57 + for _, sc := range services { 58 + m[strings.ToLower(strings.TrimSpace(sc))] = struct{}{} 59 + } 60 + return m 68 61 }
-53
internal/store/memory.go
··· 1 - package store 2 - 3 - import ( 4 - "context" 5 - "sort" 6 - "sync" 7 - ) 8 - 9 - // Memory is an in-memory Store used for tests and when DBPath is :memory:. 10 - type Memory struct { 11 - mu sync.RWMutex 12 - entries map[string]Entry 13 - } 14 - 15 - // NewMemory returns an empty in-memory store. 16 - func NewMemory() *Memory { 17 - return &Memory{entries: make(map[string]Entry)} 18 - } 19 - 20 - func (m *Memory) Get(_ context.Context, key string) (Entry, bool, error) { 21 - m.mu.RLock() 22 - defer m.mu.RUnlock() 23 - e, ok := m.entries[key] 24 - return e, ok, nil 25 - } 26 - 27 - func (m *Memory) Put(_ context.Context, e Entry) error { 28 - m.mu.Lock() 29 - defer m.mu.Unlock() 30 - m.entries[e.Key] = e 31 - return nil 32 - } 33 - 34 - func (m *Memory) Delete(_ context.Context, key string) (bool, error) { 35 - m.mu.Lock() 36 - defer m.mu.Unlock() 37 - _, ok := m.entries[key] 38 - delete(m.entries, key) 39 - return ok, nil 40 - } 41 - 42 - func (m *Memory) List(_ context.Context) ([]string, error) { 43 - m.mu.RLock() 44 - defer m.mu.RUnlock() 45 - keys := make([]string, 0, len(m.entries)) 46 - for k := range m.entries { 47 - keys = append(keys, k) 48 - } 49 - sort.Strings(keys) 50 - return keys, nil 51 - } 52 - 53 - func (m *Memory) Close() error { return nil }
-114
internal/store/sqlite.go
··· 1 - package store 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - 8 - _ "modernc.org/sqlite" 9 - ) 10 - 11 - // SQLite is a Store backed by modernc.org/sqlite (pure Go, no cgo). 12 - type SQLite struct { 13 - db *sql.DB 14 - } 15 - 16 - // OpenSQLite opens (creating if needed) a sqlite database at path and runs 17 - // migrations. Pass ":memory:" for an ephemeral database. 18 - func OpenSQLite(path string) (*SQLite, error) { 19 - memory := path == ":memory:" 20 - var dsn string 21 - if memory { 22 - // A bare ":memory:" gives every pooled connection its own private 23 - // database, so the table created during migration is invisible to the 24 - // next connection. A shared cache makes one in-memory database visible 25 - // across the pool. 26 - dsn = "file::memory:?cache=shared" 27 - } else { 28 - // Enable WAL and a busy timeout for concurrent access. 29 - dsn = fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", path) 30 - } 31 - db, err := sql.Open("sqlite", dsn) 32 - if err != nil { 33 - return nil, err 34 - } 35 - if memory { 36 - // Keep at least one connection alive; the shared in-memory database is 37 - // dropped once the last connection closes. 38 - db.SetMaxOpenConns(1) 39 - } 40 - s := &SQLite{db: db} 41 - if err := s.migrate(context.Background()); err != nil { 42 - _ = db.Close() 43 - return nil, err 44 - } 45 - return s, nil 46 - } 47 - 48 - func (s *SQLite) migrate(ctx context.Context) error { 49 - _, err := s.db.ExecContext(ctx, ` 50 - CREATE TABLE IF NOT EXISTS configs ( 51 - key TEXT PRIMARY KEY, 52 - format TEXT NOT NULL, 53 - config TEXT NOT NULL, 54 - updated_at INTEGER NOT NULL 55 - );`) 56 - return err 57 - } 58 - 59 - func (s *SQLite) Get(ctx context.Context, key string) (Entry, bool, error) { 60 - var e Entry 61 - err := s.db.QueryRowContext(ctx, 62 - `SELECT key, format, config, updated_at FROM configs WHERE key = ?`, key). 63 - Scan(&e.Key, &e.Format, &e.Config, &e.UpdatedAt) 64 - if err == sql.ErrNoRows { 65 - return Entry{}, false, nil 66 - } 67 - if err != nil { 68 - return Entry{}, false, err 69 - } 70 - return e, true, nil 71 - } 72 - 73 - func (s *SQLite) Put(ctx context.Context, e Entry) error { 74 - _, err := s.db.ExecContext(ctx, ` 75 - INSERT INTO configs (key, format, config, updated_at) 76 - VALUES (?, ?, ?, ?) 77 - ON CONFLICT(key) DO UPDATE SET 78 - format = excluded.format, 79 - config = excluded.config, 80 - updated_at = excluded.updated_at;`, 81 - e.Key, e.Format, e.Config, e.UpdatedAt) 82 - return err 83 - } 84 - 85 - func (s *SQLite) Delete(ctx context.Context, key string) (bool, error) { 86 - res, err := s.db.ExecContext(ctx, `DELETE FROM configs WHERE key = ?`, key) 87 - if err != nil { 88 - return false, err 89 - } 90 - n, err := res.RowsAffected() 91 - if err != nil { 92 - return false, err 93 - } 94 - return n > 0, nil 95 - } 96 - 97 - func (s *SQLite) List(ctx context.Context) ([]string, error) { 98 - rows, err := s.db.QueryContext(ctx, `SELECT key FROM configs ORDER BY key`) 99 - if err != nil { 100 - return nil, err 101 - } 102 - defer rows.Close() 103 - var keys []string 104 - for rows.Next() { 105 - var k string 106 - if err := rows.Scan(&k); err != nil { 107 - return nil, err 108 - } 109 - keys = append(keys, k) 110 - } 111 - return keys, rows.Err() 112 - } 113 - 114 - func (s *SQLite) Close() error { return s.db.Close() }
-28
internal/store/store.go
··· 1 - // Package store persists named Apprise configurations. 2 - package store 3 - 4 - import "context" 5 - 6 - // Entry is a stored configuration identified by Key. 7 - type Entry struct { 8 - Key string 9 - Format string // "text" | "yaml" 10 - Config string // raw configuration body 11 - UpdatedAt int64 // unix seconds 12 - } 13 - 14 - // Store is the persistence interface used by the persistent endpoints. 15 - // 16 - // Implementations must be safe for concurrent use. 17 - type Store interface { 18 - // Get returns the entry for key. The bool is false when no entry exists. 19 - Get(ctx context.Context, key string) (Entry, bool, error) 20 - // Put inserts or replaces the entry. 21 - Put(ctx context.Context, e Entry) error 22 - // Delete removes key, returning whether it existed. 23 - Delete(ctx context.Context, key string) (bool, error) 24 - // List returns all stored keys. 25 - List(ctx context.Context) ([]string, error) 26 - // Close releases any underlying resources. 27 - Close() error 28 - }
-105
internal/store/store_test.go
··· 1 - package store_test 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "path/filepath" 7 - "sync" 8 - "testing" 9 - 10 - "git.fogtype.com/nebel/apprize/internal/store" 11 - ) 12 - 13 - // newStores returns one instance of every Store implementation so the shared 14 - // suite runs identically against memory and sqlite (file and :memory:). 15 - func newStores(t *testing.T) map[string]store.Store { 16 - t.Helper() 17 - file, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) 18 - if err != nil { 19 - t.Fatalf("open sqlite file: %v", err) 20 - } 21 - mem, err := store.OpenSQLite(":memory:") 22 - if err != nil { 23 - t.Fatalf("open sqlite memory: %v", err) 24 - } 25 - stores := map[string]store.Store{ 26 - "memory": store.NewMemory(), 27 - "sqlite-file": file, 28 - "sqlite-memory": mem, 29 - } 30 - t.Cleanup(func() { 31 - for _, s := range stores { 32 - _ = s.Close() 33 - } 34 - }) 35 - return stores 36 - } 37 - 38 - func TestStoreCRUD(t *testing.T) { 39 - ctx := context.Background() 40 - for name, s := range newStores(t) { 41 - t.Run(name, func(t *testing.T) { 42 - if _, ok, err := s.Get(ctx, "k"); err != nil || ok { 43 - t.Fatalf("empty Get: ok=%v err=%v", ok, err) 44 - } 45 - 46 - seed := store.Entry{Key: "k", Format: "text", Config: "gotify://h/t", UpdatedAt: 1} 47 - if err := s.Put(ctx, seed); err != nil { 48 - t.Fatalf("Put: %v", err) 49 - } 50 - got, ok, err := s.Get(ctx, "k") 51 - if err != nil || !ok || got != seed { 52 - t.Fatalf("Get after Put = %+v ok=%v err=%v", got, ok, err) 53 - } 54 - 55 - // Put upserts in place rather than duplicating the key. 56 - if err := s.Put(ctx, store.Entry{Key: "k", Format: "yaml", Config: "urls: []", UpdatedAt: 2}); err != nil { 57 - t.Fatalf("upsert: %v", err) 58 - } 59 - keys, err := s.List(ctx) 60 - if err != nil || len(keys) != 1 || keys[0] != "k" { 61 - t.Fatalf("List after upsert = %v err=%v", keys, err) 62 - } 63 - 64 - deleted, err := s.Delete(ctx, "k") 65 - if err != nil || !deleted { 66 - t.Fatalf("Delete existing: deleted=%v err=%v", deleted, err) 67 - } 68 - if again, err := s.Delete(ctx, "k"); err != nil || again { 69 - t.Fatalf("Delete missing: deleted=%v err=%v", again, err) 70 - } 71 - }) 72 - } 73 - } 74 - 75 - // TestSQLiteMemoryConcurrent guards the shared-cache fix: a bare ":memory:" DSN 76 - // would give each pooled connection its own database, so concurrent reads would 77 - // hit "no such table". 78 - func TestSQLiteMemoryConcurrent(t *testing.T) { 79 - ctx := context.Background() 80 - s, err := store.OpenSQLite(":memory:") 81 - if err != nil { 82 - t.Fatalf("open: %v", err) 83 - } 84 - defer s.Close() 85 - if err := s.Put(ctx, store.Entry{Key: "k", Format: "text", Config: "gotify://h/t", UpdatedAt: 1}); err != nil { 86 - t.Fatalf("Put: %v", err) 87 - } 88 - 89 - var wg sync.WaitGroup 90 - fails := make(chan error, 64) 91 - for i := 0; i < 64; i++ { 92 - wg.Add(1) 93 - go func() { 94 - defer wg.Done() 95 - if _, ok, err := s.Get(ctx, "k"); err != nil || !ok { 96 - fails <- fmt.Errorf("ok=%v err=%v", ok, err) 97 - } 98 - }() 99 - } 100 - wg.Wait() 101 - close(fails) 102 - if err := <-fails; err != nil { 103 - t.Fatalf("concurrent Get: %v", err) 104 - } 105 - }
+7 -28
main.go
··· 14 14 "git.fogtype.com/nebel/apprize/internal/config" 15 15 "git.fogtype.com/nebel/apprize/internal/notify" 16 16 "git.fogtype.com/nebel/apprize/internal/server" 17 - "git.fogtype.com/nebel/apprize/internal/store" 18 17 ) 19 18 20 19 // version is overridable at build time via -ldflags. ··· 24 23 cfg := config.FromEnv() 25 24 26 25 flag.StringVar(&cfg.Bind, "bind", cfg.Bind, "listen address (env APPRIZE_BIND)") 27 - flag.StringVar(&cfg.DBPath, "db", cfg.DBPath, "sqlite database path, :memory: for ephemeral (env APPRIZE_DB_PATH)") 28 26 flag.StringVar(&cfg.APIKey, "api-key", cfg.APIKey, "optional API secret (env APPRIZE_API_KEY)") 29 - flag.Int64Var(&cfg.ConfigMaxKB, "config-max-length", cfg.ConfigMaxKB, "request body limit in KB (env APPRIZE_CONFIG_MAX_LENGTH)") 30 - flag.StringVar(&cfg.DefaultConfigID, "default-config-id", cfg.DefaultConfigID, "default key for keyless persistent routes (env APPRIZE_DEFAULT_CONFIG_ID)") 31 27 flag.Parse() 32 28 33 29 if err := run(cfg); err != nil { ··· 36 32 } 37 33 38 34 func run(cfg config.Config) error { 39 - var st store.Store 40 - if cfg.DBPath == "" || cfg.DBPath == ":memory:" { 41 - st = store.NewMemory() 42 - } else { 43 - s, err := store.OpenSQLite(cfg.DBPath) 44 - if err != nil { 45 - return err 46 - } 47 - st = s 48 - } 49 - defer st.Close() 50 - 51 35 h := server.New(server.Deps{ 52 - Notifier: notify.NewApprise(), 53 - Store: st, 54 - StatelessURLs: cfg.StatelessURLs, 55 - ConfigLock: cfg.ConfigLock, 56 - Admin: cfg.Admin, 57 - RecursionMax: cfg.RecursionMax, 58 - DenyServices: cfg.DenyServices, 59 - AllowServices: cfg.AllowServices, 60 - APIKey: cfg.APIKey, 61 - MaxBodyBytes: cfg.ConfigMaxKB * 1024, 62 - DefaultConfigID: cfg.DefaultConfigID, 63 - Version: version, 36 + Notifier: notify.NewApprise(), 37 + StatelessURLs: cfg.StatelessURLs, 38 + RecursionMax: cfg.RecursionMax, 39 + DenyServices: cfg.DenyServices, 40 + AllowServices: cfg.AllowServices, 41 + APIKey: cfg.APIKey, 42 + Version: version, 64 43 }) 65 44 66 45 srv := &http.Server{