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.

create apprize project

A drop-in, single-binary Go reimplementation of caronc/apprise-api,
conformant to swagger.yaml v1.5.0. Notifications go through
unraid/apprise-go and named configs persist in modernc.org/sqlite — pure
Go, no cgo, no Python.

Handlers depend on Notifier and Store interfaces behind a small net/http
middleware stack (recover, logging, optional API key, recursion guard,
body limit) with Accept-based content negotiation. A table-driven suite
treats testdata/swagger.yaml as the source of truth and proves every
route is covered.

Kohei Watanabe (Jun 7, 2026, 8:55 PM +0900) f96d5d8d

+4656
+7
.dockerignore
··· 1 + .git 2 + .github 3 + bin 4 + apprize 5 + apprize.db 6 + *.db 7 + tmp
+8
.gitignore
··· 1 + # Build output 2 + /apprize 3 + /apprize.exe 4 + 5 + # Runtime SQLite databases (and WAL/SHM sidecars) 6 + *.db 7 + *.db-wal 8 + *.db-shm
+23
Dockerfile
··· 1 + # syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 2 + 3 + FROM golang:1.26@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS build 4 + WORKDIR /src 5 + 6 + COPY go.mod go.sum ./ 7 + RUN go mod download 8 + 9 + COPY . . 10 + RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/apprize 11 + 12 + FROM gcr.io/distroless/base-debian12:nonroot@sha256:7a75a36f4bec82a7542c64195e402907486f9a4dd2f8797a976aa0cf31cfb470 13 + WORKDIR /app 14 + 15 + COPY --from=build /out/apprize /usr/local/bin/apprize 16 + 17 + ENV APPRIZE_BIND=:8000 18 + ENV APPRIZE_DB_PATH=/data/apprize.db 19 + 20 + VOLUME ["/data"] 21 + EXPOSE 8000 22 + 23 + ENTRYPOINT ["/usr/local/bin/apprize"]
+661
LICENSE
··· 1 + GNU AFFERO GENERAL PUBLIC LICENSE 2 + Version 3, 19 November 2007 3 + 4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 5 + Everyone is permitted to copy and distribute verbatim copies 6 + of this license document, but changing it is not allowed. 7 + 8 + Preamble 9 + 10 + The GNU Affero General Public License is a free, copyleft license for 11 + software and other kinds of works, specifically designed to ensure 12 + cooperation with the community in the case of network server software. 13 + 14 + The licenses for most software and other practical works are designed 15 + to take away your freedom to share and change the works. By contrast, 16 + our General Public Licenses are intended to guarantee your freedom to 17 + share and change all versions of a program--to make sure it remains free 18 + software for all its users. 19 + 20 + When we speak of free software, we are referring to freedom, not 21 + price. Our General Public Licenses are designed to make sure that you 22 + have the freedom to distribute copies of free software (and charge for 23 + them if you wish), that you receive source code or can get it if you 24 + want it, that you can change the software or use pieces of it in new 25 + free programs, and that you know you can do these things. 26 + 27 + Developers that use our General Public Licenses protect your rights 28 + with two steps: (1) assert copyright on the software, and (2) offer 29 + you this License which gives you legal permission to copy, distribute 30 + and/or modify the software. 31 + 32 + A secondary benefit of defending all users' freedom is that 33 + improvements made in alternate versions of the program, if they 34 + receive widespread use, become available for other developers to 35 + incorporate. Many developers of free software are heartened and 36 + encouraged by the resulting cooperation. However, in the case of 37 + software used on network servers, this result may fail to come about. 38 + The GNU General Public License permits making a modified version and 39 + letting the public access it on a server without ever releasing its 40 + source code to the public. 41 + 42 + The GNU Affero General Public License is designed specifically to 43 + ensure that, in such cases, the modified source code becomes available 44 + to the community. It requires the operator of a network server to 45 + provide the source code of the modified version running there to the 46 + users of that server. Therefore, public use of a modified version, on 47 + a publicly accessible server, gives the public access to the source 48 + code of the modified version. 49 + 50 + An older license, called the Affero General Public License and 51 + published by Affero, was designed to accomplish similar goals. This is 52 + a different license, not a version of the Affero GPL, but Affero has 53 + released a new version of the Affero GPL which permits relicensing under 54 + this license. 55 + 56 + The precise terms and conditions for copying, distribution and 57 + modification follow. 58 + 59 + TERMS AND CONDITIONS 60 + 61 + 0. Definitions. 62 + 63 + "This License" refers to version 3 of the GNU Affero General Public License. 64 + 65 + "Copyright" also means copyright-like laws that apply to other kinds of 66 + works, such as semiconductor masks. 67 + 68 + "The Program" refers to any copyrightable work licensed under this 69 + License. Each licensee is addressed as "you". "Licensees" and 70 + "recipients" may be individuals or organizations. 71 + 72 + To "modify" a work means to copy from or adapt all or part of the work 73 + in a fashion requiring copyright permission, other than the making of an 74 + exact copy. The resulting work is called a "modified version" of the 75 + earlier work or a work "based on" the earlier work. 76 + 77 + A "covered work" means either the unmodified Program or a work based 78 + on the Program. 79 + 80 + To "propagate" a work means to do anything with it that, without 81 + permission, would make you directly or secondarily liable for 82 + infringement under applicable copyright law, except executing it on a 83 + computer or modifying a private copy. Propagation includes copying, 84 + distribution (with or without modification), making available to the 85 + public, and in some countries other activities as well. 86 + 87 + To "convey" a work means any kind of propagation that enables other 88 + parties to make or receive copies. Mere interaction with a user through 89 + a computer network, with no transfer of a copy, is not conveying. 90 + 91 + An interactive user interface displays "Appropriate Legal Notices" 92 + to the extent that it includes a convenient and prominently visible 93 + feature that (1) displays an appropriate copyright notice, and (2) 94 + tells the user that there is no warranty for the work (except to the 95 + extent that warranties are provided), that licensees may convey the 96 + work under this License, and how to view a copy of this License. If 97 + the interface presents a list of user commands or options, such as a 98 + menu, a prominent item in the list meets this criterion. 99 + 100 + 1. Source Code. 101 + 102 + The "source code" for a work means the preferred form of the work 103 + for making modifications to it. "Object code" means any non-source 104 + form of a work. 105 + 106 + A "Standard Interface" means an interface that either is an official 107 + standard defined by a recognized standards body, or, in the case of 108 + interfaces specified for a particular programming language, one that 109 + is widely used among developers working in that language. 110 + 111 + The "System Libraries" of an executable work include anything, other 112 + than the work as a whole, that (a) is included in the normal form of 113 + packaging a Major Component, but which is not part of that Major 114 + Component, and (b) serves only to enable use of the work with that 115 + Major Component, or to implement a Standard Interface for which an 116 + implementation is available to the public in source code form. A 117 + "Major Component", in this context, means a major essential component 118 + (kernel, window system, and so on) of the specific operating system 119 + (if any) on which the executable work runs, or a compiler used to 120 + produce the work, or an object code interpreter used to run it. 121 + 122 + The "Corresponding Source" for a work in object code form means all 123 + the source code needed to generate, install, and (for an executable 124 + work) run the object code and to modify the work, including scripts to 125 + control those activities. However, it does not include the work's 126 + System Libraries, or general-purpose tools or generally available free 127 + programs which are used unmodified in performing those activities but 128 + which are not part of the work. For example, Corresponding Source 129 + includes interface definition files associated with source files for 130 + the work, and the source code for shared libraries and dynamically 131 + linked subprograms that the work is specifically designed to require, 132 + such as by intimate data communication or control flow between those 133 + subprograms and other parts of the work. 134 + 135 + The Corresponding Source need not include anything that users 136 + can regenerate automatically from other parts of the Corresponding 137 + Source. 138 + 139 + The Corresponding Source for a work in source code form is that 140 + same work. 141 + 142 + 2. Basic Permissions. 143 + 144 + All rights granted under this License are granted for the term of 145 + copyright on the Program, and are irrevocable provided the stated 146 + conditions are met. This License explicitly affirms your unlimited 147 + permission to run the unmodified Program. The output from running a 148 + covered work is covered by this License only if the output, given its 149 + content, constitutes a covered work. This License acknowledges your 150 + rights of fair use or other equivalent, as provided by copyright law. 151 + 152 + You may make, run and propagate covered works that you do not 153 + convey, without conditions so long as your license otherwise remains 154 + in force. You may convey covered works to others for the sole purpose 155 + of having them make modifications exclusively for you, or provide you 156 + with facilities for running those works, provided that you comply with 157 + the terms of this License in conveying all material for which you do 158 + not control copyright. Those thus making or running the covered works 159 + for you must do so exclusively on your behalf, under your direction 160 + and control, on terms that prohibit them from making any copies of 161 + your copyrighted material outside their relationship with you. 162 + 163 + Conveying under any other circumstances is permitted solely under 164 + the conditions stated below. Sublicensing is not allowed; section 10 165 + makes it unnecessary. 166 + 167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 + 169 + No covered work shall be deemed part of an effective technological 170 + measure under any applicable law fulfilling obligations under article 171 + 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 + similar laws prohibiting or restricting circumvention of such 173 + measures. 174 + 175 + When you convey a covered work, you waive any legal power to forbid 176 + circumvention of technological measures to the extent such circumvention 177 + is effected by exercising rights under this License with respect to 178 + the covered work, and you disclaim any intention to limit operation or 179 + modification of the work as a means of enforcing, against the work's 180 + users, your or third parties' legal rights to forbid circumvention of 181 + technological measures. 182 + 183 + 4. Conveying Verbatim Copies. 184 + 185 + You may convey verbatim copies of the Program's source code as you 186 + receive it, in any medium, provided that you conspicuously and 187 + appropriately publish on each copy an appropriate copyright notice; 188 + keep intact all notices stating that this License and any 189 + non-permissive terms added in accord with section 7 apply to the code; 190 + keep intact all notices of the absence of any warranty; and give all 191 + recipients a copy of this License along with the Program. 192 + 193 + You may charge any price or no price for each copy that you convey, 194 + and you may offer support or warranty protection for a fee. 195 + 196 + 5. Conveying Modified Source Versions. 197 + 198 + You may convey a work based on the Program, or the modifications to 199 + produce it from the Program, in the form of source code under the 200 + terms of section 4, provided that you also meet all of these conditions: 201 + 202 + a) The work must carry prominent notices stating that you modified 203 + it, and giving a relevant date. 204 + 205 + b) The work must carry prominent notices stating that it is 206 + released under this License and any conditions added under section 207 + 7. This requirement modifies the requirement in section 4 to 208 + "keep intact all notices". 209 + 210 + c) You must license the entire work, as a whole, under this 211 + License to anyone who comes into possession of a copy. This 212 + License will therefore apply, along with any applicable section 7 213 + additional terms, to the whole of the work, and all its parts, 214 + regardless of how they are packaged. This License gives no 215 + permission to license the work in any other way, but it does not 216 + invalidate such permission if you have separately received it. 217 + 218 + d) If the work has interactive user interfaces, each must display 219 + Appropriate Legal Notices; however, if the Program has interactive 220 + interfaces that do not display Appropriate Legal Notices, your 221 + work need not make them do so. 222 + 223 + A compilation of a covered work with other separate and independent 224 + works, which are not by their nature extensions of the covered work, 225 + and which are not combined with it such as to form a larger program, 226 + in or on a volume of a storage or distribution medium, is called an 227 + "aggregate" if the compilation and its resulting copyright are not 228 + used to limit the access or legal rights of the compilation's users 229 + beyond what the individual works permit. Inclusion of a covered work 230 + in an aggregate does not cause this License to apply to the other 231 + parts of the aggregate. 232 + 233 + 6. Conveying Non-Source Forms. 234 + 235 + You may convey a covered work in object code form under the terms 236 + of sections 4 and 5, provided that you also convey the 237 + machine-readable Corresponding Source under the terms of this License, 238 + in one of these ways: 239 + 240 + a) Convey the object code in, or embodied in, a physical product 241 + (including a physical distribution medium), accompanied by the 242 + Corresponding Source fixed on a durable physical medium 243 + customarily used for software interchange. 244 + 245 + b) Convey the object code in, or embodied in, a physical product 246 + (including a physical distribution medium), accompanied by a 247 + written offer, valid for at least three years and valid for as 248 + long as you offer spare parts or customer support for that product 249 + model, to give anyone who possesses the object code either (1) a 250 + copy of the Corresponding Source for all the software in the 251 + product that is covered by this License, on a durable physical 252 + medium customarily used for software interchange, for a price no 253 + more than your reasonable cost of physically performing this 254 + conveying of source, or (2) access to copy the 255 + Corresponding Source from a network server at no charge. 256 + 257 + c) Convey individual copies of the object code with a copy of the 258 + written offer to provide the Corresponding Source. This 259 + alternative is allowed only occasionally and noncommercially, and 260 + only if you received the object code with such an offer, in accord 261 + with subsection 6b. 262 + 263 + d) Convey the object code by offering access from a designated 264 + place (gratis or for a charge), and offer equivalent access to the 265 + Corresponding Source in the same way through the same place at no 266 + further charge. You need not require recipients to copy the 267 + Corresponding Source along with the object code. If the place to 268 + copy the object code is a network server, the Corresponding Source 269 + may be on a different server (operated by you or a third party) 270 + that supports equivalent copying facilities, provided you maintain 271 + clear directions next to the object code saying where to find the 272 + Corresponding Source. Regardless of what server hosts the 273 + Corresponding Source, you remain obligated to ensure that it is 274 + available for as long as needed to satisfy these requirements. 275 + 276 + e) Convey the object code using peer-to-peer transmission, provided 277 + you inform other peers where the object code and Corresponding 278 + Source of the work are being offered to the general public at no 279 + charge under subsection 6d. 280 + 281 + A separable portion of the object code, whose source code is excluded 282 + from the Corresponding Source as a System Library, need not be 283 + included in conveying the object code work. 284 + 285 + A "User Product" is either (1) a "consumer product", which means any 286 + tangible personal property which is normally used for personal, family, 287 + or household purposes, or (2) anything designed or sold for incorporation 288 + into a dwelling. In determining whether a product is a consumer product, 289 + doubtful cases shall be resolved in favor of coverage. For a particular 290 + product received by a particular user, "normally used" refers to a 291 + typical or common use of that class of product, regardless of the status 292 + of the particular user or of the way in which the particular user 293 + actually uses, or expects or is expected to use, the product. A product 294 + is a consumer product regardless of whether the product has substantial 295 + commercial, industrial or non-consumer uses, unless such uses represent 296 + the only significant mode of use of the product. 297 + 298 + "Installation Information" for a User Product means any methods, 299 + procedures, authorization keys, or other information required to install 300 + and execute modified versions of a covered work in that User Product from 301 + a modified version of its Corresponding Source. The information must 302 + suffice to ensure that the continued functioning of the modified object 303 + code is in no case prevented or interfered with solely because 304 + modification has been made. 305 + 306 + If you convey an object code work under this section in, or with, or 307 + specifically for use in, a User Product, and the conveying occurs as 308 + part of a transaction in which the right of possession and use of the 309 + User Product is transferred to the recipient in perpetuity or for a 310 + fixed term (regardless of how the transaction is characterized), the 311 + Corresponding Source conveyed under this section must be accompanied 312 + by the Installation Information. But this requirement does not apply 313 + if neither you nor any third party retains the ability to install 314 + modified object code on the User Product (for example, the work has 315 + been installed in ROM). 316 + 317 + The requirement to provide Installation Information does not include a 318 + requirement to continue to provide support service, warranty, or updates 319 + for a work that has been modified or installed by the recipient, or for 320 + the User Product in which it has been modified or installed. Access to a 321 + network may be denied when the modification itself materially and 322 + adversely affects the operation of the network or violates the rules and 323 + protocols for communication across the network. 324 + 325 + Corresponding Source conveyed, and Installation Information provided, 326 + in accord with this section must be in a format that is publicly 327 + documented (and with an implementation available to the public in 328 + source code form), and must require no special password or key for 329 + unpacking, reading or copying. 330 + 331 + 7. Additional Terms. 332 + 333 + "Additional permissions" are terms that supplement the terms of this 334 + License by making exceptions from one or more of its conditions. 335 + Additional permissions that are applicable to the entire Program shall 336 + be treated as though they were included in this License, to the extent 337 + that they are valid under applicable law. If additional permissions 338 + apply only to part of the Program, that part may be used separately 339 + under those permissions, but the entire Program remains governed by 340 + this License without regard to the additional permissions. 341 + 342 + When you convey a copy of a covered work, you may at your option 343 + remove any additional permissions from that copy, or from any part of 344 + it. (Additional permissions may be written to require their own 345 + removal in certain cases when you modify the work.) You may place 346 + additional permissions on material, added by you to a covered work, 347 + for which you have or can give appropriate copyright permission. 348 + 349 + Notwithstanding any other provision of this License, for material you 350 + add to a covered work, you may (if authorized by the copyright holders of 351 + that material) supplement the terms of this License with terms: 352 + 353 + a) Disclaiming warranty or limiting liability differently from the 354 + terms of sections 15 and 16 of this License; or 355 + 356 + b) Requiring preservation of specified reasonable legal notices or 357 + author attributions in that material or in the Appropriate Legal 358 + Notices displayed by works containing it; or 359 + 360 + c) Prohibiting misrepresentation of the origin of that material, or 361 + requiring that modified versions of such material be marked in 362 + reasonable ways as different from the original version; or 363 + 364 + d) Limiting the use for publicity purposes of names of licensors or 365 + authors of the material; or 366 + 367 + e) Declining to grant rights under trademark law for use of some 368 + trade names, trademarks, or service marks; or 369 + 370 + f) Requiring indemnification of licensors and authors of that 371 + material by anyone who conveys the material (or modified versions of 372 + it) with contractual assumptions of liability to the recipient, for 373 + any liability that these contractual assumptions directly impose on 374 + those licensors and authors. 375 + 376 + All other non-permissive additional terms are considered "further 377 + restrictions" within the meaning of section 10. If the Program as you 378 + received it, or any part of it, contains a notice stating that it is 379 + governed by this License along with a term that is a further 380 + restriction, you may remove that term. If a license document contains 381 + a further restriction but permits relicensing or conveying under this 382 + License, you may add to a covered work material governed by the terms 383 + of that license document, provided that the further restriction does 384 + not survive such relicensing or conveying. 385 + 386 + If you add terms to a covered work in accord with this section, you 387 + must place, in the relevant source files, a statement of the 388 + additional terms that apply to those files, or a notice indicating 389 + where to find the applicable terms. 390 + 391 + Additional terms, permissive or non-permissive, may be stated in the 392 + form of a separately written license, or stated as exceptions; 393 + the above requirements apply either way. 394 + 395 + 8. Termination. 396 + 397 + You may not propagate or modify a covered work except as expressly 398 + provided under this License. Any attempt otherwise to propagate or 399 + modify it is void, and will automatically terminate your rights under 400 + this License (including any patent licenses granted under the third 401 + paragraph of section 11). 402 + 403 + However, if you cease all violation of this License, then your 404 + license from a particular copyright holder is reinstated (a) 405 + provisionally, unless and until the copyright holder explicitly and 406 + finally terminates your license, and (b) permanently, if the copyright 407 + holder fails to notify you of the violation by some reasonable means 408 + prior to 60 days after the cessation. 409 + 410 + Moreover, your license from a particular copyright holder is 411 + reinstated permanently if the copyright holder notifies you of the 412 + violation by some reasonable means, this is the first time you have 413 + received notice of violation of this License (for any work) from that 414 + copyright holder, and you cure the violation prior to 30 days after 415 + your receipt of the notice. 416 + 417 + Termination of your rights under this section does not terminate the 418 + licenses of parties who have received copies or rights from you under 419 + this License. If your rights have been terminated and not permanently 420 + reinstated, you do not qualify to receive new licenses for the same 421 + material under section 10. 422 + 423 + 9. Acceptance Not Required for Having Copies. 424 + 425 + You are not required to accept this License in order to receive or 426 + run a copy of the Program. Ancillary propagation of a covered work 427 + occurring solely as a consequence of using peer-to-peer transmission 428 + to receive a copy likewise does not require acceptance. However, 429 + nothing other than this License grants you permission to propagate or 430 + modify any covered work. These actions infringe copyright if you do 431 + not accept this License. Therefore, by modifying or propagating a 432 + covered work, you indicate your acceptance of this License to do so. 433 + 434 + 10. Automatic Licensing of Downstream Recipients. 435 + 436 + Each time you convey a covered work, the recipient automatically 437 + receives a license from the original licensors, to run, modify and 438 + propagate that work, subject to this License. You are not responsible 439 + for enforcing compliance by third parties with this License. 440 + 441 + An "entity transaction" is a transaction transferring control of an 442 + organization, or substantially all assets of one, or subdividing an 443 + organization, or merging organizations. If propagation of a covered 444 + work results from an entity transaction, each party to that 445 + transaction who receives a copy of the work also receives whatever 446 + licenses to the work the party's predecessor in interest had or could 447 + give under the previous paragraph, plus a right to possession of the 448 + Corresponding Source of the work from the predecessor in interest, if 449 + the predecessor has it or can get it with reasonable efforts. 450 + 451 + You may not impose any further restrictions on the exercise of the 452 + rights granted or affirmed under this License. For example, you may 453 + not impose a license fee, royalty, or other charge for exercise of 454 + rights granted under this License, and you may not initiate litigation 455 + (including a cross-claim or counterclaim in a lawsuit) alleging that 456 + any patent claim is infringed by making, using, selling, offering for 457 + sale, or importing the Program or any portion of it. 458 + 459 + 11. Patents. 460 + 461 + A "contributor" is a copyright holder who authorizes use under this 462 + License of the Program or a work on which the Program is based. The 463 + work thus licensed is called the contributor's "contributor version". 464 + 465 + A contributor's "essential patent claims" are all patent claims 466 + owned or controlled by the contributor, whether already acquired or 467 + hereafter acquired, that would be infringed by some manner, permitted 468 + by this License, of making, using, or selling its contributor version, 469 + but do not include claims that would be infringed only as a 470 + consequence of further modification of the contributor version. For 471 + purposes of this definition, "control" includes the right to grant 472 + patent sublicenses in a manner consistent with the requirements of 473 + this License. 474 + 475 + Each contributor grants you a non-exclusive, worldwide, royalty-free 476 + patent license under the contributor's essential patent claims, to 477 + make, use, sell, offer for sale, import and otherwise run, modify and 478 + propagate the contents of its contributor version. 479 + 480 + In the following three paragraphs, a "patent license" is any express 481 + agreement or commitment, however denominated, not to enforce a patent 482 + (such as an express permission to practice a patent or covenant not to 483 + sue for patent infringement). To "grant" such a patent license to a 484 + party means to make such an agreement or commitment not to enforce a 485 + patent against the party. 486 + 487 + If you convey a covered work, knowingly relying on a patent license, 488 + and the Corresponding Source of the work is not available for anyone 489 + to copy, free of charge and under the terms of this License, through a 490 + publicly available network server or other readily accessible means, 491 + then you must either (1) cause the Corresponding Source to be so 492 + available, or (2) arrange to deprive yourself of the benefit of the 493 + patent license for this particular work, or (3) arrange, in a manner 494 + consistent with the requirements of this License, to extend the patent 495 + license to downstream recipients. "Knowingly relying" means you have 496 + actual knowledge that, but for the patent license, your conveying the 497 + covered work in a country, or your recipient's use of the covered work 498 + in a country, would infringe one or more identifiable patents in that 499 + country that you have reason to believe are valid. 500 + 501 + If, pursuant to or in connection with a single transaction or 502 + arrangement, you convey, or propagate by procuring conveyance of, a 503 + covered work, and grant a patent license to some of the parties 504 + receiving the covered work authorizing them to use, propagate, modify 505 + or convey a specific copy of the covered work, then the patent license 506 + you grant is automatically extended to all recipients of the covered 507 + work and works based on it. 508 + 509 + A patent license is "discriminatory" if it does not include within 510 + the scope of its coverage, prohibits the exercise of, or is 511 + conditioned on the non-exercise of one or more of the rights that are 512 + specifically granted under this License. You may not convey a covered 513 + work if you are a party to an arrangement with a third party that is 514 + in the business of distributing software, under which you make payment 515 + to the third party based on the extent of your activity of conveying 516 + the work, and under which the third party grants, to any of the 517 + parties who would receive the covered work from you, a discriminatory 518 + patent license (a) in connection with copies of the covered work 519 + conveyed by you (or copies made from those copies), or (b) primarily 520 + for and in connection with specific products or compilations that 521 + contain the covered work, unless you entered into that arrangement, 522 + or that patent license was granted, prior to 28 March 2007. 523 + 524 + Nothing in this License shall be construed as excluding or limiting 525 + any implied license or other defenses to infringement that may 526 + otherwise be available to you under applicable patent law. 527 + 528 + 12. No Surrender of Others' Freedom. 529 + 530 + If conditions are imposed on you (whether by court order, agreement or 531 + otherwise) that contradict the conditions of this License, they do not 532 + excuse you from the conditions of this License. If you cannot convey a 533 + covered work so as to satisfy simultaneously your obligations under this 534 + License and any other pertinent obligations, then as a consequence you may 535 + not convey it at all. For example, if you agree to terms that obligate you 536 + to collect a royalty for further conveying from those to whom you convey 537 + the Program, the only way you could satisfy both those terms and this 538 + License would be to refrain entirely from conveying the Program. 539 + 540 + 13. Remote Network Interaction; Use with the GNU General Public License. 541 + 542 + Notwithstanding any other provision of this License, if you modify the 543 + Program, your modified version must prominently offer all users 544 + interacting with it remotely through a computer network (if your version 545 + supports such interaction) an opportunity to receive the Corresponding 546 + Source of your version by providing access to the Corresponding Source 547 + from a network server at no charge, through some standard or customary 548 + means of facilitating copying of software. This Corresponding Source 549 + shall include the Corresponding Source for any work covered by version 3 550 + of the GNU General Public License that is incorporated pursuant to the 551 + following paragraph. 552 + 553 + Notwithstanding any other provision of this License, you have 554 + permission to link or combine any covered work with a work licensed 555 + under version 3 of the GNU General Public License into a single 556 + combined work, and to convey the resulting work. The terms of this 557 + License will continue to apply to the part which is the covered work, 558 + but the work with which it is combined will remain governed by version 559 + 3 of the GNU General Public License. 560 + 561 + 14. Revised Versions of this License. 562 + 563 + The Free Software Foundation may publish revised and/or new versions of 564 + the GNU Affero General Public License from time to time. Such new versions 565 + will be similar in spirit to the present version, but may differ in detail to 566 + address new problems or concerns. 567 + 568 + Each version is given a distinguishing version number. If the 569 + Program specifies that a certain numbered version of the GNU Affero General 570 + Public License "or any later version" applies to it, you have the 571 + option of following the terms and conditions either of that numbered 572 + version or of any later version published by the Free Software 573 + Foundation. If the Program does not specify a version number of the 574 + GNU Affero General Public License, you may choose any version ever published 575 + by the Free Software Foundation. 576 + 577 + If the Program specifies that a proxy can decide which future 578 + versions of the GNU Affero General Public License can be used, that proxy's 579 + public statement of acceptance of a version permanently authorizes you 580 + to choose that version for the Program. 581 + 582 + Later license versions may give you additional or different 583 + permissions. However, no additional obligations are imposed on any 584 + author or copyright holder as a result of your choosing to follow a 585 + later version. 586 + 587 + 15. Disclaimer of Warranty. 588 + 589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 + 598 + 16. Limitation of Liability. 599 + 600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 + SUCH DAMAGES. 609 + 610 + 17. Interpretation of Sections 15 and 16. 611 + 612 + If the disclaimer of warranty and limitation of liability provided 613 + above cannot be given local legal effect according to their terms, 614 + reviewing courts shall apply local law that most closely approximates 615 + an absolute waiver of all civil liability in connection with the 616 + Program, unless a warranty or assumption of liability accompanies a 617 + copy of the Program in return for a fee. 618 + 619 + END OF TERMS AND CONDITIONS 620 + 621 + How to Apply These Terms to Your New Programs 622 + 623 + If you develop a new program, and you want it to be of the greatest 624 + possible use to the public, the best way to achieve this is to make it 625 + free software which everyone can redistribute and change under these terms. 626 + 627 + To do so, attach the following notices to the program. It is safest 628 + to attach them to the start of each source file to most effectively 629 + state the exclusion of warranty; and each file should have at least 630 + the "copyright" line and a pointer to where the full notice is found. 631 + 632 + <one line to give the program's name and a brief idea of what it does.> 633 + Copyright (C) <year> <name of author> 634 + 635 + This program is free software: you can redistribute it and/or modify 636 + it under the terms of the GNU Affero General Public License as published by 637 + the Free Software Foundation, either version 3 of the License, or 638 + (at your option) any later version. 639 + 640 + This program is distributed in the hope that it will be useful, 641 + but WITHOUT ANY WARRANTY; without even the implied warranty of 642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 + GNU Affero General Public License for more details. 644 + 645 + You should have received a copy of the GNU Affero General Public License 646 + along with this program. If not, see <https://www.gnu.org/licenses/>. 647 + 648 + Also add information on how to contact you by electronic and paper mail. 649 + 650 + If your software can interact with users remotely through a computer 651 + network, you should also make sure that it provides a way for users to 652 + get its source. For example, if your program is a web application, its 653 + interface could display a "Source" link that leads users to an archive 654 + of the code. There are many ways you could offer source, and different 655 + solutions will be better for different programs; see section 13 for the 656 + specific requirements. 657 + 658 + You should also get your employer (if you work as a programmer) or school, 659 + if any, to sign a "copyright disclaimer" for the program, if necessary. 660 + For more information on this, and how to apply and follow the GNU AGPL, see 661 + <https://www.gnu.org/licenses/>.
+18
Makefile
··· 1 + .PHONY: help 2 + help: 3 + @cat README.md 4 + 5 + .PHONY: build 6 + build: 7 + go build 8 + 9 + .PHONY: test 10 + test: 11 + go test ./... 12 + 13 + .PHONY: clean 14 + clean: 15 + $(RM) apprize 16 + 17 + testdata/swagger.yaml: 18 + curl -sSf -o $@ https://raw.githubusercontent.com/caronc/apprise-api/refs/heads/master/swagger.yaml
+86
README.md
··· 1 + # Apprize 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. 8 + 9 + ## Build & run 10 + 11 + Requires Go 1.26+. 12 + 13 + ```sh 14 + go build 15 + ./apprize --bind :8000 --db ./apprize.db 16 + ``` 17 + 18 + Ephemeral (no file persistence): 19 + 20 + ```sh 21 + ./apprize --db :memory: 22 + ``` 23 + 24 + ### Docker 25 + 26 + ```sh 27 + docker build -t apprize . 28 + docker run --rm -p 8000:8000 -v "$PWD/data:/data" apprize 29 + ``` 30 + 31 + The image runs `apprize` directly and defaults to: 32 + 33 + - `APPRIZE_BIND=:8000` 34 + - `APPRIZE_DB_PATH=/data/apprize.db` 35 + 36 + ## Configuration 37 + 38 + Flags override environment variables. Names use the `APPRIZE_` prefix; the 39 + upstream `HTTP_PORT` is also honoured for the listen port. 40 + 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 | 54 + 55 + ## API 56 + 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 | 71 + 72 + ## Limitations 73 + 74 + apprize intentionally diverges from upstream where apprise-go cannot match it: 75 + 76 + - **Attachments are not delivered** — accepted then logged as unsupported; 77 + `/status` reports `attach_lock=true`. 78 + - **No recursion-header propagation** — inbound `X-Apprise-Recursion-Count` is 79 + enforced, but it is not injected into outbound requests. 80 + - **`/details` is simplified** — returns supported schemas and version, not 81 + per-service templates. 82 + - **No web UI** — API only. 83 + 84 + ## License 85 + 86 + [GNU AGPL-3.0](LICENSE)
+32
go.mod
··· 1 + module git.fogtype.com/nebel/apprize 2 + 3 + go 1.26.4 4 + 5 + require ( 6 + github.com/getkin/kin-openapi v0.140.0 7 + github.com/unraid/apprise-go v0.2.6 8 + gopkg.in/yaml.v3 v3.0.1 9 + modernc.org/sqlite v1.52.0 10 + ) 11 + 12 + require ( 13 + github.com/dustin/go-humanize v1.0.1 // indirect 14 + github.com/go-openapi/jsonpointer v0.22.5 // indirect 15 + github.com/go-openapi/swag/jsonname v0.25.5 // indirect 16 + github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df // indirect 17 + github.com/google/uuid v1.6.0 // indirect 18 + 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 + github.com/oasdiff/yaml v0.1.0 // indirect 22 + github.com/oasdiff/yaml3 v0.0.13 // indirect 23 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 24 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect 25 + golang.org/x/crypto v0.51.0 // indirect 26 + golang.org/x/net v0.54.0 // indirect 27 + golang.org/x/sys v0.44.0 // indirect 28 + 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 + )
+96
go.sum
··· 1 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 + github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 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 + github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= 9 + github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= 10 + github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= 11 + github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= 12 + github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= 13 + github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= 14 + github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= 15 + github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= 16 + github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao= 17 + 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 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 + 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 + github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= 33 + github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= 34 + github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= 35 + github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= 36 + github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 37 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 + 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 + github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 42 + github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 43 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= 44 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= 45 + github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 46 + github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 47 + github.com/unraid/apprise-go v0.2.6 h1:N+nZ6jl/dW/HAVEQNnrmcbiW42DoHBN6v2c73rMUGE0= 48 + github.com/unraid/apprise-go v0.2.6/go.mod h1:GC0RrJAgW5/giJaJwP8FV9Dd/XMBc5BbQE2wCmDoj+w= 49 + golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= 50 + 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 + golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= 54 + 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 + golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 61 + 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 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 66 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 67 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 68 + 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=
+78
internal/appcfg/appcfg_test.go
··· 1 + package appcfg 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestParseText(t *testing.T) { 9 + cfg := "alerts=gotify://host/token1\nteam ops=discord://id/token2\n# comment\n" 10 + got := ParseText(cfg) 11 + if len(got) != 2 { 12 + t.Fatalf("len = %d, want 2", len(got)) 13 + } 14 + if got[0].URL != "gotify://host/token1" { 15 + t.Fatalf("first URL = %q", got[0].URL) 16 + } 17 + if len(got[0].Tags) != 1 || got[0].Tags[0] != "alerts" { 18 + t.Fatalf("first tags = %v", got[0].Tags) 19 + } 20 + if len(got[1].Tags) != 2 { 21 + t.Fatalf("second tags = %v", got[1].Tags) 22 + } 23 + } 24 + 25 + func TestParseYAML(t *testing.T) { 26 + cfg := ` 27 + version: 1 28 + urls: 29 + - gotify://host/token 30 + - discord://id/token: 31 + tag: alerts,team 32 + ` 33 + got, err := ParseYAML(cfg) 34 + if err != nil { 35 + t.Fatalf("ParseYAML error: %v", err) 36 + } 37 + if len(got) != 2 { 38 + t.Fatalf("len = %d, want 2", len(got)) 39 + } 40 + if got[1].URL != "discord://id/token" { 41 + t.Fatalf("second URL = %q", got[1].URL) 42 + } 43 + if len(got[1].Tags) != 2 { 44 + t.Fatalf("second tags = %v", got[1].Tags) 45 + } 46 + } 47 + 48 + func TestTagMatching(t *testing.T) { 49 + groups := ParseTagExpr("devops alerts, finance") 50 + if !Match(groups, []string{"devops", "alerts"}) { 51 + t.Fatal("expected devops+alerts to match") 52 + } 53 + if !Match(groups, []string{"finance"}) { 54 + t.Fatal("expected finance to match") 55 + } 56 + if Match(groups, []string{"devops"}) { 57 + t.Fatal("expected devops alone not to match") 58 + } 59 + } 60 + 61 + func TestMaskURL(t *testing.T) { 62 + masked := MaskURL("gotify://host/SECRETTOKEN?apikey=ABC") 63 + if strings.Contains(masked, "SECRETTOKEN") || strings.Contains(masked, "ABC") { 64 + t.Fatalf("secret leaked in %q", masked) 65 + } 66 + } 67 + 68 + func TestURLIdentityAndService(t *testing.T) { 69 + if URLID("gotify://host/token") == "" { 70 + t.Fatal("expected non-empty URLID") 71 + } 72 + if ServiceName("tgram://token/chat") != "Telegram" { 73 + t.Fatal("expected Telegram service mapping") 74 + } 75 + if Scheme("discord://id/token") != "discord" { 76 + t.Fatal("expected discord scheme") 77 + } 78 + }
+155
internal/appcfg/parse.go
··· 1 + package appcfg 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "gopkg.in/yaml.v3" 8 + ) 9 + 10 + // ParseText parses apprise TEXT config format into URL entries. 11 + func ParseText(cfg string) []Entry { 12 + lines := strings.Split(cfg, "\n") 13 + out := make([]Entry, 0, len(lines)) 14 + for _, ln := range lines { 15 + ln = strings.TrimSpace(ln) 16 + if ln == "" || strings.HasPrefix(ln, "#") || strings.HasPrefix(ln, ";") { 17 + continue 18 + } 19 + entry := Entry{URL: ln} 20 + if i := strings.Index(ln, "="); i > 0 { 21 + entry.Tags = splitTags(strings.TrimSpace(ln[:i])) 22 + entry.URL = strings.TrimSpace(ln[i+1:]) 23 + } 24 + if strings.Contains(entry.URL, "://") { 25 + out = append(out, entry) 26 + } 27 + } 28 + return out 29 + } 30 + 31 + // ParseYAML parses apprise YAML config format into URL entries. 32 + func ParseYAML(cfg string) ([]Entry, error) { 33 + var root yaml.Node 34 + if err := yaml.Unmarshal([]byte(cfg), &root); err != nil { 35 + return nil, err 36 + } 37 + if len(root.Content) == 0 { 38 + return nil, fmt.Errorf("empty yaml") 39 + } 40 + doc := root.Content[0] 41 + if doc.Kind != yaml.MappingNode { 42 + return nil, fmt.Errorf("yaml root must be mapping") 43 + } 44 + 45 + urls := mappingValue(doc, "urls") 46 + if urls == nil || urls.Kind != yaml.SequenceNode { 47 + return nil, fmt.Errorf("yaml urls must be sequence") 48 + } 49 + 50 + out := make([]Entry, 0, len(urls.Content)) 51 + for _, n := range urls.Content { 52 + switch n.Kind { 53 + case yaml.ScalarNode: 54 + if strings.Contains(n.Value, "://") { 55 + out = append(out, Entry{URL: strings.TrimSpace(n.Value)}) 56 + } 57 + case yaml.MappingNode: 58 + if len(n.Content) < 2 { 59 + continue 60 + } 61 + rawURL := strings.TrimSpace(n.Content[0].Value) 62 + if !strings.Contains(rawURL, "://") { 63 + continue 64 + } 65 + entry := Entry{URL: rawURL} 66 + if tags := collectYAMLTags(n.Content[1]); len(tags) > 0 { 67 + entry.Tags = tags 68 + } 69 + out = append(out, entry) 70 + } 71 + } 72 + if len(out) == 0 { 73 + return nil, fmt.Errorf("no urls in yaml") 74 + } 75 + return out, nil 76 + } 77 + 78 + // Parse parses by format ("text" or "yaml"). 79 + func Parse(format, cfg string) ([]Entry, error) { 80 + if strings.EqualFold(format, "yaml") { 81 + return ParseYAML(cfg) 82 + } 83 + return ParseText(cfg), nil 84 + } 85 + 86 + func collectYAMLTags(n *yaml.Node) []string { 87 + if n == nil { 88 + return nil 89 + } 90 + if n.Kind == yaml.MappingNode { 91 + tagNode := mappingValue(n, "tag") 92 + if tagNode == nil { 93 + return nil 94 + } 95 + switch tagNode.Kind { 96 + case yaml.ScalarNode: 97 + return splitTags(tagNode.Value) 98 + case yaml.SequenceNode: 99 + tags := make([]string, 0, len(tagNode.Content)) 100 + for _, item := range tagNode.Content { 101 + if item.Kind == yaml.ScalarNode { 102 + tags = append(tags, splitTags(item.Value)...) 103 + } 104 + } 105 + return dedupe(tags) 106 + } 107 + } 108 + if n.Kind == yaml.SequenceNode { 109 + tags := make([]string, 0, len(n.Content)) 110 + for _, item := range n.Content { 111 + tags = append(tags, collectYAMLTags(item)...) 112 + } 113 + return dedupe(tags) 114 + } 115 + return nil 116 + } 117 + 118 + func mappingValue(m *yaml.Node, key string) *yaml.Node { 119 + if m == nil || m.Kind != yaml.MappingNode { 120 + return nil 121 + } 122 + for i := 0; i+1 < len(m.Content); i += 2 { 123 + if strings.EqualFold(strings.TrimSpace(m.Content[i].Value), key) { 124 + return m.Content[i+1] 125 + } 126 + } 127 + return nil 128 + } 129 + 130 + func splitTags(s string) []string { 131 + parts := strings.FieldsFunc(s, func(r rune) bool { 132 + return r == ',' || r == ' ' || r == '\t' 133 + }) 134 + return dedupe(parts) 135 + } 136 + 137 + func dedupe(items []string) []string { 138 + if len(items) == 0 { 139 + return nil 140 + } 141 + seen := map[string]struct{}{} 142 + out := make([]string, 0, len(items)) 143 + for _, item := range items { 144 + item = strings.TrimSpace(item) 145 + if item == "" { 146 + continue 147 + } 148 + if _, ok := seen[item]; ok { 149 + continue 150 + } 151 + seen[item] = struct{}{} 152 + out = append(out, item) 153 + } 154 + return out 155 + }
+49
internal/appcfg/privacy.go
··· 1 + package appcfg 2 + 3 + import ( 4 + "net/url" 5 + "strings" 6 + ) 7 + 8 + // MaskURL redacts credentials and common secret query/path segments. 9 + func MaskURL(raw string) string { 10 + u, err := url.Parse(raw) 11 + if err != nil { 12 + return maskPathToken(raw) 13 + } 14 + if u.User != nil { 15 + username := u.User.Username() 16 + if username == "" { 17 + username = "****" 18 + } 19 + u.User = url.UserPassword(username, "****") 20 + } 21 + q := u.Query() 22 + for key := range q { 23 + lk := strings.ToLower(key) 24 + if strings.Contains(lk, "token") || strings.Contains(lk, "key") || strings.Contains(lk, "secret") || strings.Contains(lk, "pass") { 25 + q.Set(key, "****") 26 + } 27 + } 28 + u.RawQuery = q.Encode() 29 + return maskPathToken(u.String()) 30 + } 31 + 32 + func maskPathToken(raw string) string { 33 + i := strings.Index(raw, "://") 34 + if i < 0 { 35 + return raw 36 + } 37 + head := raw[:i+3] 38 + rest := raw[i+3:] 39 + j := strings.Index(rest, "/") 40 + if j < 0 { 41 + return raw 42 + } 43 + host := rest[:j+1] 44 + path := rest[j+1:] 45 + if path == "" { 46 + return raw 47 + } 48 + return head + host + "****" 49 + }
+39
internal/appcfg/service.go
··· 1 + package appcfg 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "strings" 7 + ) 8 + 9 + // ServiceName maps URL schemes to display names. 10 + func ServiceName(rawURL string) string { 11 + scheme := rawURL 12 + if i := strings.Index(rawURL, "://"); i > 0 { 13 + scheme = rawURL[:i] 14 + } 15 + switch strings.ToLower(scheme) { 16 + case "tgram": 17 + return "Telegram" 18 + case "discord": 19 + return "Discord" 20 + case "gotify": 21 + return "Gotify" 22 + default: 23 + return scheme 24 + } 25 + } 26 + 27 + // URLID returns a stable short id from the URL. 28 + func URLID(rawURL string) string { 29 + sum := sha256.Sum256([]byte(rawURL)) 30 + return hex.EncodeToString(sum[:4]) 31 + } 32 + 33 + // Scheme extracts lower-cased scheme from a URL-like string. 34 + func Scheme(rawURL string) string { 35 + if i := strings.Index(rawURL, "://"); i > 0 { 36 + return strings.ToLower(rawURL[:i]) 37 + } 38 + return strings.ToLower(rawURL) 39 + }
+51
internal/appcfg/tags.go
··· 1 + package appcfg 2 + 3 + import "strings" 4 + 5 + // ParseTagExpr parses "a b, c" into groups: [["a","b"],["c"]]. 6 + func ParseTagExpr(expr string) [][]string { 7 + expr = strings.TrimSpace(expr) 8 + if expr == "" || strings.EqualFold(expr, "all") { 9 + return [][]string{{"all"}} 10 + } 11 + ors := strings.FieldsFunc(expr, func(r rune) bool { return r == ',' || r == '|' }) 12 + out := make([][]string, 0, len(ors)) 13 + for _, group := range ors { 14 + ands := strings.Fields(group) 15 + if len(ands) == 0 { 16 + continue 17 + } 18 + out = append(out, ands) 19 + } 20 + if len(out) == 0 { 21 + return [][]string{{"all"}} 22 + } 23 + return out 24 + } 25 + 26 + // Match checks if entryTags satisfy the parsed expression groups. 27 + func Match(groups [][]string, entryTags []string) bool { 28 + if len(groups) == 0 { 29 + return true 30 + } 31 + if len(groups) == 1 && len(groups[0]) == 1 && strings.EqualFold(groups[0][0], "all") { 32 + return true 33 + } 34 + tagSet := map[string]struct{}{} 35 + for _, tag := range entryTags { 36 + tagSet[strings.ToLower(strings.TrimSpace(tag))] = struct{}{} 37 + } 38 + for _, group := range groups { 39 + ok := true 40 + for _, term := range group { 41 + if _, hit := tagSet[strings.ToLower(strings.TrimSpace(term))]; !hit { 42 + ok = false 43 + break 44 + } 45 + } 46 + if ok { 47 + return true 48 + } 49 + } 50 + return false 51 + }
+7
internal/appcfg/types.go
··· 1 + package appcfg 2 + 3 + // Entry is one parsed URL entry and its tags. 4 + type Entry struct { 5 + URL string 6 + Tags []string 7 + }
+101
internal/config/config.go
··· 1 + // Package config loads server configuration from environment variables and CLI 2 + // flags. Environment variable names mirror caronc/apprise-api where sensible. 3 + package config 4 + 5 + import ( 6 + "os" 7 + "strconv" 8 + "strings" 9 + ) 10 + 11 + // Config is the resolved server configuration. 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 24 + } 25 + 26 + // FromEnv builds a Config from environment variables, applying defaults. 27 + 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")), 40 + } 41 + return c 42 + } 43 + 44 + func firstNonEmpty(vals ...string) string { 45 + for _, v := range vals { 46 + if v != "" { 47 + return v 48 + } 49 + } 50 + return "" 51 + } 52 + 53 + func portToBind(port string) string { 54 + if port == "" { 55 + return "" 56 + } 57 + return ":" + port 58 + } 59 + 60 + // splitList splits a comma/space separated list, dropping empties. 61 + func splitList(s string) []string { 62 + if strings.TrimSpace(s) == "" { 63 + return nil 64 + } 65 + fields := strings.FieldsFunc(s, func(r rune) bool { 66 + return r == ',' || r == ' ' || r == '\t' || r == '\n' 67 + }) 68 + out := make([]string, 0, len(fields)) 69 + for _, f := range fields { 70 + if f = strings.TrimSpace(f); f != "" { 71 + out = append(out, f) 72 + } 73 + } 74 + return out 75 + } 76 + 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 + func parseInt(s string, def int) int { 89 + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { 90 + return n 91 + } 92 + return def 93 + } 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 + }
+43
internal/config/config_test.go
··· 1 + package config 2 + 3 + import "testing" 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") 23 + cfg := FromEnv() 24 + if cfg.ConfigMaxKB != 512 { 25 + t.Fatalf("ConfigMaxKB = %d, want 512", cfg.ConfigMaxKB) 26 + } 27 + } 28 + 29 + func TestFromEnv_DefaultConfigIDDefault(t *testing.T) { 30 + t.Setenv("APPRIZE_DEFAULT_CONFIG_ID", "") 31 + cfg := FromEnv() 32 + if cfg.DefaultConfigID != "apprise" { 33 + t.Fatalf("DefaultConfigID = %q, want %q", cfg.DefaultConfigID, "apprise") 34 + } 35 + } 36 + 37 + func TestFromEnv_DefaultConfigIDFromEnv(t *testing.T) { 38 + t.Setenv("APPRIZE_DEFAULT_CONFIG_ID", "my-default") 39 + cfg := FromEnv() 40 + if cfg.DefaultConfigID != "my-default" { 41 + t.Fatalf("DefaultConfigID = %q, want %q", cfg.DefaultConfigID, "my-default") 42 + } 43 + }
+107
internal/notify/apprise.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "strings" 7 + 8 + apprise "github.com/unraid/apprise-go" 9 + ) 10 + 11 + // Apprise is the production Notifier backed by github.com/unraid/apprise-go. 12 + type Apprise struct{} 13 + 14 + // NewApprise returns a Notifier that delivers via apprise-go. 15 + func NewApprise() *Apprise { return &Apprise{} } 16 + 17 + // SupportedSchemas returns the notification URL schemas this build can deliver. 18 + // 19 + // It is a static, network-free capability list (not tied to any Notifier 20 + // instance), so meta endpoints can report it without depending on apprise-go 21 + // directly — the dependency stays confined to this package. 22 + func SupportedSchemas() []string { return apprise.SupportedSchemas() } 23 + 24 + // Notify builds a fresh apprise client for the given URLs and sends body. 25 + // 26 + // apprise-go's Send returns errors.Join(*apprise.TargetError...) on partial or 27 + // total failure; we unwrap that to report a per-URL Result. 28 + func (a *Apprise) Notify(_ context.Context, urls []string, body string, opts Options) ([]Result, error) { 29 + client := apprise.New() 30 + added := make([]string, 0, len(urls)) 31 + results := make([]Result, 0, len(urls)) 32 + for _, u := range urls { 33 + if err := client.Add(u); err != nil { 34 + results = append(results, Result{ 35 + URL: u, 36 + Service: schemeOf(u), 37 + OK: false, 38 + Message: err.Error(), 39 + }) 40 + continue 41 + } 42 + added = append(added, u) 43 + } 44 + 45 + if len(added) == 0 { 46 + return results, nil 47 + } 48 + 49 + var sendOpts []apprise.Option 50 + if opts.Title != "" { 51 + sendOpts = append(sendOpts, apprise.WithTitle(opts.Title)) 52 + } 53 + if opts.Type != "" { 54 + if t, ok := apprise.ParseNotifyType(opts.Type); ok { 55 + sendOpts = append(sendOpts, apprise.WithNotifyType(t)) 56 + } 57 + } 58 + if opts.InputFormat != "" { 59 + sendOpts = append(sendOpts, apprise.WithInputFormat(opts.InputFormat)) 60 + } 61 + 62 + err := client.Send(body, sendOpts...) 63 + failed := failuresByURL(err) 64 + for _, u := range added { 65 + if msg, bad := failed[u]; bad { 66 + results = append(results, Result{URL: u, Service: schemeOf(u), OK: false, Message: msg}) 67 + } else if err != nil { 68 + results = append(results, Result{URL: u, Service: schemeOf(u), OK: false, Message: err.Error()}) 69 + } else { 70 + results = append(results, Result{URL: u, Service: schemeOf(u), OK: true, Message: "Sent"}) 71 + } 72 + } 73 + return results, nil 74 + } 75 + 76 + // failuresByURL extracts per-target failures from the (possibly joined) error 77 + // returned by apprise-go's Send. 78 + func failuresByURL(err error) map[string]string { 79 + out := map[string]string{} 80 + if err == nil { 81 + return out 82 + } 83 + var targets []error 84 + if joined, ok := err.(interface{ Unwrap() []error }); ok { 85 + targets = joined.Unwrap() 86 + } else { 87 + targets = []error{err} 88 + } 89 + for _, e := range targets { 90 + var te *apprise.TargetError 91 + if errors.As(e, &te) { 92 + out[te.URL] = te.Err.Error() 93 + } 94 + } 95 + return out 96 + } 97 + 98 + // schemeOf returns the URL scheme (best effort) used as a fallback service name. 99 + func schemeOf(rawURL string) string { 100 + if i := strings.Index(rawURL, "://"); i > 0 { 101 + return rawURL[:i] 102 + } 103 + if i := strings.Index(rawURL, ":"); i > 0 { 104 + return rawURL[:i] 105 + } 106 + return rawURL 107 + }
+50
internal/notify/apprise_test.go
··· 1 + package notify 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + 7 + apprise "github.com/unraid/apprise-go" 8 + ) 9 + 10 + // TestFailuresByURL checks that the per-target failures are recovered from the 11 + // joined error apprise-go returns, while successes and non-target errors are 12 + // handled gracefully. 13 + func TestFailuresByURL(t *testing.T) { 14 + t.Run("nil error has no failures", func(t *testing.T) { 15 + if got := failuresByURL(nil); len(got) != 0 { 16 + t.Fatalf("failures = %v, want empty", got) 17 + } 18 + }) 19 + 20 + t.Run("joined target errors map per URL", func(t *testing.T) { 21 + joined := errors.Join( 22 + &apprise.TargetError{URL: "discord://a/b", Err: errors.New("401")}, 23 + &apprise.TargetError{URL: "mailto://x@y", Err: errors.New("timeout")}, 24 + ) 25 + got := failuresByURL(joined) 26 + if got["discord://a/b"] != "401" || got["mailto://x@y"] != "timeout" { 27 + t.Fatalf("failures = %v", got) 28 + } 29 + }) 30 + 31 + t.Run("single target error", func(t *testing.T) { 32 + got := failuresByURL(&apprise.TargetError{URL: "gotify://h/t", Err: errors.New("boom")}) 33 + if got["gotify://h/t"] != "boom" { 34 + t.Fatalf("failures = %v", got) 35 + } 36 + }) 37 + } 38 + 39 + func TestSchemeOf(t *testing.T) { 40 + cases := map[string]string{ 41 + "discord://id/token": "discord", 42 + "mailto://u@h": "mailto", 43 + "weird": "weird", 44 + } 45 + for in, want := range cases { 46 + if got := schemeOf(in); got != want { 47 + t.Errorf("schemeOf(%q) = %q, want %q", in, got, want) 48 + } 49 + } 50 + }
+28
internal/notify/notifier.go
··· 1 + // Package notify abstracts the notification backend so that HTTP handlers can 2 + // be tested without performing real network calls. 3 + package notify 4 + 5 + import "context" 6 + 7 + // Options carries the per-notification settings shared by every backend. 8 + type Options struct { 9 + Title string // optional message title 10 + Type string // info|success|warning|failure (empty = info) 11 + InputFormat string // text|markdown|html (empty = text) 12 + } 13 + 14 + // Result describes the outcome of delivering to a single target URL. 15 + type Result struct { 16 + URL string // the (unmasked) target URL 17 + Service string // service name derived from the URL scheme 18 + OK bool // whether delivery succeeded 19 + Message string // human readable detail (error text on failure) 20 + } 21 + 22 + // Notifier delivers a notification body to a set of Apprise URLs. 23 + // 24 + // Implementations must be safe for concurrent use. The real implementation 25 + // wraps github.com/unraid/apprise-go; tests inject a fake. 26 + type Notifier interface { 27 + Notify(ctx context.Context, urls []string, body string, opts Options) ([]Result, error) 28 + }
+416
internal/server/conformance_cases_test.go
··· 1 + package server_test 2 + 3 + // conformanceCases enumerates one or more cases per swagger operation. Each case 4 + // targets a (method, specPath) so TestSpecCoverage can prove the suite covers 5 + // the whole contract. Cases assert documented status codes, content negotiation 6 + // and (for JSON bodies) schema conformance. 7 + 8 + import ( 9 + "encoding/json" 10 + "net/url" 11 + "strings" 12 + "testing" 13 + 14 + "git.fogtype.com/nebel/apprize/internal/server" 15 + "git.fogtype.com/nebel/apprize/internal/store" 16 + ) 17 + 18 + const ( 19 + ctJSON = "application/json" 20 + ctForm = "application/x-www-form-urlencoded" 21 + ctText = "text/plain" 22 + ctHTML = "text/html" 23 + ) 24 + 25 + type opCase struct { 26 + name string 27 + specPath string 28 + method string 29 + run func(t *testing.T) 30 + } 31 + 32 + // logResponse mirrors the swagger LogResponse for decoding. 33 + type logResponse struct { 34 + Error *string `json:"error"` 35 + Details [][]string `json:"details"` 36 + } 37 + 38 + func formBody(vals map[string]string) []byte { 39 + v := url.Values{} 40 + for k, val := range vals { 41 + v.Set(k, val) 42 + } 43 + return []byte(v.Encode()) 44 + } 45 + 46 + func decodeLog(t *testing.T, body []byte) logResponse { 47 + t.Helper() 48 + var lr logResponse 49 + if err := json.Unmarshal(body, &lr); err != nil { 50 + t.Fatalf("decode LogResponse: %v (body %q)", err, truncate(string(body), 200)) 51 + } 52 + return lr 53 + } 54 + 55 + var conformanceCases = []opCase{ 56 + // ------------------------------------------------------------------- 57 + // GET /status 58 + // ------------------------------------------------------------------- 59 + {"status/200-json", "/status", "GET", func(t *testing.T) { 60 + rec := req{method: "GET", path: "/status", accept: ctJSON}.do(newServer()) 61 + wantStatus(t, rec, 200) 62 + wantContentType(t, rec, ctJSON) 63 + validateSchema(t, "/status", "GET", "200", ctJSON, rec.Body.Bytes()) 64 + }}, 65 + {"status/200-json-attach-locked", "/status", "GET", func(t *testing.T) { 66 + rec := req{method: "GET", path: "/status", accept: ctJSON}.do(newServer()) 67 + wantStatus(t, rec, 200) 68 + var obj map[string]any 69 + if err := json.Unmarshal(rec.Body.Bytes(), &obj); err != nil { 70 + t.Fatalf("status body is not JSON: %v", err) 71 + } 72 + if lock, ok := obj["attach_lock"].(bool); !ok || !lock { 73 + t.Fatalf("attach_lock = %#v, want true", obj["attach_lock"]) 74 + } 75 + status, _ := obj["status"].(map[string]any) 76 + if can, ok := status["can_write_attach"].(bool); !ok || can { 77 + t.Fatalf("status.can_write_attach = %#v, want false", status["can_write_attach"]) 78 + } 79 + }}, 80 + {"status/200-text", "/status", "GET", func(t *testing.T) { 81 + rec := req{method: "GET", path: "/status", accept: ctText}.do(newServer()) 82 + wantStatus(t, rec, 200) 83 + wantContentType(t, rec, ctText) 84 + if !strings.Contains(rec.Body.String(), "OK") { 85 + t.Errorf("text status body = %q, want to contain OK", rec.Body.String()) 86 + } 87 + }}, 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 + 115 + // ------------------------------------------------------------------- 116 + // GET /details 117 + // ------------------------------------------------------------------- 118 + {"details/200-json", "/details", "GET", func(t *testing.T) { 119 + rec := req{method: "GET", path: "/details?all=yes", accept: ctJSON}.do(newServer()) 120 + wantStatus(t, rec, 200) 121 + wantContentType(t, rec, ctJSON) 122 + var obj map[string]any 123 + if err := json.Unmarshal(rec.Body.Bytes(), &obj); err != nil { 124 + t.Errorf("details body is not a JSON object: %v", err) 125 + } 126 + }}, 127 + {"details/200-html", "/details", "GET", func(t *testing.T) { 128 + rec := req{method: "GET", path: "/details", accept: ctHTML}.do(newServer()) 129 + wantStatus(t, rec, 200) 130 + wantContentType(t, rec, ctHTML) 131 + }}, 132 + 133 + // ------------------------------------------------------------------- 134 + // POST /notify 135 + // ------------------------------------------------------------------- 136 + {"notify/200-json", "/notify", "POST", func(t *testing.T) { 137 + body := jsonBody(t, map[string]any{"urls": []string{"gotify://host/token"}, "body": "hi"}) 138 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, accept: ctJSON, body: body}.do(newServer()) 139 + wantStatus(t, rec, 200) 140 + validateSchema(t, "/notify", "POST", "200", ctJSON, rec.Body.Bytes()) 141 + if lr := decodeLog(t, rec.Body.Bytes()); lr.Error != nil { 142 + t.Errorf("expected error null on success, got %q", *lr.Error) 143 + } 144 + }}, 145 + {"notify/200-form", "/notify", "POST", func(t *testing.T) { 146 + body := formBody(map[string]string{"urls": "gotify://host/token", "body": "hi"}) 147 + rec := req{method: "POST", path: "/notify", contentType: ctForm, accept: ctJSON, body: body}.do(newServer()) 148 + wantStatus(t, rec, 200) 149 + validateSchema(t, "/notify", "POST", "200", ctJSON, rec.Body.Bytes()) 150 + }}, 151 + {"notify/200-multipart", "/notify", "POST", func(t *testing.T) { 152 + ct, body := multipartBody(t, map[string]string{"urls": "gotify://host/token", "body": "hi"}, nil) 153 + rec := req{method: "POST", path: "/notify", contentType: ct, accept: ctJSON, body: body}.do(newServer()) 154 + wantStatus(t, rec, 200) 155 + validateSchema(t, "/notify", "POST", "200", ctJSON, rec.Body.Bytes()) 156 + }}, 157 + {"notify/204-no-urls", "/notify", "POST", func(t *testing.T) { 158 + body := jsonBody(t, map[string]any{"body": "hi"}) 159 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, body: body}.do(newServer()) 160 + wantStatus(t, rec, 204) 161 + }}, 162 + {"notify/400-missing-body", "/notify", "POST", func(t *testing.T) { 163 + body := jsonBody(t, map[string]any{"urls": []string{"gotify://host/token"}}) 164 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, body: body}.do(newServer()) 165 + wantStatus(t, rec, 400) 166 + }}, 167 + {"notify/406-recursion", "/notify", "POST", func(t *testing.T) { 168 + body := jsonBody(t, map[string]any{"urls": []string{"gotify://host/token"}, "body": "hi"}) 169 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, body: body, 170 + headers: map[string]string{"X-Apprise-Recursion-Count": "5"}}.do(newServer()) 171 + wantStatus(t, rec, 406) 172 + }}, 173 + {"notify/424-failure", "/notify", "POST", func(t *testing.T) { 174 + body := jsonBody(t, map[string]any{"urls": []string{"gotify://host/token"}, "body": "hi"}) 175 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, accept: ctJSON, body: body}. 176 + do(newServer(func(d *server.Deps) { d.Notifier = failNotifier() })) 177 + wantStatus(t, rec, 424) 178 + if lr := decodeLog(t, rec.Body.Bytes()); lr.Error == nil { 179 + t.Error("expected non-null error on failure") 180 + } 181 + }}, 182 + {"notify/431-too-large", "/notify", "POST", func(t *testing.T) { 183 + big := strings.Repeat("x", 4096) 184 + body := jsonBody(t, map[string]any{"urls": []string{"gotify://host/token"}, "body": big}) 185 + rec := req{method: "POST", path: "/notify", contentType: ctJSON, body: body}. 186 + do(newServer(func(d *server.Deps) { d.MaxBodyBytes = 64 })) 187 + wantStatus(t, rec, 431) 188 + }}, 189 + 190 + // ------------------------------------------------------------------- 191 + // GET /cfg 192 + // ------------------------------------------------------------------- 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) 230 + }}, 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) 235 + }}, 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) 241 + }}, 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) 248 + }}, 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) 254 + }}, 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) 264 + }}, 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) 415 + }}, 416 + }
+294
internal/server/conformance_test.go
··· 1 + package server_test 2 + 3 + // This file is the conformance test harness. It treats testdata/swagger.yaml as 4 + // the single source of truth for the API contract and verifies, for every 5 + // documented operation, that: 6 + // - the route is reachable (not 404/405), 7 + // - each documented status code can be produced, 8 + // - content negotiation returns the documented media types, 9 + // - JSON response bodies validate against the swagger schema definitions. 10 + // 11 + // TestSpecCoverage is a meta-test asserting the case table covers the whole 12 + // spec. 13 + 14 + import ( 15 + "bytes" 16 + "context" 17 + "encoding/json" 18 + "errors" 19 + "io" 20 + "mime/multipart" 21 + "net/http" 22 + "net/http/httptest" 23 + "strings" 24 + "testing" 25 + 26 + "git.fogtype.com/nebel/apprize/internal/notify" 27 + "git.fogtype.com/nebel/apprize/internal/server" 28 + "git.fogtype.com/nebel/apprize/internal/store" 29 + "github.com/getkin/kin-openapi/openapi3" 30 + ) 31 + 32 + const specFile = "../../testdata/swagger.yaml" 33 + 34 + // specDoc is the parsed swagger document shared by all tests. 35 + var specDoc = mustLoadSpec() 36 + 37 + func mustLoadSpec() *openapi3.T { 38 + loader := openapi3.NewLoader() 39 + doc, err := loader.LoadFromFile(specFile) 40 + if err != nil { 41 + panic("loading swagger: " + err.Error()) 42 + } 43 + return doc 44 + } 45 + 46 + // --------------------------------------------------------------------------- 47 + // Fake backends 48 + // --------------------------------------------------------------------------- 49 + 50 + // notifierFunc adapts a function to the notify.Notifier interface. 51 + type notifierFunc func(context.Context, []string, string, notify.Options) ([]notify.Result, error) 52 + 53 + func (f notifierFunc) Notify(ctx context.Context, urls []string, body string, opts notify.Options) ([]notify.Result, error) { 54 + return f(ctx, urls, body, opts) 55 + } 56 + 57 + // okNotifier reports success for every URL. 58 + func okNotifier() notify.Notifier { 59 + return notifierFunc(func(_ context.Context, urls []string, _ string, _ notify.Options) ([]notify.Result, error) { 60 + res := make([]notify.Result, len(urls)) 61 + for i, u := range urls { 62 + res[i] = notify.Result{URL: u, Service: "test", OK: true, Message: "Sent"} 63 + } 64 + return res, nil 65 + }) 66 + } 67 + 68 + // failNotifier reports failure for every URL (drives the 424 path). 69 + func failNotifier() notify.Notifier { 70 + return notifierFunc(func(_ context.Context, urls []string, _ string, _ notify.Options) ([]notify.Result, error) { 71 + res := make([]notify.Result, len(urls)) 72 + for i, u := range urls { 73 + res[i] = notify.Result{URL: u, Service: "test", OK: false, Message: "boom"} 74 + } 75 + return res, nil 76 + }) 77 + } 78 + 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 + // --------------------------------------------------------------------------- 93 + // Server construction 94 + // --------------------------------------------------------------------------- 95 + 96 + // baseDeps returns sane defaults for a test server. 97 + func baseDeps() server.Deps { 98 + return server.Deps{ 99 + Notifier: okNotifier(), 100 + Store: store.NewMemory(), 101 + RecursionMax: 1, 102 + Version: "test", 103 + } 104 + } 105 + 106 + // newServer builds a handler, applying the given Deps mutators in order. 107 + func newServer(mods ...func(*server.Deps)) http.Handler { 108 + d := baseDeps() 109 + for _, m := range mods { 110 + m(&d) 111 + } 112 + 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 + } 122 + 123 + // --------------------------------------------------------------------------- 124 + // HTTP helpers 125 + // --------------------------------------------------------------------------- 126 + 127 + type req struct { 128 + method string 129 + path string 130 + contentType string 131 + accept string 132 + body []byte 133 + headers map[string]string 134 + } 135 + 136 + func (r req) do(h http.Handler) *httptest.ResponseRecorder { 137 + var body io.Reader 138 + if r.body != nil { 139 + body = bytes.NewReader(r.body) 140 + } 141 + hr := httptest.NewRequest(r.method, r.path, body) 142 + if r.contentType != "" { 143 + hr.Header.Set("Content-Type", r.contentType) 144 + } 145 + if r.accept != "" { 146 + hr.Header.Set("Accept", r.accept) 147 + } 148 + for k, v := range r.headers { 149 + hr.Header.Set(k, v) 150 + } 151 + rec := httptest.NewRecorder() 152 + h.ServeHTTP(rec, hr) 153 + return rec 154 + } 155 + 156 + func jsonBody(t *testing.T, v any) []byte { 157 + t.Helper() 158 + b, err := json.Marshal(v) 159 + if err != nil { 160 + t.Fatalf("marshal: %v", err) 161 + } 162 + return b 163 + } 164 + 165 + func multipartBody(t *testing.T, fields map[string]string, files map[string][]byte) (string, []byte) { 166 + t.Helper() 167 + var buf bytes.Buffer 168 + w := multipart.NewWriter(&buf) 169 + for k, v := range fields { 170 + if err := w.WriteField(k, v); err != nil { 171 + t.Fatalf("write field: %v", err) 172 + } 173 + } 174 + for name, content := range files { 175 + fw, err := w.CreateFormFile("attach", name) 176 + if err != nil { 177 + t.Fatalf("create file: %v", err) 178 + } 179 + if _, err := fw.Write(content); err != nil { 180 + t.Fatalf("write file: %v", err) 181 + } 182 + } 183 + if err := w.Close(); err != nil { 184 + t.Fatalf("close writer: %v", err) 185 + } 186 + return w.FormDataContentType(), buf.Bytes() 187 + } 188 + 189 + // --------------------------------------------------------------------------- 190 + // Assertions 191 + // --------------------------------------------------------------------------- 192 + 193 + func wantStatus(t *testing.T, rec *httptest.ResponseRecorder, code int) { 194 + t.Helper() 195 + if rec.Code != code { 196 + t.Errorf("status = %d, want %d (body: %q)", rec.Code, code, truncate(rec.Body.String(), 200)) 197 + } 198 + } 199 + 200 + func wantContentType(t *testing.T, rec *httptest.ResponseRecorder, prefix string) { 201 + t.Helper() 202 + got := rec.Header().Get("Content-Type") 203 + if !strings.HasPrefix(got, prefix) { 204 + t.Errorf("Content-Type = %q, want prefix %q", got, prefix) 205 + } 206 + } 207 + 208 + func truncate(s string, n int) string { 209 + if len(s) > n { 210 + return s[:n] + "..." 211 + } 212 + return s 213 + } 214 + 215 + // validateSchema validates a JSON body against the swagger response schema for 216 + // the given operation/status/content-type. Skips silently if no schema is 217 + // documented for that combination. 218 + func validateSchema(t *testing.T, specPath, method, status, contentType string, body []byte) { 219 + t.Helper() 220 + schema := responseSchema(specPath, method, status, contentType) 221 + if schema == nil { 222 + return 223 + } 224 + var v any 225 + if err := json.Unmarshal(body, &v); err != nil { 226 + t.Errorf("response is not valid JSON: %v (body: %q)", err, truncate(string(body), 200)) 227 + return 228 + } 229 + if err := schema.VisitJSON(v); err != nil { 230 + t.Errorf("response does not match swagger schema %s %s %s: %v", method, specPath, status, err) 231 + } 232 + } 233 + 234 + func responseSchema(specPath, method, status, contentType string) *openapi3.Schema { 235 + pi := specDoc.Paths.Find(specPath) 236 + if pi == nil { 237 + return nil 238 + } 239 + op := pi.GetOperation(strings.ToUpper(method)) 240 + if op == nil || op.Responses == nil { 241 + return nil 242 + } 243 + rr := op.Responses.Map()[status] 244 + if rr == nil || rr.Value == nil { 245 + return nil 246 + } 247 + mt := rr.Value.Content[contentType] 248 + if mt == nil || mt.Schema == nil { 249 + return nil 250 + } 251 + return mt.Schema.Value 252 + } 253 + 254 + // --------------------------------------------------------------------------- 255 + // Spec coverage meta-test 256 + // --------------------------------------------------------------------------- 257 + 258 + // TestSpecCoverage asserts the conformance case table exercises every 259 + // (path, method) documented in swagger.yaml, and contains no operation that is 260 + // absent from the spec. 261 + func TestSpecCoverage(t *testing.T) { 262 + covered := map[string]bool{} 263 + for _, c := range conformanceCases { 264 + covered[c.method+" "+c.specPath] = true 265 + } 266 + 267 + documented := map[string]bool{} 268 + for _, p := range specDoc.Paths.InMatchingOrder() { 269 + pi := specDoc.Paths.Find(p) 270 + for method := range pi.Operations() { 271 + documented[method+" "+p] = true 272 + } 273 + } 274 + 275 + for op := range documented { 276 + if !covered[op] { 277 + t.Errorf("operation %q is documented in swagger but not covered by any conformance case", op) 278 + } 279 + } 280 + for op := range covered { 281 + if !documented[op] { 282 + t.Errorf("conformance case targets %q which is not in swagger", op) 283 + } 284 + } 285 + } 286 + 287 + // TestConformance runs every conformance case as a subtest. 288 + func TestConformance(t *testing.T) { 289 + for _, c := range conformanceCases { 290 + t.Run(c.name, func(t *testing.T) { 291 + c.run(t) 292 + }) 293 + } 294 + }
+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 + }
+86
internal/server/meta.go
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "html" 6 + "net/http" 7 + "strings" 8 + 9 + "git.fogtype.com/nebel/apprize/internal/notify" 10 + ) 11 + 12 + // GET /status 13 + func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { 14 + mt := negotiate(r, mediaJSON, mediaText) 15 + 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 + if mt == mediaText { 50 + writeText(w, http.StatusOK, "OK") 51 + return 52 + } 53 + 54 + writeJSON(w, http.StatusOK, map[string]any{ 55 + "attach_lock": attachLock, 56 + "config_lock": s.ConfigLock, 57 + "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()))}, 62 + }, 63 + }) 64 + } 65 + 66 + // GET /details 67 + func (s *server) handleDetails(w http.ResponseWriter, r *http.Request) { 68 + mt := negotiate(r, mediaJSON, mediaHTML) 69 + 70 + if mt == mediaHTML { 71 + var b strings.Builder 72 + b.WriteString("<html><head><title>Apprise Details</title></head><body>") 73 + b.WriteString("<h1>Supported Notification Services</h1><ul>") 74 + for _, schema := range notify.SupportedSchemas() { 75 + fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(schema)) 76 + } 77 + b.WriteString("</ul></body></html>") 78 + writeHTML(w, http.StatusOK, b.String()) 79 + return 80 + } 81 + 82 + writeJSON(w, http.StatusOK, map[string]any{ 83 + "version": s.Version, 84 + "schemas": notify.SupportedSchemas(), 85 + }) 86 + }
+161
internal/server/middleware.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "crypto/subtle" 6 + "io" 7 + "log" 8 + "net/http" 9 + "strconv" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // withMiddlewareChain composes the cross-cutting middleware stack. 15 + // Execution order (outer → inner): requestLog → recover → apiKey → recursion → bodyLimit → handler. 16 + func withMiddlewareChain(s *server, h http.Handler) http.Handler { 17 + h = bodyLimitMiddleware(s.maxBodyBytes())(h) 18 + h = recursionMiddleware(s.RecursionMax)(h) 19 + if s.APIKey != "" { 20 + h = apiKeyMiddleware(s.APIKey)(h) 21 + } 22 + h = recoverMiddleware(h) 23 + h = requestLogMiddleware(h) 24 + return h 25 + } 26 + 27 + // maxBodyBytes returns the effective request-body limit. A zero Deps value 28 + // gets a sensible default so middleware never runs unbounded. 29 + func (s *server) maxBodyBytes() int64 { 30 + if s.MaxBodyBytes > 0 { 31 + return s.MaxBodyBytes 32 + } 33 + return 512 * 1024 // 512 KB, matching APPRIZE_CONFIG_MAX_LENGTH default 34 + } 35 + 36 + // recoverMiddleware converts panics into 500 responses so a single bad request 37 + // cannot take down the server. 38 + func recoverMiddleware(next http.Handler) http.Handler { 39 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 + defer func() { 41 + if rec := recover(); rec != nil { 42 + log.Printf("panic: %v", rec) 43 + http.Error(w, "internal server error", http.StatusInternalServerError) 44 + } 45 + }() 46 + next.ServeHTTP(w, r) 47 + }) 48 + } 49 + 50 + // responseRecorder wraps a ResponseWriter so we can capture the status code 51 + // for request logging without interfering with the real response. 52 + type responseRecorder struct { 53 + http.ResponseWriter 54 + status int 55 + wrote bool 56 + } 57 + 58 + func (rr *responseRecorder) WriteHeader(status int) { 59 + if !rr.wrote { 60 + rr.status = status 61 + rr.wrote = true 62 + rr.ResponseWriter.WriteHeader(status) 63 + } 64 + } 65 + 66 + func (rr *responseRecorder) Write(b []byte) (int, error) { 67 + if !rr.wrote { 68 + rr.WriteHeader(http.StatusOK) 69 + } 70 + return rr.ResponseWriter.Write(b) 71 + } 72 + 73 + // Flush delegates to the wrapped ResponseWriter if it implements http.Flusher. 74 + func (rr *responseRecorder) Flush() { 75 + if f, ok := rr.ResponseWriter.(http.Flusher); ok { 76 + f.Flush() 77 + } 78 + } 79 + 80 + // requestLogMiddleware logs every request with method, path, status and duration. 81 + func requestLogMiddleware(next http.Handler) http.Handler { 82 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 + start := time.Now() 84 + rr := &responseRecorder{ResponseWriter: w} 85 + next.ServeHTTP(rr, r) 86 + status := rr.status 87 + if status == 0 { 88 + status = http.StatusOK 89 + } 90 + log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), status, time.Since(start)) 91 + }) 92 + } 93 + 94 + // bodyLimitMiddleware rejects requests whose body exceeds limit bytes with 431. 95 + // It reads the body up-front so downstream handlers see a fully populated r.Body. 96 + func bodyLimitMiddleware(limit int64) func(http.Handler) http.Handler { 97 + return func(next http.Handler) http.Handler { 98 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 99 + if limit <= 0 || r.Body == nil { 100 + next.ServeHTTP(w, r) 101 + return 102 + } 103 + // Allow an extra byte so we can tell "exactly at limit" from "over limit". 104 + body, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) 105 + _ = r.Body.Close() 106 + if err != nil { 107 + http.Error(w, "unable to read request body", http.StatusBadRequest) 108 + return 109 + } 110 + if int64(len(body)) > limit { 111 + http.Error(w, "request body too large", http.StatusRequestHeaderFieldsTooLarge) 112 + return 113 + } 114 + r.Body = io.NopCloser(bytes.NewReader(body)) 115 + r.ContentLength = int64(len(body)) 116 + next.ServeHTTP(w, r) 117 + }) 118 + } 119 + } 120 + 121 + // apiKeyMiddleware enforces a simple shared secret when APIKey is non-empty. 122 + // Clients may present the key via `Authorization: Bearer <key>` or `X-API-Key: <key>`. 123 + func apiKeyMiddleware(key string) func(http.Handler) http.Handler { 124 + return func(next http.Handler) http.Handler { 125 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 126 + provided := strings.TrimSpace(r.Header.Get("X-API-Key")) 127 + if provided == "" { 128 + auth := r.Header.Get("Authorization") 129 + const prefix = "Bearer " 130 + if strings.HasPrefix(auth, prefix) { 131 + provided = strings.TrimSpace(auth[len(prefix):]) 132 + } 133 + } 134 + if subtle.ConstantTimeCompare([]byte(provided), []byte(key)) != 1 { 135 + http.Error(w, "unauthorized", http.StatusUnauthorized) 136 + return 137 + } 138 + next.ServeHTTP(w, r) 139 + }) 140 + } 141 + } 142 + 143 + // recursionMiddleware blocks requests whose X-Apprise-Recursion-Count header 144 + // exceeds the configured maximum, returning 406 Not Acceptable per swagger. 145 + func recursionMiddleware(max int) func(http.Handler) http.Handler { 146 + return func(next http.Handler) http.Handler { 147 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 + count := 0 149 + if h := r.Header.Get("X-Apprise-Recursion-Count"); h != "" { 150 + if n, err := strconv.Atoi(strings.TrimSpace(h)); err == nil { 151 + count = n 152 + } 153 + } 154 + if count > max { 155 + http.Error(w, "recursion limit reached", http.StatusNotAcceptable) 156 + return 157 + } 158 + next.ServeHTTP(w, r) 159 + }) 160 + } 161 + }
+139
internal/server/middleware_test.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "log" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + ) 11 + 12 + // okHandler writes 200 + "ok" and is used as the inner handler for middleware 13 + // unit tests. 14 + func okHandler() http.Handler { 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + w.Write([]byte("ok")) 17 + }) 18 + } 19 + 20 + func TestBodyLimitMiddleware_AllowsUnderLimit(t *testing.T) { 21 + h := bodyLimitMiddleware(100)(okHandler()) 22 + rec := httptest.NewRecorder() 23 + req := httptest.NewRequest("POST", "/", strings.NewReader("hello")) 24 + h.ServeHTTP(rec, req) 25 + if rec.Code != http.StatusOK { 26 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) 27 + } 28 + } 29 + 30 + func TestBodyLimitMiddleware_RejectsOverLimit(t *testing.T) { 31 + h := bodyLimitMiddleware(4)(okHandler()) 32 + rec := httptest.NewRecorder() 33 + req := httptest.NewRequest("POST", "/", strings.NewReader("hello world")) 34 + h.ServeHTTP(rec, req) 35 + if rec.Code != http.StatusRequestHeaderFieldsTooLarge { 36 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestHeaderFieldsTooLarge) 37 + } 38 + } 39 + 40 + func TestRecursionMiddleware_AllowsUnderLimit(t *testing.T) { 41 + h := recursionMiddleware(5)(okHandler()) 42 + rec := httptest.NewRecorder() 43 + req := httptest.NewRequest("POST", "/", nil) 44 + req.Header.Set("X-Apprise-Recursion-Count", "3") 45 + h.ServeHTTP(rec, req) 46 + if rec.Code != http.StatusOK { 47 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) 48 + } 49 + } 50 + 51 + func TestRecursionMiddleware_RejectsOverLimit(t *testing.T) { 52 + h := recursionMiddleware(1)(okHandler()) 53 + rec := httptest.NewRecorder() 54 + req := httptest.NewRequest("POST", "/", nil) 55 + req.Header.Set("X-Apprise-Recursion-Count", "5") 56 + h.ServeHTTP(rec, req) 57 + if rec.Code != http.StatusNotAcceptable { 58 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotAcceptable) 59 + } 60 + } 61 + 62 + func TestAPIKeyMiddleware_SkipsWhenEmpty(t *testing.T) { 63 + h := apiKeyMiddleware("")(okHandler()) 64 + rec := httptest.NewRecorder() 65 + req := httptest.NewRequest("GET", "/", nil) 66 + h.ServeHTTP(rec, req) 67 + if rec.Code != http.StatusOK { 68 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) 69 + } 70 + } 71 + 72 + func TestAPIKeyMiddleware_AcceptsXAPIKey(t *testing.T) { 73 + h := apiKeyMiddleware("secret")(okHandler()) 74 + rec := httptest.NewRecorder() 75 + req := httptest.NewRequest("GET", "/", nil) 76 + req.Header.Set("X-API-Key", "secret") 77 + h.ServeHTTP(rec, req) 78 + if rec.Code != http.StatusOK { 79 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) 80 + } 81 + } 82 + 83 + func TestAPIKeyMiddleware_AcceptsBearer(t *testing.T) { 84 + h := apiKeyMiddleware("secret")(okHandler()) 85 + rec := httptest.NewRecorder() 86 + req := httptest.NewRequest("GET", "/", nil) 87 + req.Header.Set("Authorization", "Bearer secret") 88 + h.ServeHTTP(rec, req) 89 + if rec.Code != http.StatusOK { 90 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) 91 + } 92 + } 93 + 94 + func TestAPIKeyMiddleware_RejectsBadKey(t *testing.T) { 95 + h := apiKeyMiddleware("secret")(okHandler()) 96 + rec := httptest.NewRecorder() 97 + req := httptest.NewRequest("GET", "/", nil) 98 + req.Header.Set("X-API-Key", "wrong") 99 + h.ServeHTTP(rec, req) 100 + if rec.Code != http.StatusUnauthorized { 101 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) 102 + } 103 + } 104 + 105 + func TestWithMiddlewareChain_LogsPanicAs500(t *testing.T) { 106 + var buf bytes.Buffer 107 + oldOutput := log.Writer() 108 + oldFlags := log.Flags() 109 + oldPrefix := log.Prefix() 110 + log.SetOutput(&buf) 111 + log.SetFlags(0) 112 + log.SetPrefix("") 113 + defer func() { 114 + log.SetOutput(oldOutput) 115 + log.SetFlags(oldFlags) 116 + log.SetPrefix(oldPrefix) 117 + }() 118 + 119 + s := &server{Deps: Deps{RecursionMax: 3}} 120 + panicHandler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { 121 + panic("boom") 122 + }) 123 + h := withMiddlewareChain(s, panicHandler) 124 + 125 + rec := httptest.NewRecorder() 126 + req := httptest.NewRequest(http.MethodGet, "/panic", nil) 127 + h.ServeHTTP(rec, req) 128 + 129 + if rec.Code != http.StatusInternalServerError { 130 + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) 131 + } 132 + logs := buf.String() 133 + if !strings.Contains(logs, "panic: boom") { 134 + t.Fatalf("expected panic log, got %q", logs) 135 + } 136 + if !strings.Contains(logs, "GET /panic 500 ") { 137 + t.Fatalf("expected request log with 500 status, got %q", logs) 138 + } 139 + }
+139
internal/server/negotiate.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + "strings" 7 + ) 8 + 9 + // mediaType is a response representation the server can produce. Handlers offer 10 + // the representations they support and negotiate picks one from the request's 11 + // Accept header. 12 + type mediaType int 13 + 14 + const ( 15 + mediaJSON mediaType = iota 16 + mediaText 17 + mediaHTML 18 + ) 19 + 20 + // mime returns the bare media type used for Accept matching. 21 + func (m mediaType) mime() string { 22 + switch m { 23 + case mediaText: 24 + return "text/plain" 25 + case mediaHTML: 26 + return "text/html" 27 + default: 28 + return "application/json" 29 + } 30 + } 31 + 32 + // contentType returns the value written to the Content-Type response header. 33 + func (m mediaType) contentType() string { 34 + switch m { 35 + case mediaText: 36 + return "text/plain; charset=utf-8" 37 + case mediaHTML: 38 + return "text/html; charset=utf-8" 39 + default: 40 + return "application/json" 41 + } 42 + } 43 + 44 + // negotiate selects the best representation for r from the offered set, which is 45 + // listed in server preference order (best first). When the Accept header is 46 + // absent, is "*/*", or matches none of the offers, the first offer wins — this 47 + // is the endpoint's representative content type per the swagger contract. 48 + func negotiate(r *http.Request, offers ...mediaType) mediaType { 49 + if len(offers) == 0 { 50 + return mediaJSON 51 + } 52 + accept := strings.TrimSpace(r.Header.Get("Accept")) 53 + if accept == "" { 54 + return offers[0] 55 + } 56 + 57 + ranges := parseAccept(accept) 58 + best := offers[0] 59 + bestQ := -1.0 60 + for _, off := range offers { 61 + // Strict ">" keeps earlier (more preferred) offers on ties. 62 + if q := qualityFor(ranges, off.mime()); q > bestQ { 63 + bestQ = q 64 + best = off 65 + } 66 + } 67 + if bestQ <= 0 { 68 + return offers[0] 69 + } 70 + return best 71 + } 72 + 73 + // mediaRange is a single parsed entry from an Accept header. 74 + type mediaRange struct { 75 + typ, sub string 76 + q float64 77 + } 78 + 79 + // parseAccept parses an Accept header into its media ranges, honouring q-values. 80 + func parseAccept(header string) []mediaRange { 81 + var out []mediaRange 82 + for _, part := range strings.Split(header, ",") { 83 + segs := strings.Split(part, ";") 84 + mt := strings.TrimSpace(segs[0]) 85 + slash := strings.IndexByte(mt, '/') 86 + if slash < 0 { 87 + continue 88 + } 89 + mr := mediaRange{ 90 + typ: strings.ToLower(mt[:slash]), 91 + sub: strings.ToLower(mt[slash+1:]), 92 + q: 1.0, 93 + } 94 + for _, p := range segs[1:] { 95 + if p = strings.TrimSpace(p); strings.HasPrefix(p, "q=") { 96 + if v, err := strconv.ParseFloat(p[2:], 64); err == nil { 97 + mr.q = v 98 + } 99 + } 100 + } 101 + out = append(out, mr) 102 + } 103 + return out 104 + } 105 + 106 + // qualityFor returns the q-value the ranges assign to mime, picking the most 107 + // specific matching range (exact > type/* > */*). Zero means not acceptable. 108 + func qualityFor(ranges []mediaRange, mime string) float64 { 109 + slash := strings.IndexByte(mime, '/') 110 + typ, sub := mime[:slash], mime[slash+1:] 111 + best := 0.0 112 + bestSpec := -1 113 + for _, mr := range ranges { 114 + spec := matchSpecificity(mr, typ, sub) 115 + if spec < 0 { 116 + continue 117 + } 118 + if spec > bestSpec || (spec == bestSpec && mr.q > best) { 119 + bestSpec = spec 120 + best = mr.q 121 + } 122 + } 123 + return best 124 + } 125 + 126 + // matchSpecificity scores how specifically mr matches typ/sub: 2 exact, 1 127 + // type wildcard, 0 full wildcard, -1 no match. 128 + func matchSpecificity(mr mediaRange, typ, sub string) int { 129 + switch { 130 + case mr.typ == typ && mr.sub == sub: 131 + return 2 132 + case mr.typ == typ && mr.sub == "*": 133 + return 1 134 + case mr.typ == "*" && mr.sub == "*": 135 + return 0 136 + default: 137 + return -1 138 + } 139 + }
+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
internal/server/respond.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // writeJSON writes v as JSON with the given status code. 9 + func writeJSON(w http.ResponseWriter, code int, v any) { 10 + w.Header().Set("Content-Type", "application/json") 11 + w.WriteHeader(code) 12 + _ = json.NewEncoder(w).Encode(v) 13 + } 14 + 15 + // writeText writes a plain text response. 16 + func writeText(w http.ResponseWriter, code int, text string) { 17 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 18 + w.WriteHeader(code) 19 + _, _ = w.Write([]byte(text)) 20 + } 21 + 22 + // writeHTML writes an HTML response. 23 + func writeHTML(w http.ResponseWriter, code int, html string) { 24 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 25 + w.WriteHeader(code) 26 + _, _ = w.Write([]byte(html)) 27 + }
+68
internal/server/server.go
··· 1 + // Package server wires the Apprise-compatible HTTP API. 2 + package server 3 + 4 + import ( 5 + "net/http" 6 + 7 + "git.fogtype.com/nebel/apprize/internal/notify" 8 + "git.fogtype.com/nebel/apprize/internal/store" 9 + ) 10 + 11 + // Deps holds everything a server instance needs. Tests construct it directly 12 + // with a fake Notifier and an in-memory Store. 13 + type Deps struct { 14 + Notifier notify.Notifier 15 + Store store.Store 16 + 17 + // 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 27 + 28 + Version string 29 + } 30 + 31 + // server is the concrete handler holding dependencies. 32 + type server struct { 33 + Deps 34 + } 35 + 36 + // New returns the HTTP handler implementing the Apprise API. 37 + func New(d Deps) http.Handler { 38 + s := &server{Deps: d} 39 + mux := http.NewServeMux() 40 + 41 + // Meta — P1 42 + mux.HandleFunc("GET /status", s.handleStatus) 43 + mux.HandleFunc("GET /details", s.handleDetails) 44 + 45 + // Stateless — P2+ 46 + mux.HandleFunc("POST /notify", s.handleNotify) 47 + 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) 62 + } 63 + 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) 68 + }
+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 + }
+87
main.go
··· 1 + // Command apprize runs an Apprise-API-compatible notification server. 2 + package main 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "flag" 8 + "log" 9 + "net/http" 10 + "os/signal" 11 + "syscall" 12 + "time" 13 + 14 + "git.fogtype.com/nebel/apprize/internal/config" 15 + "git.fogtype.com/nebel/apprize/internal/notify" 16 + "git.fogtype.com/nebel/apprize/internal/server" 17 + "git.fogtype.com/nebel/apprize/internal/store" 18 + ) 19 + 20 + // version is overridable at build time via -ldflags. 21 + var version = "dev" 22 + 23 + func main() { 24 + cfg := config.FromEnv() 25 + 26 + 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 + 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 + flag.Parse() 32 + 33 + if err := run(cfg); err != nil { 34 + log.Fatal(err) 35 + } 36 + } 37 + 38 + 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 + 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, 64 + }) 65 + 66 + srv := &http.Server{ 67 + Addr: cfg.Bind, 68 + Handler: h, 69 + ReadHeaderTimeout: 10 * time.Second, 70 + } 71 + 72 + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 73 + defer stop() 74 + 75 + go func() { 76 + log.Printf("apprize %s listening on %s", version, cfg.Bind) 77 + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 78 + log.Printf("server error: %v", err) 79 + stop() 80 + } 81 + }() 82 + 83 + <-ctx.Done() 84 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 85 + defer cancel() 86 + return srv.Shutdown(shutdownCtx) 87 + }
+2
mise.toml
··· 1 + [tools] 2 + go = "latest"
+9
renovate.json
··· 1 + { 2 + "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 + "extends": [ 4 + "config:best-practices", 5 + ":automergeAll", 6 + ":automergeBranch", 7 + ":skipStatusChecks" 8 + ] 9 + }
+664
testdata/swagger.yaml
··· 1 + openapi: '3.0.3' 2 + 3 + info: 4 + title: Apprise API 5 + description: > 6 + A lightweight REST framework that wraps the Apprise API Notification 7 + Library. See https://github.com/caronc/apprise-api for details. 8 + version: 1.5.0 9 + 10 + servers: 11 + - url: http://localhost:8000 12 + description: Local hosting of Apprise API 13 + - url: / 14 + description: Relative path (Current Host of Apprise API instance) 15 + 16 + tags: 17 + - name: Stateless 18 + description: Stateless notification endpoints that do not use a stored key. 19 + - name: Persistent 20 + description: Endpoints that work with pre-saved configurations identified by a {key}. 21 + - name: Meta 22 + description: Introspection and system endpoints. 23 + 24 + paths: 25 + /status: 26 + get: 27 + operationId: Meta_GetStatus 28 + summary: Server Health Check 29 + description: > 30 + Performs a health check on the server configuration. 31 + Returns 200 if OK, 417 if there is a configuration/permission issue. 32 + tags: 33 + - Meta 34 + responses: 35 + '200': 36 + description: Server is healthy. 37 + content: 38 + text/plain: 39 + schema: 40 + type: string 41 + example: "OK" 42 + application/json: 43 + schema: 44 + $ref: '#/components/schemas/StatusResponse' 45 + '417': 46 + description: Expectation Failed (Configuration or Permission issues). 47 + content: 48 + text/plain: 49 + schema: 50 + type: string 51 + example: "CONFIG_PERMISSION_ISSUE" 52 + application/json: 53 + schema: 54 + $ref: '#/components/schemas/StatusResponse' 55 + 56 + /details: 57 + get: 58 + operationId: Meta_GetDetails 59 + summary: Returns details of all supported Apprise URLs and their options. 60 + description: > 61 + Returns a list of supported notification services. 62 + If Accept header is 'text/html', renders a UI page. 63 + If Accept header is 'application/json', returns the JSON structure. 64 + tags: 65 + - Meta 66 + parameters: 67 + - in: query 68 + name: all 69 + schema: 70 + type: string 71 + enum: ["yes", "no"] 72 + description: Show all plugins (including disabled ones). 73 + responses: 74 + '200': 75 + description: Details of supported services. 76 + content: 77 + application/json: 78 + schema: 79 + type: object 80 + text/html: 81 + schema: 82 + type: string 83 + 84 + /notify: 85 + post: 86 + operationId: Stateless_SendNotification 87 + summary: > 88 + Sends one or more notifications to the URLs identified as part 89 + of the payload or in APPRISE_STATELESS_URLS. 90 + tags: 91 + - Stateless 92 + parameters: 93 + - $ref: '#/components/parameters/RecursionHeader' 94 + - $ref: '#/components/parameters/IdHeader' 95 + requestBody: 96 + required: true 97 + content: 98 + application/json: 99 + schema: 100 + $ref: '#/components/schemas/StatelessNotificationRequest' 101 + multipart/form-data: 102 + schema: 103 + $ref: '#/components/schemas/StatelessNotificationMultipart' 104 + application/x-www-form-urlencoded: 105 + schema: 106 + $ref: '#/components/schemas/StatelessNotificationForm' 107 + responses: 108 + '200': 109 + description: > 110 + Notification accepted. Response contains processing logs. 111 + content: 112 + text/plain: 113 + schema: 114 + type: string 115 + example: "2025-01-01 12:00:00,000 - INFO - Sent to Telegram" 116 + text/html: 117 + schema: 118 + type: string 119 + description: Rendered HTML logs. 120 + application/json: 121 + schema: 122 + $ref: '#/components/schemas/LogResponse' 123 + '204': 124 + description: No Content (No valid URLs provided). 125 + '400': 126 + description: Bad Request (Invalid payload or format). 127 + '406': 128 + description: Method Not Accepted (Recursion limit reached). 129 + '424': 130 + description: One or more notifications could not be sent. 131 + '431': 132 + description: Request Header Fields Too Large (JSON Payload too big). 133 + 134 + /cfg: 135 + get: 136 + operationId: Persistent_ListConfigurations 137 + summary: List all stored configuration keys. 138 + description: > 139 + Returns a list of all Config Keys currently stored. 140 + Requires APPRISE_ADMIN to be enabled in server settings. 141 + tags: 142 + - Persistent 143 + responses: 144 + '200': 145 + description: List of keys retrieved. 146 + content: 147 + application/json: 148 + schema: 149 + type: array 150 + items: 151 + type: string 152 + text/html: 153 + schema: 154 + type: string 155 + '403': 156 + description: Access Denied (APPRISE_ADMIN not enabled). 157 + 158 + /add/{key}: 159 + post: 160 + operationId: Persistent_AddConfiguration 161 + summary: Saves Apprise configuration (or set of URLs) to the persistent store. 162 + tags: 163 + - Persistent 164 + parameters: 165 + - $ref: '#/components/parameters/key' 166 + requestBody: 167 + required: false 168 + content: 169 + application/json: 170 + schema: 171 + $ref: '#/components/schemas/AddConfigurationRequest' 172 + application/x-www-form-urlencoded: 173 + schema: 174 + $ref: '#/components/schemas/AddConfigurationForm' 175 + responses: 176 + '200': 177 + description: Configuration stored (or updated). 178 + content: 179 + text/plain: 180 + schema: 181 + type: string 182 + example: "Successfully saved configuration" 183 + application/json: 184 + schema: 185 + type: object 186 + properties: 187 + error: 188 + type: string 189 + nullable: true 190 + '400': 191 + description: Bad Request (Invalid format or payload). 192 + '403': 193 + description: Forbidden (Config Lock enabled). 194 + '431': 195 + description: Payload Too Large. 196 + '500': 197 + description: Internal Error (Could not write to disk). 198 + 199 + /del/{key}: 200 + post: 201 + operationId: Persistent_RemoveConfiguration 202 + summary: Removes Apprise configuration from the persistent store. 203 + tags: 204 + - Persistent 205 + parameters: 206 + - $ref: '#/components/parameters/key' 207 + responses: 208 + '200': 209 + description: Configuration removed. 210 + '204': 211 + description: No content (Configuration did not exist). 212 + '403': 213 + description: Forbidden (Config Lock enabled). 214 + '500': 215 + description: Internal Error (Could not delete from disk). 216 + 217 + /get/{key}: 218 + post: 219 + operationId: Persistent_GetConfiguration 220 + summary: > 221 + Returns the Apprise configuration for {key}. 222 + description: > 223 + Depending on the Accept header and how the config was stored, 224 + returns TEXT, YAML, or JSON. 225 + tags: 226 + - Persistent 227 + parameters: 228 + - $ref: '#/components/parameters/key' 229 + responses: 230 + '200': 231 + description: Configuration returned. 232 + content: 233 + text/plain: 234 + schema: 235 + type: string 236 + description: Returned if config was saved as TEXT. 237 + text/yaml: 238 + schema: 239 + type: string 240 + description: Returned if config was saved as YAML. 241 + application/json: 242 + schema: 243 + $ref: '#/components/schemas/ConfigResponse' 244 + '204': 245 + description: No content (Key found but empty). 246 + '403': 247 + description: Forbidden (Config Lock enabled). 248 + '500': 249 + description: Internal Server Error. 250 + 251 + /cfg/{key}: 252 + post: 253 + operationId: Persistent_GetConfigurationAlias 254 + summary: > 255 + Alias for /get/{key}. Returns the Apprise configuration. 256 + tags: 257 + - Persistent 258 + parameters: 259 + - $ref: '#/components/parameters/key' 260 + responses: 261 + '200': 262 + description: Configuration returned. 263 + content: 264 + text/plain: 265 + schema: 266 + type: string 267 + text/yaml: 268 + schema: 269 + type: string 270 + application/json: 271 + schema: 272 + $ref: '#/components/schemas/ConfigResponse' 273 + '204': 274 + description: No content. 275 + '403': 276 + description: Forbidden. 277 + '500': 278 + description: Internal Server Error. 279 + 280 + /notify/{key}: 281 + post: 282 + operationId: Persistent_SendNotification 283 + summary: > 284 + Sends notification(s) to all of the endpoints previously 285 + configured under {key}, optionally filtered by tag. 286 + tags: 287 + - Persistent 288 + parameters: 289 + - $ref: '#/components/parameters/key' 290 + - $ref: '#/components/parameters/RecursionHeader' 291 + - $ref: '#/components/parameters/IdHeader' 292 + requestBody: 293 + required: false 294 + content: 295 + application/json: 296 + schema: 297 + $ref: '#/components/schemas/PersistentNotificationRequest' 298 + multipart/form-data: 299 + schema: 300 + $ref: '#/components/schemas/PersistentNotificationMultipart' 301 + application/x-www-form-urlencoded: 302 + schema: 303 + $ref: '#/components/schemas/PersistentNotificationForm' 304 + responses: 305 + '200': 306 + description: Notification accepted. Response contains logs. 307 + content: 308 + text/plain: 309 + schema: 310 + type: string 311 + text/html: 312 + schema: 313 + type: string 314 + application/json: 315 + schema: 316 + $ref: '#/components/schemas/LogResponse' 317 + '204': 318 + description: No configuration found for this key. 319 + '406': 320 + description: Method Not Accepted (Recursion limit reached). 321 + '424': 322 + description: One or more notifications could not be sent. 323 + '431': 324 + description: Payload Too Large. 325 + 326 + /json/urls/{key}: 327 + get: 328 + operationId: Persistent_GetUrls 329 + summary: > 330 + Returns a JSON object describing the URLs and tags associated 331 + with {key}. 332 + tags: 333 + - Persistent 334 + parameters: 335 + - $ref: '#/components/parameters/key' 336 + - in: query 337 + name: privacy 338 + schema: 339 + type: integer 340 + enum: [0, 1] 341 + x-enumNames: ["ShowSecrets", "HideSecrets"] 342 + required: false 343 + description: > 344 + When set to 1, secrets within URLs are redacted. 345 + - in: query 346 + name: tag 347 + schema: 348 + type: string 349 + default: all 350 + required: false 351 + description: > 352 + Filter URLs by tag expression. 353 + Comma (",") or Pipe ("|") for OR, space (" ") for AND. 354 + Use "all" to select everything. 355 + responses: 356 + '200': 357 + description: URL listing. 358 + content: 359 + application/json: 360 + schema: 361 + $ref: '#/components/schemas/JsonUrlsResponse' 362 + '204': 363 + description: No content. 364 + 365 + components: 366 + parameters: 367 + key: 368 + in: path 369 + name: key 370 + required: true 371 + schema: 372 + type: string 373 + minLength: 1 374 + maxLength: 128 375 + description: Configuration key (1-128 chars, alphanumeric, _, -). 376 + RecursionHeader: 377 + in: header 378 + name: X-Apprise-Recursion-Count 379 + schema: 380 + type: integer 381 + default: 0 382 + description: Internal header to track recursion depth. 383 + IdHeader: 384 + in: header 385 + name: X-Apprise-ID 386 + schema: 387 + type: string 388 + description: Optional Unique ID to associate with the notification. 389 + 390 + schemas: 391 + NotificationType: 392 + type: string 393 + description: Logical type of the notification. 394 + enum: 395 + - info 396 + - success 397 + - warning 398 + - failure 399 + default: info 400 + 401 + NotificationFormat: 402 + type: string 403 + description: Text format of the message body. 404 + enum: 405 + - text 406 + - markdown 407 + - html 408 + default: text 409 + 410 + StatusResponse: 411 + type: object 412 + properties: 413 + attach_lock: 414 + type: boolean 415 + config_lock: 416 + type: boolean 417 + status: 418 + type: object 419 + properties: 420 + persistent_storage: 421 + type: boolean 422 + can_write_config: 423 + type: boolean 424 + can_write_attach: 425 + type: boolean 426 + details: 427 + type: array 428 + items: 429 + type: string 430 + 431 + ConfigResponse: 432 + type: object 433 + properties: 434 + format: 435 + type: string 436 + description: The format of the returned config (text or yaml). 437 + config: 438 + type: string 439 + description: The raw configuration content. 440 + 441 + LogResponse: 442 + type: object 443 + properties: 444 + error: 445 + type: string 446 + nullable: true 447 + details: 448 + type: array 449 + description: A list of log entries [Level, Date, Message]. 450 + items: 451 + type: array 452 + items: 453 + type: string 454 + 455 + # JSON Request 456 + StatelessNotificationRequest: 457 + type: object 458 + properties: 459 + urls: 460 + type: array 461 + description: > 462 + One or more Apprise URLs. If omitted, the environment 463 + variable APPRISE_STATELESS_URLS is used (if set). 464 + items: 465 + type: string 466 + body: 467 + type: string 468 + description: Message body to send. 469 + title: 470 + type: string 471 + description: Optional message title. 472 + type: 473 + $ref: '#/components/schemas/NotificationType' 474 + format: 475 + $ref: '#/components/schemas/NotificationFormat' 476 + tag: 477 + type: string 478 + description: > 479 + Optional Apprise tag expression. Supports tag, priority:tag, 480 + tag:retry, and priority:tag:retry tokens. Comma or pipe separates 481 + OR groups; whitespace, ampersand, or plus separates AND terms. 482 + required: 483 + - body 484 + 485 + # Multipart Request (Files) 486 + StatelessNotificationMultipart: 487 + type: object 488 + properties: 489 + urls: 490 + type: string 491 + description: Comma separated list of Apprise URLs. 492 + body: 493 + type: string 494 + title: 495 + type: string 496 + type: 497 + $ref: '#/components/schemas/NotificationType' 498 + format: 499 + $ref: '#/components/schemas/NotificationFormat' 500 + attach: 501 + type: array 502 + items: 503 + type: string 504 + format: binary 505 + description: File attachments. 506 + required: 507 + - body 508 + 509 + # Form URL Encoded Request 510 + StatelessNotificationForm: 511 + type: object 512 + properties: 513 + urls: 514 + type: string 515 + body: 516 + type: string 517 + title: 518 + type: string 519 + type: 520 + $ref: '#/components/schemas/NotificationType' 521 + format: 522 + $ref: '#/components/schemas/NotificationFormat' 523 + required: 524 + - body 525 + 526 + AddConfigurationRequest: 527 + type: object 528 + description: > 529 + Either supply urls, or supply config (with format). 530 + properties: 531 + config: 532 + type: string 533 + description: TEXT or YAML Apprise configuration. 534 + format: 535 + type: string 536 + enum: [text, yaml, auto] 537 + 538 + AddConfigurationForm: 539 + type: object 540 + properties: 541 + urls: 542 + type: string 543 + description: Comma separated URLs. 544 + config: 545 + type: string 546 + format: 547 + type: string 548 + enum: [text, yaml, auto] 549 + 550 + PersistentNotificationRequest: 551 + type: object 552 + properties: 553 + body: 554 + type: string 555 + title: 556 + type: string 557 + type: 558 + $ref: '#/components/schemas/NotificationType' 559 + format: 560 + $ref: '#/components/schemas/NotificationFormat' 561 + tag: 562 + type: string 563 + default: all 564 + description: > 565 + Optional Apprise tag expression. Supports tag, priority:tag, 566 + tag:retry, and priority:tag:retry tokens. Comma or pipe separates 567 + OR groups; whitespace, ampersand, or plus separates AND terms. 568 + required: 569 + - body 570 + 571 + PersistentNotificationMultipart: 572 + type: object 573 + properties: 574 + body: 575 + type: string 576 + title: 577 + type: string 578 + type: 579 + $ref: '#/components/schemas/NotificationType' 580 + format: 581 + $ref: '#/components/schemas/NotificationFormat' 582 + tag: 583 + type: string 584 + description: > 585 + Optional Apprise tag expression. Supports tag, priority:tag, 586 + tag:retry, and priority:tag:retry tokens. Comma or pipe separates 587 + OR groups; whitespace, ampersand, or plus separates AND terms. 588 + attach: 589 + type: array 590 + items: 591 + type: string 592 + format: binary 593 + required: 594 + - body 595 + 596 + PersistentNotificationForm: 597 + type: object 598 + properties: 599 + body: 600 + type: string 601 + title: 602 + type: string 603 + type: 604 + $ref: '#/components/schemas/NotificationType' 605 + format: 606 + $ref: '#/components/schemas/NotificationFormat' 607 + tag: 608 + type: string 609 + description: > 610 + Optional Apprise tag expression. Supports tag, priority:tag, 611 + tag:retry, and priority:tag:retry tokens. Comma or pipe separates 612 + OR groups; whitespace, ampersand, or plus separates AND terms. 613 + required: 614 + - body 615 + 616 + JsonUrlsResponse: 617 + type: object 618 + properties: 619 + tags: 620 + type: array 621 + description: Bare tag names available in this configuration. 622 + items: 623 + type: string 624 + urls: 625 + type: array 626 + items: 627 + $ref: '#/components/schemas/url' 628 + 629 + url: 630 + type: object 631 + properties: 632 + id: 633 + type: string 634 + service_name: 635 + type: string 636 + enabled: 637 + type: boolean 638 + url: 639 + type: string 640 + retry: 641 + type: integer 642 + description: Configured retry count for this URL. 643 + optional: 644 + type: boolean 645 + description: Whether failures from this URL are ignored when determining notification success. 646 + tags: 647 + type: array 648 + description: Bare tag names associated with this URL. 649 + items: 650 + type: string 651 + tag_details: 652 + type: array 653 + description: Structured tag metadata, including priority-aware exact tokens. 654 + items: 655 + type: object 656 + properties: 657 + name: 658 + type: string 659 + priority: 660 + type: integer 661 + token: 662 + type: string 663 + exact: 664 + type: string