├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── build.zig ├── build.zig.zon ├── lib ├── markdown │ ├── cmark-aolium.h │ ├── cmark-gfm-extension_api.h │ ├── cmark-gfm.h │ ├── cmark-gfm_export.h │ └── cmark-gfm_version.h ├── raw_json │ └── raw_json.zig └── sqlite3 │ ├── sqlite3.c │ └── sqlite3.h ├── readme.md ├── src ├── aolium.zig ├── app.zig ├── config.zig ├── env.zig ├── init.zig ├── main.zig ├── markdown.zig ├── migrations │ ├── auth │ │ ├── migrate_1.zig │ │ └── migrations.zig │ ├── conn.zig │ ├── data │ │ ├── migrate_1.zig │ │ ├── migrate_2.zig │ │ └── migrations.zig │ └── migrations.zig ├── t.zig ├── user.zig ├── version.txt └── web │ ├── auth │ ├── _auth.zig │ ├── check.zig │ ├── login.zig │ ├── logout.zig │ └── register.zig │ ├── comments │ ├── _comments.zig │ ├── approve.zig │ ├── count.zig │ ├── create.zig │ ├── delete.zig │ └── index.zig │ ├── dispatcher.zig │ ├── misc │ └── _misc.zig │ ├── posts │ ├── _posts.zig │ ├── create.zig │ ├── index.zig │ ├── show.zig │ └── update.zig │ └── web.zig └── test_runner.zig /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*.*.*" 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Set env 19 | run: | 20 | echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" > src/version.txt 21 | 22 | - name: install zig 23 | run: | 24 | sudo snap install zig --classic --edge 25 | echo "zig: $(zig version)" >> src/version.txt 26 | 27 | - name: commit 28 | run: | 29 | echo "commit: $(git rev-parse HEAD | tr -d '\n')" >> src/version.txt 30 | 31 | - name: build-x86_64-linux-gnu 32 | run: | 33 | zig build -Doptimize=ReleaseFast -Dcpu=skylake -Dtarget=x86_64-linux-gnu 34 | mkdir -p release/aolium-x86_64-linux-gnu/ 35 | mv zig-out/bin/aolium release/aolium-x86_64-linux-gnu/ 36 | 37 | - name: create archive 38 | run: | 39 | cd release 40 | tar -cJf aolium-x86_64-linux-gnu.tar.xz aolium-x86_64-linux-gnu 41 | 42 | - name: release 43 | uses: softprops/action-gh-release@v1 44 | with: 45 | files: | 46 | /home/runner/work/aolium/aolium/release/aolium-x86_64-linux-gnu.tar.xz 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | zig-out/ 3 | zig-cache/ 4 | tests/db 5 | 6 | *.so 7 | *.dylib 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | F= 2 | .PHONY: t 3 | t: 4 | TEST_FILTER="${F}" zig build test --summary all -freference-trace 5 | 6 | .PHONY: s 7 | s: 8 | zig build run -freference-trace -- --root /tmp/aolium/ --log_http 9 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const LazyPath = std.Build.LazyPath; 4 | const ModuleMap = std.StringArrayHashMap(*std.Build.Module); 5 | 6 | pub fn build(b: *std.Build) !void { 7 | const target = b.standardTargetOptions(.{}); 8 | const optimize = b.standardOptimizeOption(.{}); 9 | 10 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 11 | const allocator = gpa.allocator(); 12 | 13 | var modules = ModuleMap.init(allocator); 14 | defer modules.deinit(); 15 | 16 | const dep_opts = .{.target = target,.optimize = optimize}; 17 | 18 | try modules.put("zul", b.dependency("zul", dep_opts).module("zul")); 19 | try modules.put("logz", b.dependency("logz", dep_opts).module("logz")); 20 | 21 | try modules.put("httpz", b.dependency("httpz", dep_opts).module("httpz")); 22 | try modules.put("cache", b.dependency("cache", dep_opts).module("cache")); 23 | try modules.put("typed", b.dependency("typed", dep_opts).module("typed")); 24 | try modules.put("validate", b.dependency("validate", dep_opts).module("validate")); 25 | 26 | const zqlite = b.dependency("zqlite", dep_opts).module("zqlite"); 27 | zqlite.addCSourceFile(.{ 28 | .file = b.path("lib/sqlite3/sqlite3.c"), 29 | .flags = &[_][]const u8{ 30 | "-DSQLITE_DQS=0", 31 | "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", 32 | "-DSQLITE_USE_ALLOCA=1", 33 | "-DSQLITE_THREADSAFE=1", 34 | "-DSQLITE_TEMP_STORE=3", 35 | "-DSQLITE_ENABLE_API_ARMOR=1", 36 | "-DSQLITE_ENABLE_UNLOCK_NOTIFY", 37 | "-DSQLITE_ENABLE_UPDATE_DELETE_LIMIT=1", 38 | "-DSQLITE_DEFAULT_FILE_PERMISSIONS=0600", 39 | "-DSQLITE_OMIT_DECLTYPE=1", 40 | "-DSQLITE_OMIT_DEPRECATED=1", 41 | "-DSQLITE_OMIT_LOAD_EXTENSION=1", 42 | "-DSQLITE_OMIT_PROGRESS_CALLBACK=1", 43 | "-DSQLITE_OMIT_SHARED_CACHE", 44 | "-DSQLITE_OMIT_TRACE=1", 45 | "-DSQLITE_OMIT_UTF16=1", 46 | "-DHAVE_USLEEP=0", 47 | }, 48 | }); 49 | zqlite.addIncludePath(b.path("lib/sqlite3/")); 50 | try modules.put("zqlite", zqlite); 51 | 52 | // local libraries 53 | try modules.put("raw_json", b.addModule("raw_json", .{.root_source_file = .{.path = "lib/raw_json/raw_json.zig"}})); 54 | 55 | // setup executable 56 | const exe = b.addExecutable(.{ 57 | .name = "aolium", 58 | .root_source_file = .{ .path = "src/main.zig" }, 59 | .target = target, 60 | .optimize = optimize, 61 | }); 62 | try addLibs(b, exe, modules); 63 | b.installArtifact(exe); 64 | 65 | const run_cmd = b.addRunArtifact(exe); 66 | run_cmd.step.dependOn(b.getInstallStep()); 67 | if (b.args) |args| { 68 | run_cmd.addArgs(args); 69 | } 70 | 71 | const run_step = b.step("run", "Run the app"); 72 | run_step.dependOn(&run_cmd.step); 73 | 74 | // setup tests 75 | const tests = b.addTest(.{ 76 | .root_source_file = .{ .path = "src/main.zig" }, 77 | .target = target, 78 | .optimize = optimize, 79 | .test_runner = b.path("test_runner.zig"), 80 | }); 81 | 82 | try addLibs(b, tests, modules); 83 | const run_test = b.addRunArtifact(tests); 84 | run_test.has_side_effects = true; 85 | 86 | const test_step = b.step("test", "Run tests"); 87 | test_step.dependOn(&run_test.step); 88 | } 89 | 90 | fn addLibs(b: *std.Build, step: *std.Build.Step.Compile, modules: ModuleMap) !void { 91 | var it = modules.iterator(); 92 | while (it.next()) |m| { 93 | step.root_module.addImport(m.key_ptr.*, m.value_ptr.*); 94 | } 95 | 96 | step.linkLibC(); 97 | 98 | step.addRPath(b.path("lib/markdown")); 99 | step.addLibraryPath(b.path("lib/markdown")); 100 | step.addIncludePath(b.path("lib/markdown")); 101 | step.linkSystemLibrary("cmark-gfm"); 102 | step.linkSystemLibrary("cmark-gfm-extensions"); 103 | } 104 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = "aolium", 3 | .paths = .{""}, 4 | .version = "0.0.0", 5 | .dependencies = .{ 6 | .httpz = .{ 7 | .url = "https://github.com/karlseguin/http.zig/archive/6946917979ec263574cb5586032f655e8c6728d9.tar.gz", 8 | .hash = "1220141aeed687703aa42ed5b219715e33fc8805c8bbb5617f638b4974fd20d57d67" 9 | }, 10 | .cache = .{ 11 | .url = "https://github.com/karlseguin/cache.zig/archive/2ef2f773a9a52c1bfb2d34d1bdc546357b234fa6.tar.gz", 12 | .hash = "12201d35c6f56f106df987e6881e5caab9fd716088cb75d2c9103ec3a02ac12d9475" 13 | }, 14 | .logz = .{ 15 | .url = "https://github.com/karlseguin/log.zig/archive/138d33e531522d862746a00b422fd6a046dcb46b.tar.gz", 16 | .hash = "1220a28e3f384aa445b8400b85f78c8525464044a724d31197ea7881024f752a876d" 17 | }, 18 | .typed = .{ 19 | .url = "https://github.com/karlseguin/typed.zig/archive/5d1e1f0d2bf921368351dc13d4426b894d0ccd4e.tar.gz", 20 | .hash = "12206501c104976af3c5f883fb7202c9950920328dd7f3baf187d8c614ac627ced24" 21 | }, 22 | .validate = .{ 23 | .url = "https://github.com/karlseguin/validate.zig/archive/070d49ae66676a4b9b9d9810ee4f2a8a621e9acc.tar.gz", 24 | .hash = "12206ccc6e5ae35c3a261ed4b1ac675a68d8cf44d043de6dbb811f76b4ece21a343a" 25 | }, 26 | .zqlite = .{ 27 | .url = "https://github.com/karlseguin/zqlite.zig/archive/fa961d7422f05e4cc546e9bbc6e98296e82fc82a.tar.gz", 28 | .hash = "12205e11bd7e60af91e3fe96b7fd90c2bfd73bdae778dbee9d243a5027e06fc3c101" 29 | }, 30 | .zul = .{ 31 | .url = "https://github.com/karlseguin/zul/archive/52287aeb567fe773f8cc844e588ee45438b6c6bb.tar.gz", 32 | .hash = "1220ccbe14ea7a88f124f01ac43345c98b2255c737ba32fa84c373ab8bed3a7c73ed" 33 | } 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /lib/markdown/cmark-aolium.h: -------------------------------------------------------------------------------- 1 | #include "cmark-gfm.h" 2 | #include "cmark-gfm-extension_api.h" 3 | 4 | extern void cmark_release_plugins(); 5 | extern void cmark_gfm_core_extensions_ensure_registered(); 6 | cmark_syntax_extension *table; 7 | cmark_syntax_extension *autolink; 8 | cmark_syntax_extension *strikethrough; 9 | 10 | void init() { 11 | cmark_gfm_core_extensions_ensure_registered(); 12 | table = cmark_find_syntax_extension("table"); 13 | autolink = cmark_find_syntax_extension("autolink"); 14 | strikethrough = cmark_find_syntax_extension("strikethrough"); 15 | } 16 | 17 | void deinit() { 18 | cmark_release_plugins(); 19 | } 20 | 21 | char *markdown_to_html(const char *text, size_t len) { 22 | cmark_parser *parser = cmark_parser_new(0); 23 | cmark_parser_attach_syntax_extension(parser, table); 24 | cmark_parser_attach_syntax_extension(parser, autolink); 25 | cmark_parser_attach_syntax_extension(parser, strikethrough); 26 | 27 | cmark_parser_feed(parser, text, len); 28 | cmark_node *node = cmark_parser_finish(parser); 29 | 30 | char *html = cmark_render_html(node, CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE, cmark_parser_get_syntax_extensions(parser)); 31 | cmark_node_free(node); 32 | cmark_parser_free(parser); 33 | return html; 34 | } 35 | -------------------------------------------------------------------------------- /lib/markdown/cmark-gfm.h: -------------------------------------------------------------------------------- 1 | #ifndef CMARK_GFM_H 2 | #define CMARK_GFM_H 3 | 4 | #include 5 | #include 6 | #include "cmark-gfm_export.h" 7 | #include "cmark-gfm_version.h" 8 | 9 | #ifdef __cplusplus 10 | extern "C" { 11 | #endif 12 | 13 | /** # NAME 14 | * 15 | * **cmark-gfm** - CommonMark parsing, manipulating, and rendering 16 | */ 17 | 18 | /** # DESCRIPTION 19 | * 20 | * ## Simple Interface 21 | */ 22 | 23 | /** Convert 'text' (assumed to be a UTF-8 encoded string with length 24 | * 'len') from CommonMark Markdown to HTML, returning a null-terminated, 25 | * UTF-8-encoded string. It is the caller's responsibility 26 | * to free the returned buffer. 27 | */ 28 | CMARK_GFM_EXPORT 29 | char *cmark_markdown_to_html(const char *text, size_t len, int options); 30 | 31 | /** ## Node Structure 32 | */ 33 | 34 | #define CMARK_NODE_TYPE_PRESENT (0x8000) 35 | #define CMARK_NODE_TYPE_BLOCK (CMARK_NODE_TYPE_PRESENT | 0x0000) 36 | #define CMARK_NODE_TYPE_INLINE (CMARK_NODE_TYPE_PRESENT | 0x4000) 37 | #define CMARK_NODE_TYPE_MASK (0xc000) 38 | #define CMARK_NODE_VALUE_MASK (0x3fff) 39 | 40 | typedef enum { 41 | /* Error status */ 42 | CMARK_NODE_NONE = 0x0000, 43 | 44 | /* Block */ 45 | CMARK_NODE_DOCUMENT = CMARK_NODE_TYPE_BLOCK | 0x0001, 46 | CMARK_NODE_BLOCK_QUOTE = CMARK_NODE_TYPE_BLOCK | 0x0002, 47 | CMARK_NODE_LIST = CMARK_NODE_TYPE_BLOCK | 0x0003, 48 | CMARK_NODE_ITEM = CMARK_NODE_TYPE_BLOCK | 0x0004, 49 | CMARK_NODE_CODE_BLOCK = CMARK_NODE_TYPE_BLOCK | 0x0005, 50 | CMARK_NODE_HTML_BLOCK = CMARK_NODE_TYPE_BLOCK | 0x0006, 51 | CMARK_NODE_CUSTOM_BLOCK = CMARK_NODE_TYPE_BLOCK | 0x0007, 52 | CMARK_NODE_PARAGRAPH = CMARK_NODE_TYPE_BLOCK | 0x0008, 53 | CMARK_NODE_HEADING = CMARK_NODE_TYPE_BLOCK | 0x0009, 54 | CMARK_NODE_THEMATIC_BREAK = CMARK_NODE_TYPE_BLOCK | 0x000a, 55 | CMARK_NODE_FOOTNOTE_DEFINITION = CMARK_NODE_TYPE_BLOCK | 0x000b, 56 | 57 | /* Inline */ 58 | CMARK_NODE_TEXT = CMARK_NODE_TYPE_INLINE | 0x0001, 59 | CMARK_NODE_SOFTBREAK = CMARK_NODE_TYPE_INLINE | 0x0002, 60 | CMARK_NODE_LINEBREAK = CMARK_NODE_TYPE_INLINE | 0x0003, 61 | CMARK_NODE_CODE = CMARK_NODE_TYPE_INLINE | 0x0004, 62 | CMARK_NODE_HTML_INLINE = CMARK_NODE_TYPE_INLINE | 0x0005, 63 | CMARK_NODE_CUSTOM_INLINE = CMARK_NODE_TYPE_INLINE | 0x0006, 64 | CMARK_NODE_EMPH = CMARK_NODE_TYPE_INLINE | 0x0007, 65 | CMARK_NODE_STRONG = CMARK_NODE_TYPE_INLINE | 0x0008, 66 | CMARK_NODE_LINK = CMARK_NODE_TYPE_INLINE | 0x0009, 67 | CMARK_NODE_IMAGE = CMARK_NODE_TYPE_INLINE | 0x000a, 68 | CMARK_NODE_FOOTNOTE_REFERENCE = CMARK_NODE_TYPE_INLINE | 0x000b, 69 | } cmark_node_type; 70 | 71 | extern cmark_node_type CMARK_NODE_LAST_BLOCK; 72 | extern cmark_node_type CMARK_NODE_LAST_INLINE; 73 | 74 | /* For backwards compatibility: */ 75 | #define CMARK_NODE_HEADER CMARK_NODE_HEADING 76 | #define CMARK_NODE_HRULE CMARK_NODE_THEMATIC_BREAK 77 | #define CMARK_NODE_HTML CMARK_NODE_HTML_BLOCK 78 | #define CMARK_NODE_INLINE_HTML CMARK_NODE_HTML_INLINE 79 | 80 | typedef enum { 81 | CMARK_NO_LIST, 82 | CMARK_BULLET_LIST, 83 | CMARK_ORDERED_LIST 84 | } cmark_list_type; 85 | 86 | typedef enum { 87 | CMARK_NO_DELIM, 88 | CMARK_PERIOD_DELIM, 89 | CMARK_PAREN_DELIM 90 | } cmark_delim_type; 91 | 92 | typedef struct cmark_node cmark_node; 93 | typedef struct cmark_parser cmark_parser; 94 | typedef struct cmark_iter cmark_iter; 95 | typedef struct cmark_syntax_extension cmark_syntax_extension; 96 | 97 | /** 98 | * ## Custom memory allocator support 99 | */ 100 | 101 | /** Defines the memory allocation functions to be used by CMark 102 | * when parsing and allocating a document tree 103 | */ 104 | typedef struct cmark_mem { 105 | void *(*calloc)(size_t, size_t); 106 | void *(*realloc)(void *, size_t); 107 | void (*free)(void *); 108 | } cmark_mem; 109 | 110 | /** The default memory allocator; uses the system's calloc, 111 | * realloc and free. 112 | */ 113 | CMARK_GFM_EXPORT 114 | cmark_mem *cmark_get_default_mem_allocator(void); 115 | 116 | /** An arena allocator; uses system calloc to allocate large 117 | * slabs of memory. Memory in these slabs is not reused at all. 118 | */ 119 | CMARK_GFM_EXPORT 120 | cmark_mem *cmark_get_arena_mem_allocator(void); 121 | 122 | /** Resets the arena allocator, quickly returning all used memory 123 | * to the operating system. 124 | */ 125 | CMARK_GFM_EXPORT 126 | void cmark_arena_reset(void); 127 | 128 | /** Callback for freeing user data with a 'cmark_mem' context. 129 | */ 130 | typedef void (*cmark_free_func) (cmark_mem *mem, void *user_data); 131 | 132 | 133 | /* 134 | * ## Basic data structures 135 | * 136 | * To keep dependencies to the strict minimum, libcmark implements 137 | * its own versions of "classic" data structures. 138 | */ 139 | 140 | /** 141 | * ### Linked list 142 | */ 143 | 144 | /** A generic singly linked list. 145 | */ 146 | typedef struct _cmark_llist 147 | { 148 | struct _cmark_llist *next; 149 | void *data; 150 | } cmark_llist; 151 | 152 | /** Append an element to the linked list, return the possibly modified 153 | * head of the list. 154 | */ 155 | CMARK_GFM_EXPORT 156 | cmark_llist * cmark_llist_append (cmark_mem * mem, 157 | cmark_llist * head, 158 | void * data); 159 | 160 | /** Free the list starting with 'head', calling 'free_func' with the 161 | * data pointer of each of its elements 162 | */ 163 | CMARK_GFM_EXPORT 164 | void cmark_llist_free_full (cmark_mem * mem, 165 | cmark_llist * head, 166 | cmark_free_func free_func); 167 | 168 | /** Free the list starting with 'head' 169 | */ 170 | CMARK_GFM_EXPORT 171 | void cmark_llist_free (cmark_mem * mem, 172 | cmark_llist * head); 173 | 174 | /** 175 | * ## Creating and Destroying Nodes 176 | */ 177 | 178 | /** Creates a new node of type 'type'. Note that the node may have 179 | * other required properties, which it is the caller's responsibility 180 | * to assign. 181 | */ 182 | CMARK_GFM_EXPORT cmark_node *cmark_node_new(cmark_node_type type); 183 | 184 | /** Same as `cmark_node_new`, but explicitly listing the memory 185 | * allocator used to allocate the node. Note: be sure to use the same 186 | * allocator for every node in a tree, or bad things can happen. 187 | */ 188 | CMARK_GFM_EXPORT cmark_node *cmark_node_new_with_mem(cmark_node_type type, 189 | cmark_mem *mem); 190 | 191 | CMARK_GFM_EXPORT cmark_node *cmark_node_new_with_ext(cmark_node_type type, 192 | cmark_syntax_extension *extension); 193 | 194 | CMARK_GFM_EXPORT cmark_node *cmark_node_new_with_mem_and_ext(cmark_node_type type, 195 | cmark_mem *mem, 196 | cmark_syntax_extension *extension); 197 | 198 | /** Frees the memory allocated for a node and any children. 199 | */ 200 | CMARK_GFM_EXPORT void cmark_node_free(cmark_node *node); 201 | 202 | /** 203 | * ## Tree Traversal 204 | */ 205 | 206 | /** Returns the next node in the sequence after 'node', or NULL if 207 | * there is none. 208 | */ 209 | CMARK_GFM_EXPORT cmark_node *cmark_node_next(cmark_node *node); 210 | 211 | /** Returns the previous node in the sequence after 'node', or NULL if 212 | * there is none. 213 | */ 214 | CMARK_GFM_EXPORT cmark_node *cmark_node_previous(cmark_node *node); 215 | 216 | /** Returns the parent of 'node', or NULL if there is none. 217 | */ 218 | CMARK_GFM_EXPORT cmark_node *cmark_node_parent(cmark_node *node); 219 | 220 | /** Returns the first child of 'node', or NULL if 'node' has no children. 221 | */ 222 | CMARK_GFM_EXPORT cmark_node *cmark_node_first_child(cmark_node *node); 223 | 224 | /** Returns the last child of 'node', or NULL if 'node' has no children. 225 | */ 226 | CMARK_GFM_EXPORT cmark_node *cmark_node_last_child(cmark_node *node); 227 | 228 | /** Returns the footnote reference of 'node', or NULL if 'node' doesn't have a 229 | * footnote reference. 230 | */ 231 | CMARK_GFM_EXPORT cmark_node *cmark_node_parent_footnote_def(cmark_node *node); 232 | 233 | /** 234 | * ## Iterator 235 | * 236 | * An iterator will walk through a tree of nodes, starting from a root 237 | * node, returning one node at a time, together with information about 238 | * whether the node is being entered or exited. The iterator will 239 | * first descend to a child node, if there is one. When there is no 240 | * child, the iterator will go to the next sibling. When there is no 241 | * next sibling, the iterator will return to the parent (but with 242 | * a 'cmark_event_type' of `CMARK_EVENT_EXIT`). The iterator will 243 | * return `CMARK_EVENT_DONE` when it reaches the root node again. 244 | * One natural application is an HTML renderer, where an `ENTER` event 245 | * outputs an open tag and an `EXIT` event outputs a close tag. 246 | * An iterator might also be used to transform an AST in some systematic 247 | * way, for example, turning all level-3 headings into regular paragraphs. 248 | * 249 | * void 250 | * usage_example(cmark_node *root) { 251 | * cmark_event_type ev_type; 252 | * cmark_iter *iter = cmark_iter_new(root); 253 | * 254 | * while ((ev_type = cmark_iter_next(iter)) != CMARK_EVENT_DONE) { 255 | * cmark_node *cur = cmark_iter_get_node(iter); 256 | * // Do something with `cur` and `ev_type` 257 | * } 258 | * 259 | * cmark_iter_free(iter); 260 | * } 261 | * 262 | * Iterators will never return `EXIT` events for leaf nodes, which are nodes 263 | * of type: 264 | * 265 | * * CMARK_NODE_HTML_BLOCK 266 | * * CMARK_NODE_THEMATIC_BREAK 267 | * * CMARK_NODE_CODE_BLOCK 268 | * * CMARK_NODE_TEXT 269 | * * CMARK_NODE_SOFTBREAK 270 | * * CMARK_NODE_LINEBREAK 271 | * * CMARK_NODE_CODE 272 | * * CMARK_NODE_HTML_INLINE 273 | * 274 | * Nodes must only be modified after an `EXIT` event, or an `ENTER` event for 275 | * leaf nodes. 276 | */ 277 | 278 | typedef enum { 279 | CMARK_EVENT_NONE, 280 | CMARK_EVENT_DONE, 281 | CMARK_EVENT_ENTER, 282 | CMARK_EVENT_EXIT 283 | } cmark_event_type; 284 | 285 | /** Creates a new iterator starting at 'root'. The current node and event 286 | * type are undefined until 'cmark_iter_next' is called for the first time. 287 | * The memory allocated for the iterator should be released using 288 | * 'cmark_iter_free' when it is no longer needed. 289 | */ 290 | CMARK_GFM_EXPORT 291 | cmark_iter *cmark_iter_new(cmark_node *root); 292 | 293 | /** Frees the memory allocated for an iterator. 294 | */ 295 | CMARK_GFM_EXPORT 296 | void cmark_iter_free(cmark_iter *iter); 297 | 298 | /** Advances to the next node and returns the event type (`CMARK_EVENT_ENTER`, 299 | * `CMARK_EVENT_EXIT` or `CMARK_EVENT_DONE`). 300 | */ 301 | CMARK_GFM_EXPORT 302 | cmark_event_type cmark_iter_next(cmark_iter *iter); 303 | 304 | /** Returns the current node. 305 | */ 306 | CMARK_GFM_EXPORT 307 | cmark_node *cmark_iter_get_node(cmark_iter *iter); 308 | 309 | /** Returns the current event type. 310 | */ 311 | CMARK_GFM_EXPORT 312 | cmark_event_type cmark_iter_get_event_type(cmark_iter *iter); 313 | 314 | /** Returns the root node. 315 | */ 316 | CMARK_GFM_EXPORT 317 | cmark_node *cmark_iter_get_root(cmark_iter *iter); 318 | 319 | /** Resets the iterator so that the current node is 'current' and 320 | * the event type is 'event_type'. The new current node must be a 321 | * descendant of the root node or the root node itself. 322 | */ 323 | CMARK_GFM_EXPORT 324 | void cmark_iter_reset(cmark_iter *iter, cmark_node *current, 325 | cmark_event_type event_type); 326 | 327 | /** 328 | * ## Accessors 329 | */ 330 | 331 | /** Returns the user data of 'node'. 332 | */ 333 | CMARK_GFM_EXPORT void *cmark_node_get_user_data(cmark_node *node); 334 | 335 | /** Sets arbitrary user data for 'node'. Returns 1 on success, 336 | * 0 on failure. 337 | */ 338 | CMARK_GFM_EXPORT int cmark_node_set_user_data(cmark_node *node, void *user_data); 339 | 340 | /** Set free function for user data */ 341 | CMARK_GFM_EXPORT 342 | int cmark_node_set_user_data_free_func(cmark_node *node, 343 | cmark_free_func free_func); 344 | 345 | /** Returns the type of 'node', or `CMARK_NODE_NONE` on error. 346 | */ 347 | CMARK_GFM_EXPORT cmark_node_type cmark_node_get_type(cmark_node *node); 348 | 349 | /** Like 'cmark_node_get_type', but returns a string representation 350 | of the type, or `""`. 351 | */ 352 | CMARK_GFM_EXPORT 353 | const char *cmark_node_get_type_string(cmark_node *node); 354 | 355 | /** Returns the string contents of 'node', or an empty 356 | string if none is set. Returns NULL if called on a 357 | node that does not have string content. 358 | */ 359 | CMARK_GFM_EXPORT const char *cmark_node_get_literal(cmark_node *node); 360 | 361 | /** Sets the string contents of 'node'. Returns 1 on success, 362 | * 0 on failure. 363 | */ 364 | CMARK_GFM_EXPORT int cmark_node_set_literal(cmark_node *node, const char *content); 365 | 366 | /** Returns the heading level of 'node', or 0 if 'node' is not a heading. 367 | */ 368 | CMARK_GFM_EXPORT int cmark_node_get_heading_level(cmark_node *node); 369 | 370 | /* For backwards compatibility */ 371 | #define cmark_node_get_header_level cmark_node_get_heading_level 372 | #define cmark_node_set_header_level cmark_node_set_heading_level 373 | 374 | /** Sets the heading level of 'node', returning 1 on success and 0 on error. 375 | */ 376 | CMARK_GFM_EXPORT int cmark_node_set_heading_level(cmark_node *node, int level); 377 | 378 | /** Returns the list type of 'node', or `CMARK_NO_LIST` if 'node' 379 | * is not a list. 380 | */ 381 | CMARK_GFM_EXPORT cmark_list_type cmark_node_get_list_type(cmark_node *node); 382 | 383 | /** Sets the list type of 'node', returning 1 on success and 0 on error. 384 | */ 385 | CMARK_GFM_EXPORT int cmark_node_set_list_type(cmark_node *node, 386 | cmark_list_type type); 387 | 388 | /** Returns the list delimiter type of 'node', or `CMARK_NO_DELIM` if 'node' 389 | * is not a list. 390 | */ 391 | CMARK_GFM_EXPORT cmark_delim_type cmark_node_get_list_delim(cmark_node *node); 392 | 393 | /** Sets the list delimiter type of 'node', returning 1 on success and 0 394 | * on error. 395 | */ 396 | CMARK_GFM_EXPORT int cmark_node_set_list_delim(cmark_node *node, 397 | cmark_delim_type delim); 398 | 399 | /** Returns starting number of 'node', if it is an ordered list, otherwise 0. 400 | */ 401 | CMARK_GFM_EXPORT int cmark_node_get_list_start(cmark_node *node); 402 | 403 | /** Sets starting number of 'node', if it is an ordered list. Returns 1 404 | * on success, 0 on failure. 405 | */ 406 | CMARK_GFM_EXPORT int cmark_node_set_list_start(cmark_node *node, int start); 407 | 408 | /** Returns 1 if 'node' is a tight list, 0 otherwise. 409 | */ 410 | CMARK_GFM_EXPORT int cmark_node_get_list_tight(cmark_node *node); 411 | 412 | /** Sets the "tightness" of a list. Returns 1 on success, 0 on failure. 413 | */ 414 | CMARK_GFM_EXPORT int cmark_node_set_list_tight(cmark_node *node, int tight); 415 | 416 | /** 417 | * Returns item index of 'node'. This is only used when rendering output 418 | * formats such as commonmark, which need to output the index. It is not 419 | * required for formats such as html or latex. 420 | */ 421 | CMARK_GFM_EXPORT int cmark_node_get_item_index(cmark_node *node); 422 | 423 | /** Sets item index of 'node'. Returns 1 on success, 0 on failure. 424 | */ 425 | CMARK_GFM_EXPORT int cmark_node_set_item_index(cmark_node *node, int idx); 426 | 427 | /** Returns the info string from a fenced code block. 428 | */ 429 | CMARK_GFM_EXPORT const char *cmark_node_get_fence_info(cmark_node *node); 430 | 431 | /** Sets the info string in a fenced code block, returning 1 on 432 | * success and 0 on failure. 433 | */ 434 | CMARK_GFM_EXPORT int cmark_node_set_fence_info(cmark_node *node, const char *info); 435 | 436 | /** Sets code blocks fencing details 437 | */ 438 | CMARK_GFM_EXPORT int cmark_node_set_fenced(cmark_node * node, int fenced, 439 | int length, int offset, char character); 440 | 441 | /** Returns code blocks fencing details 442 | */ 443 | CMARK_GFM_EXPORT int cmark_node_get_fenced(cmark_node *node, int *length, int *offset, char *character); 444 | 445 | /** Returns the URL of a link or image 'node', or an empty string 446 | if no URL is set. Returns NULL if called on a node that is 447 | not a link or image. 448 | */ 449 | CMARK_GFM_EXPORT const char *cmark_node_get_url(cmark_node *node); 450 | 451 | /** Sets the URL of a link or image 'node'. Returns 1 on success, 452 | * 0 on failure. 453 | */ 454 | CMARK_GFM_EXPORT int cmark_node_set_url(cmark_node *node, const char *url); 455 | 456 | /** Returns the title of a link or image 'node', or an empty 457 | string if no title is set. Returns NULL if called on a node 458 | that is not a link or image. 459 | */ 460 | CMARK_GFM_EXPORT const char *cmark_node_get_title(cmark_node *node); 461 | 462 | /** Sets the title of a link or image 'node'. Returns 1 on success, 463 | * 0 on failure. 464 | */ 465 | CMARK_GFM_EXPORT int cmark_node_set_title(cmark_node *node, const char *title); 466 | 467 | /** Returns the literal "on enter" text for a custom 'node', or 468 | an empty string if no on_enter is set. Returns NULL if called 469 | on a non-custom node. 470 | */ 471 | CMARK_GFM_EXPORT const char *cmark_node_get_on_enter(cmark_node *node); 472 | 473 | /** Sets the literal text to render "on enter" for a custom 'node'. 474 | Any children of the node will be rendered after this text. 475 | Returns 1 on success 0 on failure. 476 | */ 477 | CMARK_GFM_EXPORT int cmark_node_set_on_enter(cmark_node *node, 478 | const char *on_enter); 479 | 480 | /** Returns the literal "on exit" text for a custom 'node', or 481 | an empty string if no on_exit is set. Returns NULL if 482 | called on a non-custom node. 483 | */ 484 | CMARK_GFM_EXPORT const char *cmark_node_get_on_exit(cmark_node *node); 485 | 486 | /** Sets the literal text to render "on exit" for a custom 'node'. 487 | Any children of the node will be rendered before this text. 488 | Returns 1 on success 0 on failure. 489 | */ 490 | CMARK_GFM_EXPORT int cmark_node_set_on_exit(cmark_node *node, const char *on_exit); 491 | 492 | /** Returns the line on which 'node' begins. 493 | */ 494 | CMARK_GFM_EXPORT int cmark_node_get_start_line(cmark_node *node); 495 | 496 | /** Returns the column at which 'node' begins. 497 | */ 498 | CMARK_GFM_EXPORT int cmark_node_get_start_column(cmark_node *node); 499 | 500 | /** Returns the line on which 'node' ends. 501 | */ 502 | CMARK_GFM_EXPORT int cmark_node_get_end_line(cmark_node *node); 503 | 504 | /** Returns the column at which 'node' ends. 505 | */ 506 | CMARK_GFM_EXPORT int cmark_node_get_end_column(cmark_node *node); 507 | 508 | /** 509 | * ## Tree Manipulation 510 | */ 511 | 512 | /** Unlinks a 'node', removing it from the tree, but not freeing its 513 | * memory. (Use 'cmark_node_free' for that.) 514 | */ 515 | CMARK_GFM_EXPORT void cmark_node_unlink(cmark_node *node); 516 | 517 | /** Inserts 'sibling' before 'node'. Returns 1 on success, 0 on failure. 518 | */ 519 | CMARK_GFM_EXPORT int cmark_node_insert_before(cmark_node *node, 520 | cmark_node *sibling); 521 | 522 | /** Inserts 'sibling' after 'node'. Returns 1 on success, 0 on failure. 523 | */ 524 | CMARK_GFM_EXPORT int cmark_node_insert_after(cmark_node *node, cmark_node *sibling); 525 | 526 | /** Replaces 'oldnode' with 'newnode' and unlinks 'oldnode' (but does 527 | * not free its memory). 528 | * Returns 1 on success, 0 on failure. 529 | */ 530 | CMARK_GFM_EXPORT int cmark_node_replace(cmark_node *oldnode, cmark_node *newnode); 531 | 532 | /** Adds 'child' to the beginning of the children of 'node'. 533 | * Returns 1 on success, 0 on failure. 534 | */ 535 | CMARK_GFM_EXPORT int cmark_node_prepend_child(cmark_node *node, cmark_node *child); 536 | 537 | /** Adds 'child' to the end of the children of 'node'. 538 | * Returns 1 on success, 0 on failure. 539 | */ 540 | CMARK_GFM_EXPORT int cmark_node_append_child(cmark_node *node, cmark_node *child); 541 | 542 | /** Consolidates adjacent text nodes. 543 | */ 544 | CMARK_GFM_EXPORT void cmark_consolidate_text_nodes(cmark_node *root); 545 | 546 | /** Ensures a node and all its children own their own chunk memory. 547 | */ 548 | CMARK_GFM_EXPORT void cmark_node_own(cmark_node *root); 549 | 550 | /** 551 | * ## Parsing 552 | * 553 | * Simple interface: 554 | * 555 | * cmark_node *document = cmark_parse_document("Hello *world*", 13, 556 | * CMARK_OPT_DEFAULT); 557 | * 558 | * Streaming interface: 559 | * 560 | * cmark_parser *parser = cmark_parser_new(CMARK_OPT_DEFAULT); 561 | * FILE *fp = fopen("myfile.md", "rb"); 562 | * while ((bytes = fread(buffer, 1, sizeof(buffer), fp)) > 0) { 563 | * cmark_parser_feed(parser, buffer, bytes); 564 | * if (bytes < sizeof(buffer)) { 565 | * break; 566 | * } 567 | * } 568 | * document = cmark_parser_finish(parser); 569 | * cmark_parser_free(parser); 570 | */ 571 | 572 | /** Creates a new parser object. 573 | */ 574 | CMARK_GFM_EXPORT 575 | cmark_parser *cmark_parser_new(int options); 576 | 577 | /** Creates a new parser object with the given memory allocator 578 | */ 579 | CMARK_GFM_EXPORT 580 | cmark_parser *cmark_parser_new_with_mem(int options, cmark_mem *mem); 581 | 582 | /** Frees memory allocated for a parser object. 583 | */ 584 | CMARK_GFM_EXPORT 585 | void cmark_parser_free(cmark_parser *parser); 586 | 587 | /** Feeds a string of length 'len' to 'parser'. 588 | */ 589 | CMARK_GFM_EXPORT 590 | void cmark_parser_feed(cmark_parser *parser, const char *buffer, size_t len); 591 | 592 | /** Finish parsing and return a pointer to a tree of nodes. 593 | */ 594 | CMARK_GFM_EXPORT 595 | cmark_node *cmark_parser_finish(cmark_parser *parser); 596 | 597 | /** Parse a CommonMark document in 'buffer' of length 'len'. 598 | * Returns a pointer to a tree of nodes. The memory allocated for 599 | * the node tree should be released using 'cmark_node_free' 600 | * when it is no longer needed. 601 | */ 602 | CMARK_GFM_EXPORT 603 | cmark_node *cmark_parse_document(const char *buffer, size_t len, int options); 604 | 605 | /** Parse a CommonMark document in file 'f', returning a pointer to 606 | * a tree of nodes. The memory allocated for the node tree should be 607 | * released using 'cmark_node_free' when it is no longer needed. 608 | */ 609 | CMARK_GFM_EXPORT 610 | cmark_node *cmark_parse_file(FILE *f, int options); 611 | 612 | /** 613 | * ## Rendering 614 | */ 615 | 616 | /** Render a 'node' tree as XML. It is the caller's responsibility 617 | * to free the returned buffer. 618 | */ 619 | CMARK_GFM_EXPORT 620 | char *cmark_render_xml(cmark_node *root, int options); 621 | 622 | /** As for 'cmark_render_xml', but specifying the allocator to use for 623 | * the resulting string. 624 | */ 625 | CMARK_GFM_EXPORT 626 | char *cmark_render_xml_with_mem(cmark_node *root, int options, cmark_mem *mem); 627 | 628 | /** Render a 'node' tree as an HTML fragment. It is up to the user 629 | * to add an appropriate header and footer. It is the caller's 630 | * responsibility to free the returned buffer. 631 | */ 632 | CMARK_GFM_EXPORT 633 | char *cmark_render_html(cmark_node *root, int options, cmark_llist *extensions); 634 | 635 | /** As for 'cmark_render_html', but specifying the allocator to use for 636 | * the resulting string. 637 | */ 638 | CMARK_GFM_EXPORT 639 | char *cmark_render_html_with_mem(cmark_node *root, int options, cmark_llist *extensions, cmark_mem *mem); 640 | 641 | /** Render a 'node' tree as a groff man page, without the header. 642 | * It is the caller's responsibility to free the returned buffer. 643 | */ 644 | CMARK_GFM_EXPORT 645 | char *cmark_render_man(cmark_node *root, int options, int width); 646 | 647 | /** As for 'cmark_render_man', but specifying the allocator to use for 648 | * the resulting string. 649 | */ 650 | CMARK_GFM_EXPORT 651 | char *cmark_render_man_with_mem(cmark_node *root, int options, int width, cmark_mem *mem); 652 | 653 | /** Render a 'node' tree as a commonmark document. 654 | * It is the caller's responsibility to free the returned buffer. 655 | */ 656 | CMARK_GFM_EXPORT 657 | char *cmark_render_commonmark(cmark_node *root, int options, int width); 658 | 659 | /** As for 'cmark_render_commonmark', but specifying the allocator to use for 660 | * the resulting string. 661 | */ 662 | CMARK_GFM_EXPORT 663 | char *cmark_render_commonmark_with_mem(cmark_node *root, int options, int width, cmark_mem *mem); 664 | 665 | /** Render a 'node' tree as a plain text document. 666 | * It is the caller's responsibility to free the returned buffer. 667 | */ 668 | CMARK_GFM_EXPORT 669 | char *cmark_render_plaintext(cmark_node *root, int options, int width); 670 | 671 | /** As for 'cmark_render_plaintext', but specifying the allocator to use for 672 | * the resulting string. 673 | */ 674 | CMARK_GFM_EXPORT 675 | char *cmark_render_plaintext_with_mem(cmark_node *root, int options, int width, cmark_mem *mem); 676 | 677 | /** Render a 'node' tree as a LaTeX document. 678 | * It is the caller's responsibility to free the returned buffer. 679 | */ 680 | CMARK_GFM_EXPORT 681 | char *cmark_render_latex(cmark_node *root, int options, int width); 682 | 683 | /** As for 'cmark_render_latex', but specifying the allocator to use for 684 | * the resulting string. 685 | */ 686 | CMARK_GFM_EXPORT 687 | char *cmark_render_latex_with_mem(cmark_node *root, int options, int width, cmark_mem *mem); 688 | 689 | /** 690 | * ## Options 691 | */ 692 | 693 | /** Default options. 694 | */ 695 | #define CMARK_OPT_DEFAULT 0 696 | 697 | /** 698 | * ### Options affecting rendering 699 | */ 700 | 701 | /** Include a `data-sourcepos` attribute on all block elements. 702 | */ 703 | #define CMARK_OPT_SOURCEPOS (1 << 1) 704 | 705 | /** Render `softbreak` elements as hard line breaks. 706 | */ 707 | #define CMARK_OPT_HARDBREAKS (1 << 2) 708 | 709 | /** `CMARK_OPT_SAFE` is defined here for API compatibility, 710 | but it no longer has any effect. "Safe" mode is now the default: 711 | set `CMARK_OPT_UNSAFE` to disable it. 712 | */ 713 | #define CMARK_OPT_SAFE (1 << 3) 714 | 715 | /** Render raw HTML and unsafe links (`javascript:`, `vbscript:`, 716 | * `file:`, and `data:`, except for `image/png`, `image/gif`, 717 | * `image/jpeg`, or `image/webp` mime types). By default, 718 | * raw HTML is replaced by a placeholder HTML comment. Unsafe 719 | * links are replaced by empty strings. 720 | */ 721 | #define CMARK_OPT_UNSAFE (1 << 17) 722 | 723 | /** Render `softbreak` elements as spaces. 724 | */ 725 | #define CMARK_OPT_NOBREAKS (1 << 4) 726 | 727 | /** 728 | * ### Options affecting parsing 729 | */ 730 | 731 | /** Legacy option (no effect). 732 | */ 733 | #define CMARK_OPT_NORMALIZE (1 << 8) 734 | 735 | /** Validate UTF-8 in the input before parsing, replacing illegal 736 | * sequences with the replacement character U+FFFD. 737 | */ 738 | #define CMARK_OPT_VALIDATE_UTF8 (1 << 9) 739 | 740 | /** Convert straight quotes to curly, --- to em dashes, -- to en dashes. 741 | */ 742 | #define CMARK_OPT_SMART (1 << 10) 743 | 744 | /** Use GitHub-style
 tags for code blocks instead of 
.
746 |  */
747 | #define CMARK_OPT_GITHUB_PRE_LANG (1 << 11)
748 | 
749 | /** Be liberal in interpreting inline HTML tags.
750 |  */
751 | #define CMARK_OPT_LIBERAL_HTML_TAG (1 << 12)
752 | 
753 | /** Parse footnotes.
754 |  */
755 | #define CMARK_OPT_FOOTNOTES (1 << 13)
756 | 
757 | /** Only parse strikethroughs if surrounded by exactly 2 tildes.
758 |  * Gives some compatibility with redcarpet.
759 |  */
760 | #define CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE (1 << 14)
761 | 
762 | /** Use style attributes to align table cells instead of align attributes.
763 |  */
764 | #define CMARK_OPT_TABLE_PREFER_STYLE_ATTRIBUTES (1 << 15)
765 | 
766 | /** Include the remainder of the info string in code blocks in
767 |  * a separate attribute.
768 |  */
769 | #define CMARK_OPT_FULL_INFO_STRING (1 << 16)
770 | 
771 | /**
772 |  * ## Version information
773 |  */
774 | 
775 | /** The library version as integer for runtime checks. Also available as
776 |  * macro CMARK_VERSION for compile time checks.
777 |  *
778 |  * * Bits 16-23 contain the major version.
779 |  * * Bits 8-15 contain the minor version.
780 |  * * Bits 0-7 contain the patchlevel.
781 |  *
782 |  * In hexadecimal format, the number 0x010203 represents version 1.2.3.
783 |  */
784 | CMARK_GFM_EXPORT
785 | int cmark_version(void);
786 | 
787 | /** The library version string for runtime checks. Also available as
788 |  * macro CMARK_VERSION_STRING for compile time checks.
789 |  */
790 | CMARK_GFM_EXPORT
791 | const char *cmark_version_string(void);
792 | 
793 | /** # AUTHORS
794 |  *
795 |  * John MacFarlane, Vicent Marti,  Kārlis Gaņģis, Nick Wellnhofer.
796 |  */
797 | 
798 | #ifndef CMARK_NO_SHORT_NAMES
799 | #define NODE_DOCUMENT CMARK_NODE_DOCUMENT
800 | #define NODE_BLOCK_QUOTE CMARK_NODE_BLOCK_QUOTE
801 | #define NODE_LIST CMARK_NODE_LIST
802 | #define NODE_ITEM CMARK_NODE_ITEM
803 | #define NODE_CODE_BLOCK CMARK_NODE_CODE_BLOCK
804 | #define NODE_HTML_BLOCK CMARK_NODE_HTML_BLOCK
805 | #define NODE_CUSTOM_BLOCK CMARK_NODE_CUSTOM_BLOCK
806 | #define NODE_PARAGRAPH CMARK_NODE_PARAGRAPH
807 | #define NODE_HEADING CMARK_NODE_HEADING
808 | #define NODE_HEADER CMARK_NODE_HEADER
809 | #define NODE_THEMATIC_BREAK CMARK_NODE_THEMATIC_BREAK
810 | #define NODE_HRULE CMARK_NODE_HRULE
811 | #define NODE_TEXT CMARK_NODE_TEXT
812 | #define NODE_SOFTBREAK CMARK_NODE_SOFTBREAK
813 | #define NODE_LINEBREAK CMARK_NODE_LINEBREAK
814 | #define NODE_CODE CMARK_NODE_CODE
815 | #define NODE_HTML_INLINE CMARK_NODE_HTML_INLINE
816 | #define NODE_CUSTOM_INLINE CMARK_NODE_CUSTOM_INLINE
817 | #define NODE_EMPH CMARK_NODE_EMPH
818 | #define NODE_STRONG CMARK_NODE_STRONG
819 | #define NODE_LINK CMARK_NODE_LINK
820 | #define NODE_IMAGE CMARK_NODE_IMAGE
821 | #define BULLET_LIST CMARK_BULLET_LIST
822 | #define ORDERED_LIST CMARK_ORDERED_LIST
823 | #define PERIOD_DELIM CMARK_PERIOD_DELIM
824 | #define PAREN_DELIM CMARK_PAREN_DELIM
825 | #endif
826 | 
827 | typedef int32_t bufsize_t;
828 | 
829 | #ifdef __cplusplus
830 | }
831 | #endif
832 | 
833 | #endif
834 | 


--------------------------------------------------------------------------------
/lib/markdown/cmark-gfm_export.h:
--------------------------------------------------------------------------------
 1 | 
 2 | #ifndef CMARK_GFM_EXPORT_H
 3 | #define CMARK_GFM_EXPORT_H
 4 | 
 5 | #ifdef CMARK_GFM_STATIC_DEFINE
 6 | #  define CMARK_GFM_EXPORT
 7 | #  define CMARK_GFM_NO_EXPORT
 8 | #else
 9 | #  ifndef CMARK_GFM_EXPORT
10 | #    ifdef libcmark_gfm_EXPORTS
11 |         /* We are building this library */
12 | #      define CMARK_GFM_EXPORT __attribute__((visibility("default")))
13 | #    else
14 |         /* We are using this library */
15 | #      define CMARK_GFM_EXPORT __attribute__((visibility("default")))
16 | #    endif
17 | #  endif
18 | 
19 | #  ifndef CMARK_GFM_NO_EXPORT
20 | #    define CMARK_GFM_NO_EXPORT __attribute__((visibility("hidden")))
21 | #  endif
22 | #endif
23 | 
24 | #ifndef CMARK_GFM_DEPRECATED
25 | #  define CMARK_GFM_DEPRECATED __attribute__ ((__deprecated__))
26 | #endif
27 | 
28 | #ifndef CMARK_GFM_DEPRECATED_EXPORT
29 | #  define CMARK_GFM_DEPRECATED_EXPORT CMARK_GFM_EXPORT CMARK_GFM_DEPRECATED
30 | #endif
31 | 
32 | #ifndef CMARK_GFM_DEPRECATED_NO_EXPORT
33 | #  define CMARK_GFM_DEPRECATED_NO_EXPORT CMARK_GFM_NO_EXPORT CMARK_GFM_DEPRECATED
34 | #endif
35 | 
36 | #if 0 /* DEFINE_NO_DEPRECATED */
37 | #  ifndef CMARK_GFM_NO_DEPRECATED
38 | #    define CMARK_GFM_NO_DEPRECATED
39 | #  endif
40 | #endif
41 | 
42 | #endif /* CMARK_GFM_EXPORT_H */
43 | 


--------------------------------------------------------------------------------
/lib/markdown/cmark-gfm_version.h:
--------------------------------------------------------------------------------
1 | #ifndef CMARK_GFM_VERSION_H
2 | #define CMARK_GFM_VERSION_H
3 | 
4 | #define CMARK_GFM_VERSION ((0 << 24) | (29 << 16) | (0 << 8) | 12)
5 | #define CMARK_GFM_VERSION_STRING "0.29.0.gfm.12"
6 | 
7 | #endif
8 | 


--------------------------------------------------------------------------------
/lib/raw_json/raw_json.zig:
--------------------------------------------------------------------------------
 1 | pub const Raw = struct {
 2 | 	value: ?[]const u8,
 3 | 
 4 | 	pub fn init(value: ?[]const u8) Raw {
 5 | 		return .{.value = value};
 6 | 	}
 7 | 
 8 | 	pub fn jsonStringify(self: Raw, out: anytype) !void {
 9 | 		const json = if (self.value) |value| value else "null";
10 | 		return out.print("{s}", .{json});
11 | 	}
12 | };
13 | 
14 | pub fn init(value: ?[]const u8) Raw {
15 | 	return Raw.init(value);
16 | }
17 | 


--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Aolium API Server
2 | This is the source code for the API server of .
3 | 


--------------------------------------------------------------------------------
/src/aolium.zig:
--------------------------------------------------------------------------------
 1 | pub const App = @import("app.zig").App;
 2 | pub const Env = @import("env.zig").Env;
 3 | pub const User = @import("user.zig").User;
 4 | pub const Config = @import("config.zig").Config;
 5 | 
 6 | pub const is_test = @import("builtin").is_test;
 7 | pub var version: []const u8 = @embedFile("version.txt");
 8 | pub const MAX_USERNAME_LEN = 20;
 9 | 
10 | // +37 = /UUID
11 | pub const MAX_WEB_POST_URL = "https://www.aolium.com/".len + MAX_USERNAME_LEN + 37;
12 | 
13 | pub const codes = struct {
14 | 	pub const INTERNAL_SERVER_ERROR_UNCAUGHT = 0;
15 | 	pub const INTERNAL_SERVER_ERROR_CAUGHT = 1;
16 | 	pub const ROUTER_NOT_FOUND = 2;
17 | 	pub const NOT_FOUND = 3;
18 | 	pub const INVALID_JSON = 4;
19 | 	pub const VALIDATION_ERROR = 5;
20 | 	pub const INVALID_AUTHORIZATION = 6;
21 | 	pub const EXPIRED_SESSION_ID = 7;
22 | 	pub const ACCESS_DENIED = 8;
23 | 	pub const CONNECTION_RESET = 9;
24 | };
25 | 
26 | pub const val = struct {
27 | 	pub const USERNAME_IN_USE = 100;
28 | 	pub const EMPTY_POST = 101;
29 | 	pub const INVALID_EMAIL = 102;
30 | 	pub const INVALID_USERNAME = 103;
31 | 	pub const RESERVED_USERNAME = 104;
32 | 	pub const UNKNOWN_USERNAME = 105;
33 | 	pub const LINK_IN_COMMENT = 106;
34 | };
35 | 
36 | pub const testing = @import("t.zig");
37 | 
38 | const logz = @import("logz");
39 | pub fn sqliteErr(ctx: []const u8, err: anyerror, conn: anytype, logger: logz.Logger) error{SqliteError} {
40 | 	logger.level(.Error).
41 | 		ctx(ctx).
42 | 		err(err).
43 | 		boolean("sqlite", true).
44 | 		stringZ("desc", conn.lastError()).
45 | 		log();
46 | 
47 | 	return error.SqliteError;
48 | }
49 | 


--------------------------------------------------------------------------------
/src/app.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const logz = @import("logz");
  4 | const cache = @import("cache");
  5 | const zqlite = @import("zqlite");
  6 | const web = @import("web/web.zig");
  7 | const aolium = @import("aolium.zig");
  8 | const migrations = @import("migrations/migrations.zig");
  9 | 
 10 | const User = aolium.User;
 11 | const Config = aolium.Config;
 12 | 
 13 | const Allocator = std.mem.Allocator;
 14 | const ValidatorPool = @import("validate").Pool;
 15 | const BufferPool = @import("zul").StringBuilder.Pool;
 16 | 
 17 | const DATA_POOL_COUNT = if (aolium.is_test) 1 else 64;
 18 | const DATA_POOL_MASK = DATA_POOL_COUNT - 1;
 19 | 
 20 | pub const App = struct {
 21 | 	config: Config,
 22 | 	allocator: Allocator,
 23 | 
 24 | 	// pool of sqlite connections to the auto database
 25 | 	auth_pool: zqlite.Pool,
 26 | 
 27 | 	// shard of pools
 28 | 	data_pools: [DATA_POOL_COUNT]zqlite.Pool,
 29 | 
 30 | 	// a pool of string builders
 31 | 	buffers: *BufferPool,
 32 | 
 33 | 	// pool of validator, accessed through the env
 34 | 	validators: ValidatorPool(void),
 35 | 
 36 | 	// An HTTP cache
 37 | 	http_cache: cache.Cache(web.CachedResponse),
 38 | 
 39 | 	// lower(username) => User
 40 | 	user_cache: cache.Cache(User),
 41 | 
 42 | 	// session_id => User
 43 | 	session_cache: cache.Cache(User),
 44 | 
 45 | 	pub fn init(allocator: Allocator, config: Config) !App{
 46 | 		// we need to generate some temporary stuff while setting up
 47 | 		var arena = std.heap.ArenaAllocator.init(allocator);
 48 | 		defer arena.deinit();
 49 | 		const aa = arena.allocator();
 50 | 
 51 | 		var http_cache = try cache.Cache(web.CachedResponse).init(allocator, .{
 52 | 			.max_size = 524_288_000, //500mb
 53 | 			.gets_per_promote = 50,
 54 | 		});
 55 | 		errdefer http_cache.deinit();
 56 | 
 57 | 		var user_cache = try cache.Cache(User).init(allocator, .{
 58 | 			.max_size = 1000,
 59 | 			.gets_per_promote = 10,
 60 | 		});
 61 | 		errdefer user_cache.deinit();
 62 | 
 63 | 		var session_cache = try cache.Cache(User).init(allocator, .{
 64 | 			.max_size = 1000,
 65 | 			.gets_per_promote = 10,
 66 | 		});
 67 | 		errdefer session_cache.deinit();
 68 | 
 69 | 		const auth_db_path = try std.fs.path.joinZ(aa, &[_][]const u8{config.root, "auth.sqlite3"});
 70 | 		var auth_pool = zqlite.Pool.init(allocator, .{
 71 | 			.size = 20,
 72 | 			.path = auth_db_path,
 73 | 			.flags = zqlite.OpenFlags.Create | zqlite.OpenFlags.EXResCode,
 74 | 		}) catch |err| {
 75 | 			logz.fatal().ctx("app.auth_pool").err(err).string("path", auth_db_path).log();
 76 | 			return err;
 77 | 		};
 78 | 		errdefer auth_pool.deinit();
 79 | 
 80 | 		{
 81 | 			const conn = auth_pool.acquire();
 82 | 			defer auth_pool.release(conn);
 83 | 			try migrations.migrateAuth(conn);
 84 | 		}
 85 | 
 86 | 		var started: usize = 0;
 87 | 		var data_pools: [DATA_POOL_COUNT]zqlite.Pool = undefined;
 88 | 		errdefer for (0..started) |i| data_pools[i].deinit();
 89 | 
 90 | 		while (started < DATA_POOL_COUNT) : (started += 1) {
 91 | 			const db_file = try std.fmt.allocPrint(aa, "data_{d:0>2}.sqlite3", .{started});
 92 | 			const data_db_path = try std.fs.path.joinZ(aa, &[_][]const u8{config.root, db_file});
 93 | 			var pool = zqlite.Pool.init(allocator, .{
 94 | 				.size = 20,
 95 | 				.path = data_db_path,
 96 | 				.flags = zqlite.OpenFlags.Create | zqlite.OpenFlags.EXResCode,
 97 | 			}) catch |err| {
 98 | 				logz.fatal().ctx("app.data_pool").err(err).string("path", data_db_path).log();
 99 | 				return err;
100 | 			};
101 | 			errdefer pool.deinit();
102 | 
103 | 			{
104 | 				const conn = pool.acquire();
105 | 				defer pool.release(conn);
106 | 				try migrations.migrateData(conn, started);
107 | 			}
108 | 			data_pools[started] = pool;
109 | 		}
110 | 
111 | 		return .{
112 | 			.config = config,
113 | 			.allocator = allocator,
114 | 			.auth_pool = auth_pool,
115 | 			.data_pools = data_pools,
116 | 			.http_cache = http_cache,
117 | 			.user_cache = user_cache,
118 | 			.session_cache = session_cache,
119 | 			.buffers = try BufferPool.init(allocator, 100, 500_000),
120 | 			.validators = try ValidatorPool(void).init(allocator, config.validator),
121 | 		};
122 | 	}
123 | 
124 | 	pub fn deinit(self: *App) void {
125 | 		self.validators.deinit();
126 | 		self.auth_pool.deinit();
127 | 		for (&self.data_pools) |*dp| {
128 | 			dp.deinit();
129 | 		}
130 | 		self.buffers.deinit();
131 | 		self.http_cache.deinit();
132 | 		self.user_cache.deinit();
133 | 		self.session_cache.deinit();
134 | 	}
135 | 
136 | 	pub fn getAuthConn(self: *App) zqlite.Conn {
137 | 		return self.auth_pool.acquire();
138 | 	}
139 | 
140 | 	pub fn releaseAuthConn(self: *App, conn: zqlite.Conn) void {
141 | 		return self.auth_pool.release(conn);
142 | 	}
143 | 
144 | 	pub fn getDataConn(self: *App, shard_id: usize) zqlite.Conn {
145 | 		return self.data_pools[shard_id].acquire();
146 | 	}
147 | 
148 | 	pub fn releaseDataConn(self: *App, conn: zqlite.Conn, shard_id: usize) void {
149 | 		return self.data_pools[shard_id].release(conn);
150 | 	}
151 | 
152 | 	pub fn getUserFromUsername(self: *App, username: []const u8) !?User {
153 | 		var buf: [aolium.MAX_USERNAME_LEN]u8 = undefined;
154 | 		const lower = std.ascii.lowerString(&buf, username);
155 | 
156 | 		const entry = (try self.user_cache.fetch(*App, lower, loadUserFromUsername, self, .{.ttl = 1800})) orelse {
157 | 			return null;
158 | 		};
159 | 
160 | 		// entry "owns" the user, but user can safely be copied (it's just a couple ints)
161 | 		const user = entry.value;
162 | 		entry.release();
163 | 		return user;
164 | 	}
165 | 
166 | 	// todo: either look this up or make it a consistent hash
167 | 	pub fn getShardId(username: []const u8) usize {
168 | 		var buf: [aolium.MAX_USERNAME_LEN]u8 = undefined;
169 | 		const lower = std.ascii.lowerString(&buf, username);
170 | 		const hash_code = std.hash.Wyhash.hash(0, lower);
171 | 		return hash_code & DATA_POOL_MASK;
172 | 	}
173 | 
174 | 	pub fn clearUserCache(self: *App, user_id: i64) void {
175 | 		// TODO: delPrefix tries to minize write locks, but it's still an O(N) on the
176 | 		// cache, this has to switch to be switched to layered cache at some point.
177 | 		_ = self.http_cache.delPrefix(std.mem.asBytes(&user_id)) catch |err| {
178 | 			logz.err().ctx("app.clearUserCache").err(err).int("user_id", user_id).log();
179 | 		};
180 | 	}
181 | 
182 | 	pub fn clearPostCache(self: *App, post_id: zul.UUID) void {
183 | 		// TODO: delPrefix tries to minize write locks, but it's still an O(N) on the
184 | 		// cache, this has to switch to be switched to layered cache at some point.
185 | 		_ = self.http_cache.delPrefix(&post_id.bin) catch |err| {
186 | 			logz.err().ctx("app.clearPostCache").err(err).binary("post_id", &post_id.bin).log();
187 | 		};
188 | 	}
189 | 
190 | 	// called on a cache miss from getUserFromUsername
191 | 	fn loadUserFromUsername(self: *App, username: []const u8) !?User {
192 | 		const sql = "select id, username from users where lower(username) = ?1 and active";
193 | 		const args = .{username};
194 | 
195 | 		const conn = self.getAuthConn();
196 | 		defer self.releaseAuthConn(conn);
197 | 
198 | 		const row = conn.row(sql, args) catch |err| {
199 | 			return aolium.sqliteErr("App.loadUserFromUsername", err, conn, logz.logger());
200 | 		} orelse return null;
201 | 
202 | 		defer row.deinit();
203 | 
204 | 		return try User.init(self.user_cache.allocator, row.int(0), row.text(1));
205 | 	}
206 | };
207 | 
208 | const t = aolium.testing;
209 | test "app: getUserFromUsername" {
210 | 	var tc = t.context(.{});
211 | 	defer tc.deinit();
212 | 
213 | 	const uid1 = tc.insert.user(.{.username = "Leto"});
214 | 	const uid2 = tc.insert.user(.{.username = "duncan"});
215 | 	_ = tc.insert.user(.{.username = "Piter", .active = false});
216 | 
217 | 	try t.expectEqual(null, try tc.app.getUserFromUsername("Hello"));
218 | 	try t.expectEqual(null, try tc.app.getUserFromUsername("Piter"));
219 | 	try t.expectEqual(null, try tc.app.getUserFromUsername("piter"));
220 | 
221 | 	{
222 | 		const user = (try tc.app.getUserFromUsername("leto")).?;
223 | 		try t.expectEqual(uid1, user.id);
224 | 		try t.expectEqual(0, user.shard_id);
225 | 	}
226 | 
227 | 	{
228 | 		// ensure we get this from the cache
229 | 		const conn = tc.app.getAuthConn();
230 | 		defer tc.app.releaseAuthConn(conn);
231 | 		try conn.exec("delete from users where id = ?1", .{uid1});
232 | 
233 | 		const user = (try tc.app.getUserFromUsername("LETO")).?;
234 | 		try t.expectEqual(uid1, user.id);
235 | 		try t.expectEqual(0, user.shard_id);
236 | 	}
237 | 
238 | 	{
239 | 		const user = (try tc.app.getUserFromUsername("Duncan")).?;
240 | 		try t.expectEqual(uid2, user.id);
241 | 		try t.expectEqual(0, user.shard_id);
242 | 	}
243 | }
244 | 


--------------------------------------------------------------------------------
/src/config.zig:
--------------------------------------------------------------------------------
 1 | const logz = @import("logz");
 2 | const httpz = @import("httpz");
 3 | const aolium = @import("aolium.zig");
 4 | const validate = @import("validate");
 5 | 
 6 | pub const Config = struct {
 7 | 	// The absolute root DB path
 8 | 	root: [:0]const u8,
 9 | 
10 | 	// For improving the uniqueness of request_id in a multi-server setup
11 | 	// The instance_id is part of the request_id, thus N instances will generate
12 | 	// distinct request_ids from each other
13 | 	instance_id: u8,
14 | 
15 | 	// http port to listen on
16 | 	port: u16,
17 | 
18 | 	// address to bind to
19 | 	address: []const u8,
20 | 
21 | 	// https://github.com/ziglang/zig/issues/15091
22 | 	log_http: bool,
23 | 
24 | 	cors: ?httpz.Config.CORS = null,
25 | 	logger: logz.Config = .{},
26 | 	validator: validate.Config = .{},
27 | };
28 | 


--------------------------------------------------------------------------------
/src/env.zig:
--------------------------------------------------------------------------------
 1 | const logz = @import("logz");
 2 | const cache = @import("cache");
 3 | const aolium = @import("aolium.zig");
 4 | const validate = @import("validate");
 5 | 
 6 | const App = aolium.App;
 7 | const User = aolium.User;
 8 | 
 9 | pub const Env = struct {
10 | 	// If a user is loaded, it comes from the cache, which uses reference counting
11 | 	// to release entries in a thread-safe way. The env is the owner of the cache
12 | 	// entry for the user (if we have a user).s
13 | 	_cached_user_entry: ?*cache.Entry(User) = null,
14 | 
15 | 	app: *App,
16 | 
17 | 	user: ?User = null,
18 | 
19 | 	// should be loaded via the env.validator() function
20 | 	_validator: ?*validate.Context(void) = null,
21 | 
22 | 	// This logger has the "$rid=REQUEST_ID" attributes (and maybe more) automatically
23 | 	// added to any generated log. Managed by the dispatcher.
24 | 	logger: logz.Logger,
25 | 
26 | 	pub fn deinit(self: Env) void {
27 | 		self.logger.release();
28 | 
29 | 		if (self._cached_user_entry) |ue| {
30 | 			ue.release();
31 | 		}
32 | 
33 | 		if (self._validator) |val| {
34 | 			self.app.validators.release(val);
35 | 		}
36 | 	}
37 | 
38 | 	pub fn validator(self: *Env) !*validate.Context(void) {
39 | 		if (self._validator) |val| {
40 | 			return val;
41 | 		}
42 | 
43 | 		const val = try self.app.validators.acquire({});
44 | 		self._validator = val;
45 | 		return val;
46 | 	}
47 | };
48 | 


--------------------------------------------------------------------------------
/src/init.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const validate = @import("validate");
 3 | const markdown = @import("markdown.zig");
 4 | 
 5 | // There's no facility to do initialization on startup (like Go's init), so
 6 | // we'll just hard-code this ourselves. The reason we extract this out is
 7 | // largely so that our tests can call this (done when a test context is created)
 8 | pub fn init(aa: std.mem.Allocator) !void {
 9 | 	markdown.init();
10 | 
11 | 	const builder = try aa.create(validate.Builder(void));
12 | 	builder.* = try validate.Builder(void).init(aa);
13 | 	try @import("web/auth/_auth.zig").init(builder);
14 | 	try @import("web/posts/_posts.zig").init(builder);
15 | 	try @import("web/comments/_comments.zig").init(builder);
16 | }
17 | 
18 | pub fn deinit() void {
19 | 	markdown.deinit();
20 | }
21 | 


--------------------------------------------------------------------------------
/src/main.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const logz = @import("logz");
  4 | const httpz = @import("httpz");
  5 | const aolium = @import("aolium.zig");
  6 | 
  7 | const App = aolium.App;
  8 | const Config = aolium.Config;
  9 | const Allocator = std.mem.Allocator;
 10 | 
 11 | pub fn main() !void {
 12 | 	var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 13 | 	const allocator = gpa.allocator();
 14 | 
 15 | 	// Some data exists for the entire lifetime of the project. We could just
 16 | 	// use the gpa allocator, but if we don't properly clean it up, it'll cause
 17 | 	// tests to report leaks.
 18 | 	var arena = std.heap.ArenaAllocator.init(allocator);
 19 | 	defer arena.deinit();
 20 | 	const aa = arena.allocator();
 21 | 
 22 | 	const config = try parseArgs(aa);
 23 | 	try logz.setup(allocator, config.logger);
 24 | 	defer logz.deinit();
 25 | 
 26 | 	try @import("init.zig").init(aa);
 27 | 	defer @import("init.zig").deinit();
 28 | 
 29 | 	logz.info().ctx("init").
 30 | 		string("db_root", config.root).
 31 | 		boolean("log_http", config.log_http).
 32 | 		stringSafe("log_level", @tagName(logz.level())).
 33 | 		log();
 34 | 
 35 | 	var app = try App.init(allocator, config);
 36 | 	defer app.deinit();
 37 | 	try @import("web/web.zig").start(&app);
 38 | }
 39 | 
 40 | fn parseArgs(allocator: Allocator) !Config {
 41 | 	var args = try zul.CommandLineArgs.parse(allocator);
 42 | 	defer args.deinit();
 43 | 
 44 | 	const stdout = std.io.getStdOut().writer();
 45 | 
 46 | 	if (args.contains("version")) {
 47 | 		try std.io.getStdOut().writer().print("{s}", .{aolium.version});
 48 | 		std.process.exit(0);
 49 | 	}
 50 | 
 51 | 	var port: u16 = 8517;
 52 | 	var address: []const u8 = "127.0.0.1";
 53 | 	var instance_id: u8 = 0;
 54 | 	var log_level = logz.Level.Info;
 55 | 	var cors: ?httpz.Config.CORS = null;
 56 | 
 57 | 	const log_http = args.contains("log_http");
 58 | 
 59 | 	if (args.get("port")) |value| {
 60 | 		port = std.fmt.parseInt(u16, value, 10) catch {
 61 | 			try stdout.print("port must be a positive integer\n", .{});
 62 | 			std.process.exit(2);
 63 | 		};
 64 | 	}
 65 | 
 66 | 	if (args.get("address")) |value| {
 67 | 		address = try allocator.dupe(u8, value);
 68 | 	}
 69 | 
 70 | 	if (args.get("log_level")) |value| {
 71 | 		log_level = logz.Level.parse(value) orelse {
 72 | 			try stdout.print("invalid log_level value\n", .{});
 73 | 			std.process.exit(2);
 74 | 		};
 75 | 	}
 76 | 
 77 | 	if (args.get("instance_id")) |value| {
 78 | 		instance_id = std.fmt.parseInt(u8, value, 10) catch {
 79 | 			try stdout.print("instance_id must be an integer between 0 and 255r\n", .{});
 80 | 			std.process.exit(2);
 81 | 		};
 82 | 	}
 83 | 
 84 | 	if (args.get("cors")) |value| {
 85 | 		cors = httpz.Config.CORS{
 86 | 			.origin = try allocator.dupe(u8, value),
 87 | 			.max_age = "7200",
 88 | 			.headers = "content-type,authorization",
 89 | 			.methods = "GET,POST,PUT,DELETE",
 90 | 		};
 91 | 	}
 92 | 
 93 | 	var root: [:0]u8 = undefined;
 94 | 	const path = args.get("root") orelse "db/";
 95 | 	if (std.fs.path.isAbsolute(path)) {
 96 | 		root = try allocator.dupeZ(u8, path);
 97 | 	} else {
 98 | 		var buffer: [std.fs.MAX_PATH_BYTES]u8 = undefined;
 99 | 		const cwd = try std.posix.getcwd(&buffer);
100 | 		root = try std.fs.path.joinZ(allocator, &[_][]const u8{cwd, path});
101 | 	}
102 | 
103 | 	try std.fs.cwd().makePath(root);
104 | 
105 | 	return .{
106 | 		.root = root,
107 | 		.port = port,
108 | 		.cors = cors,
109 | 		.address = address,
110 | 		.log_http = log_http,
111 | 		.instance_id = instance_id,
112 | 		.logger = .{.level = log_level},
113 | 
114 | 	};
115 | }
116 | 
117 | const t = aolium.testing;
118 | test {
119 | 	t.setup();
120 | 	std.testing.refAllDecls(@This());
121 | }
122 | 


--------------------------------------------------------------------------------
/src/markdown.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const c = @cImport(@cInclude("cmark-aolium.h"));
 3 | 
 4 | pub fn init() void {
 5 | 	c.init();
 6 | }
 7 | 
 8 | pub fn deinit() void {
 9 | 	c.deinit();
10 | }
11 | 
12 | pub fn toHTML(input: [*:0]const u8, len: usize) Result {
13 | 	return .{
14 | 		.value = c.markdown_to_html(input, len),
15 | 	};
16 | }
17 | 
18 | pub const Result = struct {
19 | 	value: [*:0]u8,
20 | 
21 | 	pub fn deinit(self: Result) void {
22 | 		std.c.free(@ptrCast(self.value));
23 | 	}
24 | };
25 | 


--------------------------------------------------------------------------------
/src/migrations/auth/migrate_1.zig:
--------------------------------------------------------------------------------
 1 | const migrations = @import("migrations.zig");
 2 | 
 3 | const Conn = migrations.Conn;
 4 | 
 5 | pub fn run(conn: Conn) !void {
 6 | 	try conn.execNoArgs("migration.create.users",
 7 | 		\\ create table users (
 8 | 		\\  id integer primary key,
 9 | 		\\  username text not null,
10 | 		\\  email text null,
11 | 		\\  password text not null,
12 | 		\\  active bool not null,
13 | 		\\  spam_js text null,
14 | 		\\  spam_load int null,
15 | 		\\  spam_drink text null,
16 | 		\\  spam_hidden text null,
17 | 		\\  reset_password bool not null,
18 | 		\\  created int not null default(unixepoch()),
19 | 		\\  last_login timestamptz null
20 | 		\\ )
21 | 	);
22 | 
23 | 	try conn.execNoArgs("migration.create.users_index",
24 | 		\\ create unique index users_username on users (lower(username))
25 | 	);
26 | 
27 | 	try conn.execNoArgs("migration.create.sessions",
28 | 		\\ create table sessions (
29 | 		\\  id text not null primary key,
30 | 		\\  user_id integer not null,
31 | 		\\  expires timestamptz not null,
32 | 		\\  created int not null default(unixepoch())
33 | 		\\ )
34 | 	);
35 | }
36 | 


--------------------------------------------------------------------------------
/src/migrations/auth/migrations.zig:
--------------------------------------------------------------------------------
1 | const m = @import("../migrations.zig");
2 | 
3 | pub const Conn = m.Conn;
4 | 
5 | pub const migrations = [_]m.Migration{
6 | 	.{.id = 1, .run = &(@import("migrate_1.zig").run)},
7 | };
8 | 


--------------------------------------------------------------------------------
/src/migrations/conn.zig:
--------------------------------------------------------------------------------
 1 | const logz = @import("logz");
 2 | const zqlite = @import("zqlite");
 3 | 
 4 | // Wraps a zqlite.Conn to provide some helper functions (mostly just consistent
 5 | // logging in case of error)
 6 | pub const Conn = struct {
 7 | 	conn: zqlite.Conn,
 8 | 	logger: logz.Logger,
 9 | 	migration_id: u32,
10 | 
11 | 	pub fn begin(self: Conn) !void {
12 | 		self.logger.level(.Info).ctx("Migrate.run").int("id", self.migration_id).log();
13 | 		return self.execNoArgs("begin", "begin exclusive");
14 | 	}
15 | 
16 | 	pub fn rollback(self: Conn) void {
17 | 		self.execNoArgs("rollback", "rollback") catch {};
18 | 	}
19 | 
20 | 	pub fn commit(self: Conn) !void {
21 | 		return self.execNoArgs("commit", "commit");
22 | 	}
23 | 
24 | 	pub fn execNoArgs(self: Conn, ctx: []const u8, sql: [:0]const u8) !void {
25 | 		self.conn.execNoArgs(sql) catch |err| {
26 | 			self.log(ctx, err, sql);
27 | 			return err;
28 | 		};
29 | 	}
30 | 
31 | 	pub fn exec(self: Conn, ctx: []const u8, sql: [:0]const u8, args: anytype) !void {
32 | 		self.conn.exec(sql, args) catch |err| {
33 | 			self.log(ctx, err, sql);
34 | 			return err;
35 | 		};
36 | 	}
37 | 
38 | 	pub fn row(self: Conn, ctx: []const u8, sql: [:0]const u8, args: anytype) !?zqlite.Row {
39 | 		return self.conn.row(sql, args) catch |err| {
40 | 			self.log(ctx, err, sql);
41 | 			return err;
42 | 		};
43 | 	}
44 | 
45 | 	fn log(self: Conn, ctx: []const u8, err: anyerror, sql: []const u8) void {
46 | 		self.logger.level(.Error).
47 | 			ctx(ctx).
48 | 			err(err).
49 | 			stringZ("desc", self.conn.lastError()).
50 | 			string("sql", sql).
51 | 			int("id", self.migration_id).
52 | 			log();
53 | 	}
54 | };
55 | 


--------------------------------------------------------------------------------
/src/migrations/data/migrate_1.zig:
--------------------------------------------------------------------------------
 1 | const migrations = @import("migrations.zig");
 2 | 
 3 | const Conn = migrations.Conn;
 4 | 
 5 | pub fn run(conn: Conn) !void {
 6 | 	try conn.execNoArgs("migration.create.posts",
 7 | 		\\ create table posts (
 8 | 		\\  id blob primary key,
 9 | 		\\  user_id integer not null,
10 | 		\\  title text null,
11 | 		\\  text text not null,
12 | 		\\  tags text null,
13 | 		\\  type text not null,
14 | 		\\  created int not null default(unixepoch()),
15 | 		\\  updated int not null default(unixepoch())
16 | 		\\ )
17 | 	);
18 | }
19 | 


--------------------------------------------------------------------------------
/src/migrations/data/migrate_2.zig:
--------------------------------------------------------------------------------
 1 | const migrations = @import("migrations.zig");
 2 | 
 3 | const Conn = migrations.Conn;
 4 | 
 5 | pub fn run(conn: Conn) !void {
 6 | 	try conn.execNoArgs("migration.create.comments",
 7 | 		\\ create table comments (
 8 | 		\\  id blob primary key,
 9 | 		\\  post_id blog not null,
10 | 		\\  user_id integer null,
11 | 		\\  name text null,
12 | 		\\  comment text not null,
13 | 		\\  created int not null default(unixepoch()),
14 | 		\\  approved int null
15 | 		\\ )
16 | 	);
17 | 
18 | 	try conn.execNoArgs("migration.create.comments_post_id_index",
19 | 		\\ create index comments_post_id on comments(post_id)
20 | 	);
21 | 
22 | 	try conn.execNoArgs("migration.comment_count.posts",
23 | 		\\ alter table posts add column comments int not null default(0)
24 | 	);
25 | 
26 | 	try conn.execNoArgs("migration.create.posts_user_id_index",
27 | 		\\ create index posts_user_id on posts(user_id, created desc)
28 | 	);
29 | }
30 | 


--------------------------------------------------------------------------------
/src/migrations/data/migrations.zig:
--------------------------------------------------------------------------------
1 | const m = @import("../migrations.zig");
2 | 
3 | pub const Conn = m.Conn;
4 | 
5 | pub const migrations = [_]m.Migration{
6 | 	.{.id = 1, .run = &(@import("migrate_1.zig").run)},
7 | 	.{.id = 2, .run = &(@import("migrate_2.zig").run)},
8 | };
9 | 


--------------------------------------------------------------------------------
/src/migrations/migrations.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const logz = @import("logz");
 3 | const zqlite = @import("zqlite");
 4 | 
 5 | // A wrapper around zqlite.Conn with helpers and migration-specific error logging
 6 | pub const Conn = @import("conn.zig").Conn;
 7 | 
 8 | pub const Migration = struct {
 9 | 	id: u32,
10 | 	run: *const fn(conn: Conn) anyerror!void,
11 | };
12 | 
13 | const auth_migrations = @import("auth/migrations.zig").migrations;
14 | const data_migrations = @import("data/migrations.zig").migrations;
15 | 
16 | // A micro-optimizations. Storing our migrations in an array means that
17 | // we don't have to scan the array to find any missing migrations. All pending
18 | // migrations are at: migrations[current_migration_id+1..];
19 | // We still give migrations an id for the sake of explicitness, but the id
20 | // is a 1-based offset of the migration in the array.
21 | 
22 | comptime {
23 | 	// make sure our migrations are in the right order
24 | 	for (auth_migrations, 0..) |m, i| {
25 | 		if (m.id != i + 1) @compileError("invalid auth migration order");
26 | 	}
27 | 
28 | 	for (data_migrations, 0..) |m, i| {
29 | 		if (m.id != i + 1) @compileError("invalid data migration order");
30 | 	}
31 | }
32 | 
33 | pub fn migrateAuth(conn: zqlite.Conn) !void {
34 | 	var logger = logz.logger().stringSafe("type", "auth").multiuse();
35 | 	defer logger.release();
36 | 	return migrate(&auth_migrations, conn, logger);
37 | }
38 | 
39 | pub fn migrateData(conn: zqlite.Conn, shard: usize) !void {
40 | 	var logger = logz.logger().stringSafe("type", "data").int("shard", shard).multiuse();
41 | 	defer logger.release();
42 | 	return migrate(&data_migrations, conn, logger);
43 | }
44 | 
45 | // runs any pending migration against the connection
46 | fn migrate(migrations: []const Migration, conn: zqlite.Conn, logger: logz.Logger) !void {
47 | 	var mconn = Conn{
48 | 		.conn = conn,
49 | 		.logger = logger,
50 | 		.migration_id = 0,
51 | 	};
52 | 
53 | 	try mconn.execNoArgs("Migration.migrations",
54 | 		\\ create table if not exists migrations (
55 | 		\\   id uinteger not null primary key,
56 | 		\\   created int not null default(unixepoch())
57 | 		\\ )
58 | 	);
59 | 
60 | 	var installed_id: usize = 0;
61 | 	const get_installed_sql = "select id from migrations order by id desc limit 1";
62 | 	if (try mconn.row("Migration.get_intalled", get_installed_sql, .{})) |row| {
63 | 		installed_id = @intCast(row.int(0));
64 | 		row.deinit();
65 | 	}
66 | 
67 | 	if (installed_id >= migrations.len) {
68 | 		// why > ? I don't know
69 | 		return;
70 | 	}
71 | 
72 | 	for (migrations[installed_id..]) |migration| {
73 | 		const id = migration.id;
74 | 		mconn.migration_id = id;
75 | 
76 | 		try mconn.begin();
77 | 		errdefer mconn.rollback();
78 | 
79 | 		try migration.run(mconn);
80 | 		const update_migration_sql = "insert into migrations (id) values (?1)";
81 | 		try mconn.exec("migration.insert.migrations", update_migration_sql, .{id});
82 | 
83 | 		try mconn.commit();
84 | 	}
85 | }
86 | 


--------------------------------------------------------------------------------
/src/t.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const logz = @import("logz");
  4 | const typed = @import("typed");
  5 | const validate = @import("validate");
  6 | const aolium = @import("aolium.zig");
  7 | pub const web = @import("httpz").testing;
  8 | 
  9 | const App = aolium.App;
 10 | const Env = aolium.Env;
 11 | const User = aolium.User;
 12 | const Allocator = std.mem.Allocator;
 13 | pub const allocator = std.testing.allocator;
 14 | 
 15 | // std.testing.expectEqual won't coerce expected to actual, which is a problem
 16 | // when expected is frequently a comptime.
 17 | // https://github.com/ziglang/zig/issues/4437
 18 | pub fn expectEqual(expected: anytype, actual: anytype) !void {
 19 | 	try std.testing.expectEqual(@as(@TypeOf(actual), expected), actual);
 20 | }
 21 | pub fn expectContains(expected: []const u8, actual :[]const u8) !void {
 22 | 	if (std.mem.indexOf(u8, actual, expected) == null) {
 23 | 		std.debug.print("\nExpected string to contain '{s}'\n  Actual: {s}\n", .{expected, actual});
 24 | 		return error.StringContain;
 25 | 	}
 26 | }
 27 | pub fn expectDelta(expected: anytype, actual: @TypeOf(expected), delta: @TypeOf(expected)) !void {
 28 | 	try expectEqual(true, expected - delta <= actual);
 29 | 	try expectEqual(true, expected + delta >= actual);
 30 | }
 31 | pub const expectError = std.testing.expectError;
 32 | pub const expectSlice = std.testing.expectEqualSlices;
 33 | pub const expectString = std.testing.expectEqualStrings;
 34 | 
 35 | // We will _very_ rarely use this. Zig test doesn't have test lifecycle hooks. We
 36 | // can setup globals on startup, but we can't clean this up properly. If we use
 37 | // std.testing.allocator for these, it'll report a leak. So, we create a gpa
 38 | // without any leak reporting, and use that for the few globals that we have.
 39 | var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 40 | const leaking_allocator = gpa.allocator();
 41 | 
 42 | pub fn noLogs() void {
 43 | 	// don't use testing.allocator here because it _will_ leak, but we don't
 44 | 	// care, we just need this to be available.
 45 | 	logz.setup(leaking_allocator, .{.pool_size = 1, .level = .None, .output = .stderr}) catch unreachable;
 46 | }
 47 | 
 48 | pub fn restoreLogs() void {
 49 | 	// don't use testing.allocator here because it _will_ leak, but we don't
 50 | 	// care, we just need this to be available.
 51 | 	logz.setup(leaking_allocator, .{.pool_size = 2, .level = .Error, .output = .stderr}) catch unreachable;
 52 | }
 53 | pub fn setup() void {
 54 | 	restoreLogs();
 55 | 	@import("init.zig").init(leaking_allocator) catch unreachable;
 56 | 
 57 | 	// remove any test db
 58 | 	std.fs.cwd().deleteTree("tests/db") catch unreachable;
 59 | 	std.fs.cwd().makePath("tests/db") catch unreachable;
 60 | 
 61 | 	var tc = context(.{});
 62 | 	defer tc.deinit();
 63 | }
 64 | 
 65 | // Our Test.Context exists to help us write tests. It does this by:
 66 | // - Exposing the httpz.testing helpers
 67 | // - Giving us an arena for any ad-hoc allocation we need
 68 | // - Having a working *App
 69 | // - Exposing a database factory
 70 | // - Creating envs and users as needed
 71 | pub fn context(_: Context.Config) *Context {
 72 | 	var arena = allocator.create(std.heap.ArenaAllocator) catch unreachable;
 73 | 	arena.* = std.heap.ArenaAllocator.init(allocator);
 74 | 
 75 | 	const aa = arena.allocator();
 76 | 	const app = aa.create(App) catch unreachable;
 77 | 	app.* = App.init(allocator, .{
 78 | 		.port = 8517,
 79 | 		.address = "localhost",
 80 | 		.root = "tests/db",
 81 | 		.log_http = false,
 82 | 		.instance_id = 0,
 83 | 	}) catch unreachable;
 84 | 
 85 | 	const ctx = allocator.create(Context) catch unreachable;
 86 | 	ctx.* = .{
 87 | 		._env = null,
 88 | 		._arena = arena,
 89 | 		.arena = aa,
 90 | 		.app = app,
 91 | 		.web = web.init(.{}),
 92 | 		.insert = Inserter.init(ctx),
 93 | 	};
 94 | 	return ctx;
 95 | }
 96 | 
 97 | pub const Context = struct {
 98 | 	_arena: *std.heap.ArenaAllocator,
 99 | 	_env: ?*Env,
100 | 	_user: ?User = null,
101 | 	app: *App,
102 | 	web: web.Testing,
103 | 	insert: Inserter,
104 | 	arena: std.mem.Allocator,
105 | 
106 | 	const Config = struct {
107 | 	};
108 | 
109 | 	pub fn deinit(self: *Context) void {
110 | 		self.web.deinit();
111 | 		if (self._env) |e| {
112 | 			e.deinit();
113 | 			allocator.destroy(e);
114 | 		}
115 | 		self.app.deinit();
116 | 
117 | 		self._arena.deinit();
118 | 		allocator.destroy(self._arena);
119 | 		allocator.destroy(self);
120 | 	}
121 | 
122 | 	pub fn user(self: *Context, config: anytype) void {
123 | 		const T = @TypeOf(config);
124 | 		self._user = User{
125 | 			.id = config.id,
126 | 			.shard_id = 0,
127 | 			.username = if (@hasField(T, "username")) config.username else "",
128 | 		};
129 | 	}
130 | 
131 | 	pub fn env(self: *Context) *Env {
132 | 		if (self._env) |e| {
133 | 			return e;
134 | 		}
135 | 
136 | 		const e = allocator.create(Env) catch unreachable;
137 | 		e.* = Env{
138 | 			.app = self.app,
139 | 			.user = self._user,
140 | 			.logger = logz.logger().multiuse(),
141 | 		};
142 | 		self._env = e;
143 | 		return e;
144 | 	}
145 | 
146 | 	pub fn expectInvalid(self: Context, expectation: anytype) !void {
147 | 		return validate.testing.expectInvalid(expectation, self._env.?._validator.?);
148 | 	}
149 | 
150 | 	pub fn reset(self: *Context) void {
151 | 		if (self._env) |e| {
152 | 			e.deinit();
153 | 			allocator.destroy(e);
154 | 			self._env = null;
155 | 		}
156 | 
157 | 		self._user = null;
158 | 		self.web.deinit();
159 | 		self.web = web.init(.{});
160 | 	}
161 | 
162 | 	pub fn getAuthRow(self: Context, sql: []const u8, args: anytype) ?typed.Map {
163 | 		const conn = self.app.getAuthConn();
164 | 		defer self.app.releaseAuthConn(conn);
165 | 		return self.getRow(sql, args, conn);
166 | 	}
167 | 
168 | 	pub fn getDataRow(self: Context, sql: []const u8, args: anytype) ?typed.Map {
169 | 		const conn = self.app.getDataConn(0);
170 | 		defer self.app.releaseDataConn(conn, 0);
171 | 		return self.getRow(sql, args, conn);
172 | 	}
173 | 
174 | 	pub fn getRow(self: Context, sql: []const u8, args: anytype, conn: anytype) ?typed.Map {
175 | 		const row = conn.row(sql, args) catch unreachable orelse return null;
176 | 		defer row.deinit();
177 | 
178 | 		const stmt = row.stmt;
179 | 		const aa = self.arena;
180 | 		const column_count: usize = @intCast(stmt.columnCount());
181 | 
182 | 		var m = typed.Map.init(aa);
183 | 		for (0..column_count) |i| {
184 | 			const name = aa.dupe(u8, std.mem.span(stmt.columnName(i))) catch unreachable;
185 | 			const value = switch (stmt.columnType(i)) {
186 | 				.int => typed.Value{.i64 = row.int(i)},
187 | 				.float => typed.Value{.f64 = row.float(i)},
188 | 				.text => typed.Value{.string = aa.dupe(u8, row.text(i)) catch unreachable},
189 | 				.blob => typed.Value{.string = aa.dupe(u8, row.blob(i)) catch unreachable},
190 | 				.null => typed.Value{.null = {}},
191 | 				else => unreachable,
192 | 			};
193 | 			m.put(name, value) catch unreachable;
194 | 		}
195 | 
196 | 		return m;
197 | 	}
198 | };
199 | 
200 | // A data factory for inserting data into a tenant's instance
201 | const Inserter = struct {
202 | 	ctx: *Context,
203 | 
204 | 	fn init(ctx: *Context) Inserter {
205 | 		return .{
206 | 			.ctx = ctx,
207 | 		};
208 | 	}
209 | 
210 | 	const UserParams = struct {
211 | 		username: ?[]const u8 = null,
212 | 		password: ?[]const u8 = null,
213 | 		active: bool = true,
214 | 		reset_password: bool = false,
215 | 	};
216 | 
217 | 	pub fn user(self: Inserter, p: UserParams) i64 {
218 | 		const arena = self.ctx.arena;
219 | 		const argon2 =  std.crypto.pwhash.argon2;
220 | 
221 | 		var buf: [300]u8 = undefined;
222 | 		const password = argon2.strHash(p.password orelse "password", .{
223 | 			.allocator = arena,
224 | 			.params = argon2.Params.fromLimits(1, 1024),
225 | 		}, &buf) catch unreachable;
226 | 
227 | 
228 | 		const sql =
229 | 			\\ insert or replace into users (username, password, active, reset_password)
230 | 			\\ values (?1, ?2, ?3, ?4)
231 | 		;
232 | 
233 | 		const args = .{
234 | 			p.username orelse "leto",
235 | 			password,
236 | 			p.active,
237 | 			p.reset_password,
238 | 		};
239 | 
240 | 		var app = self.ctx.app;
241 | 		const conn = app.getAuthConn();
242 | 		defer app.releaseAuthConn(conn);
243 | 
244 | 		conn.exec(sql, args) catch {
245 | 			std.debug.print("inserter.users: {s}", .{conn.lastError()});
246 | 			unreachable;
247 | 		};
248 | 
249 | 		return conn.lastInsertedRowId();
250 | 	}
251 | 
252 | 	const SessionParams = struct {
253 | 		id: ?[]const u8 = null,
254 | 		user_id: ?i64 = null,
255 | 		ttl: i64 = 120,
256 | 	};
257 | 
258 | 	pub fn session(self: Inserter, p: SessionParams) []const u8 {
259 | 		const arena = self.ctx.arena;
260 | 		const id = p.id orelse (zul.UUID.v4().toHexAlloc(arena, .lower) catch unreachable);
261 | 
262 | 		const sql =
263 | 			\\ insert into sessions (id, user_id, expires)
264 | 			\\ values (?1, ?2, unixepoch() + ?3)
265 | 		;
266 | 		const args = .{id, p.user_id orelse 0, p.ttl};
267 | 
268 | 		var app = self.ctx.app;
269 | 		const conn = app.getAuthConn();
270 | 		defer app.releaseAuthConn(conn);
271 | 
272 | 		conn.exec(sql, args) catch {
273 | 			std.debug.print("inserter.sessions: {s}", .{conn.lastError()});
274 | 			unreachable;
275 | 		};
276 | 		return id;
277 | 	}
278 | 
279 | 	const PostParams = struct {
280 | 		user_id: i64 = 0,
281 | 		title: ?[]const u8 = null,
282 | 		text: ?[]const u8 = null,
283 | 		type: ?[]const u8 = null,
284 | 		tags: ?[]const []const u8 = null,
285 | 		comments: i64 = 0,
286 | 		created: i64 = 0,
287 | 		updated: i64 = 0,
288 | 	};
289 | 
290 | 	pub fn post(self: Inserter, p: PostParams) []const u8 {
291 | 		var app = self.ctx.app;
292 | 
293 | 		const user_id = p.user_id;
294 | 
295 | 		const arena = self.ctx.arena;
296 | 		const id = zul.UUID.v4();
297 | 
298 | 		var tags: ?[]const u8 = null;
299 | 		if (p.tags) |tgs| {
300 | 			tags = std.json.stringifyAlloc(arena, tgs, .{}) catch unreachable;
301 | 		}
302 | 
303 | 		const sql =
304 | 			\\ insert into posts (id, user_id, title, text, type, tags, comments, created, updated)
305 | 			\\ values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
306 | 		;
307 | 		const args = .{&id.bin, user_id, p.title, p.text orelse "", p.type orelse "simple", tags, p.comments, p.created, p.updated};
308 | 
309 | 		const conn = app.getDataConn(0);
310 | 		defer app.releaseDataConn(conn, 0);
311 | 
312 | 		conn.exec(sql, args) catch {
313 | 			std.debug.print("inserter.posts: {s}", .{conn.lastError()});
314 | 			unreachable;
315 | 		};
316 | 
317 | 		return id.toHexAlloc(arena, .lower) catch unreachable;
318 | 	}
319 | 
320 | 	const CommentParams = struct {
321 | 		user_id: ?i64 = null,
322 | 		post_id: ?[]const u8 = null,
323 | 		comment: ?[]const u8 = null,
324 | 		name: ?[]const u8 = null,
325 | 		created: i64 = 0,
326 | 		approved: ?i64 = 0,
327 | 	};
328 | 
329 | 	pub fn comment(self: Inserter, p: CommentParams) []const u8 {
330 | 		var app = self.ctx.app;
331 | 
332 | 		const user_id = p.user_id;
333 | 
334 | 		const arena = self.ctx.arena;
335 | 		const id = zul.UUID.v4();
336 | 
337 | 		const post_id = if (p.post_id) |pid| zul.UUID.parse(pid) catch unreachable else zul.UUID.v4();
338 | 
339 | 		const sql =
340 | 			\\ insert into comments (id, post_id, user_id, name, comment, created, approved)
341 | 			\\ values (?1, ?2, ?3, ?4, ?5, ?6, ?7)
342 | 		;
343 | 		const args = .{&id.bin, &post_id.bin, user_id, p.name orelse "", p.comment orelse "", p.created, p.approved};
344 | 
345 | 		const conn = app.getDataConn(0);
346 | 		defer app.releaseDataConn(conn, 0);
347 | 
348 | 		conn.exec(sql, args) catch {
349 | 			std.debug.print("inserter.comments: {s}", .{conn.lastError()});
350 | 			unreachable;
351 | 		};
352 | 
353 | 		return id.toHexAlloc(arena, .lower) catch unreachable;
354 | 	}
355 | };
356 | 
357 | fn shardIdForUserId(app: *App, user_id: i64) usize {
358 | 	const conn = app.getAuthConn();
359 | 	defer app.releaseAuthConn(conn);
360 | 	const row = conn.row("select username from users where id = ?1", .{user_id}) catch unreachable orelse return 0;
361 | 	defer row.deinit();
362 | 	return App.getShardId(row.text(0));
363 | }
364 | 


--------------------------------------------------------------------------------
/src/user.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const aolium = @import("aolium.zig");
 3 | 
 4 | const App = aolium.App;
 5 | const Allocator = std.mem.Allocator;
 6 | 
 7 | pub const User = struct {
 8 | 	id: i64,
 9 | 	shard_id: usize,
10 | 	username: []const u8,
11 | 
12 | 	pub fn init(allocator: Allocator, id: i64, username: []const u8) !User {
13 | 		return .{
14 | 			.id = id,
15 | 			.shard_id = App.getShardId(username),
16 | 			.username = try allocator.dupe(u8, username),
17 | 		};
18 | 	}
19 | 
20 | 	pub fn removedFromCache(self: User, allocator: Allocator) void {
21 | 		allocator.free(self.username);
22 | 	}
23 | };
24 | 


--------------------------------------------------------------------------------
/src/version.txt:
--------------------------------------------------------------------------------
1 | local-dev
2 | 


--------------------------------------------------------------------------------
/src/web/auth/_auth.zig:
--------------------------------------------------------------------------------
 1 | const validate = @import("validate");
 2 | pub const web = @import("../web.zig");
 3 | 
 4 | // expose nested routes
 5 | pub const _check = @import("check.zig");
 6 | pub const _login = @import("login.zig");
 7 | pub const _logout = @import("logout.zig");
 8 | pub const _register = @import("register.zig");
 9 | 
10 | pub const check = _check.handler;
11 | pub const login = _login.handler;
12 | pub const logout = _logout.handler;
13 | pub const register = _register.handler;
14 | 
15 | pub fn init(builder: *validate.Builder(void)) !void {
16 | 	_login.init(builder);
17 | 	_register.init(builder);
18 | }
19 | 


--------------------------------------------------------------------------------
/src/web/auth/check.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const httpz = @import("httpz");
 3 | const auth = @import("_auth.zig");
 4 | 
 5 | const web = auth.web;
 6 | const aolium = web.aolium;
 7 | 
 8 | pub fn handler(_: *aolium.Env, _: *httpz.Request, res: *httpz.Response) !void {
 9 | 	// Can only get here if we got past the dispatcher
10 | 	res.status = 204;
11 | }
12 | 


--------------------------------------------------------------------------------
/src/web/auth/login.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const httpz = @import("httpz");
  3 | const typed = @import("typed");
  4 | const zqlite = @import("zqlite");
  5 | const validate = @import("validate");
  6 | const auth = @import("_auth.zig");
  7 | 
  8 | const web = auth.web;
  9 | const aolium = web.aolium;
 10 | const argon2 = std.crypto.pwhash.argon2;
 11 | 
 12 | const User = aolium.User;
 13 | 
 14 | var login_validator: *validate.Object(void) = undefined;
 15 | 
 16 | pub fn init(builder: *validate.Builder(void)) void {
 17 | 	login_validator = builder.object(&.{
 18 | 		builder.field("username", builder.string(.{.required = true, .trim = true, .min = 1})),
 19 | 		builder.field("password", builder.string(.{.required = true, .trim = true, .min = 1})),
 20 | 	}, .{});
 21 | }
 22 | 
 23 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 24 | 	const input = try web.validateJson(req, login_validator, env);
 25 | 	const username = input.get("username").?.string;
 26 | 
 27 | 	// load the user row
 28 | 	const sql =
 29 | 		\\ select id, password, reset_password
 30 | 		\\ from users
 31 | 		\\ where lower(username) = lower(?1) and active
 32 | 	;
 33 | 	const args = .{username};
 34 | 
 35 | 	const app = env.app;
 36 | 	const conn = app.getAuthConn();
 37 | 	defer app.releaseAuthConn(conn);
 38 | 
 39 | 	const row = conn.row(sql, args) catch |err| {
 40 | 		return aolium.sqliteErr("login.select", err, conn, env.logger);
 41 | 	} orelse {
 42 | 		// timing attack, username enumeration it's security theater here.
 43 | 		return web.notFound(res, "username or password are invalid");
 44 | 	};
 45 | 	defer row.deinit();
 46 | 
 47 | 	{
 48 | 		// verify the password
 49 | 		const hashed = row.text(1);
 50 | 		argon2.strVerify(hashed, input.get("password").?.string, .{.allocator = req.arena}) catch {
 51 | 			return web.notFound(res, "username or password are invalid");
 52 | 		};
 53 | 	}
 54 | 
 55 | 	return createSession(env, conn, .{
 56 | 		.id = row.int(0),
 57 | 		.username = username,
 58 | 		.reset_password = row.int(2) == 1,
 59 | 	}, res);
 60 | }
 61 | 
 62 | // used by register.zig
 63 | pub fn createSession(env: *aolium.Env, conn: zqlite.Conn, user_data: anytype, res: *httpz.Response) !void {
 64 | 	const user_id = user_data.id;
 65 | 
 66 | 	var session_id_buf: [20]u8 = undefined;
 67 | 	std.crypto.random.bytes(&session_id_buf);
 68 | 	const session_id = std.fmt.bytesToHex(session_id_buf, .lower);
 69 | 
 70 | 	{
 71 | 		// create the session
 72 | 		const session_sql = "insert into sessions (id, user_id, expires) values (?1, ?2, unixepoch() + 2592000)";
 73 | 		conn.exec(session_sql,.{&session_id, user_id}) catch |err| {
 74 | 			return aolium.sqliteErr("sessions.insert", err, conn, env.logger);
 75 | 		};
 76 | 	}
 77 | 
 78 | 	var session_cache = env.app.session_cache;
 79 | 	const user = try User.init(session_cache.allocator, user_id, user_data.username);
 80 | 	try session_cache.put(&session_id, user, .{.ttl = 1800});
 81 | 
 82 | 	return res.json(.{
 83 | 		.user = .{
 84 | 			.id = user.id,
 85 | 			.username = user_data.username,
 86 | 		},
 87 | 		.session_id = session_id,
 88 | 		.reset_password = user_data.reset_password,
 89 | 	}, .{});
 90 | }
 91 | 
 92 | const t = aolium.testing;
 93 | test "auth.login: empty body" {
 94 | 	var tc = t.context(.{});
 95 | 	defer tc.deinit();
 96 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
 97 | }
 98 | 
 99 | test "auth.login: invalid json body" {
100 | 	var tc = t.context(.{});
101 | 	defer tc.deinit();
102 | 
103 | 	tc.web.body("{hi");
104 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
105 | }
106 | 
107 | test "auth.login: invalid input" {
108 | 	{
109 | 		var tc = t.context(.{});
110 | 		defer tc.deinit();
111 | 
112 | 		tc.web.json(.{.hack = true});
113 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
114 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "username"});
115 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "password"});
116 | 	}
117 | 
118 | 	{
119 | 		var tc = t.context(.{});
120 | 		defer tc.deinit();
121 | 
122 | 		tc.web.json(.{.username = 32, .password = true});
123 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
124 | 		try tc.expectInvalid(.{.code = validate.codes.TYPE_STRING, .field = "username"});
125 | 		try tc.expectInvalid(.{.code = validate.codes.TYPE_STRING, .field = "password"});
126 | 	}
127 | }
128 | 
129 | test "auth.login: username not found" {
130 | 	var tc = t.context(.{});
131 | 	defer tc.deinit();
132 | 
133 | 	tc.web.json(.{.username = "piter", .password = "sapho"});
134 | 	try handler(tc.env(), tc.web.req, tc.web.res);
135 | 	try tc.web.expectStatus(404);
136 | 	try tc.web.expectJson(.{.desc = "username or password are invalid", .code = 3});
137 | }
138 | 
139 | test "auth.login: not active" {
140 | 	var tc = t.context(.{});
141 | 	defer tc.deinit();
142 | 
143 | 	_ = tc.insert.user(.{.active = false, .username = "duncan", .password = "ginaz"});
144 | 	tc.web.json(.{.username = "duncan", .password = "ginaz"});
145 | 	try handler(tc.env(), tc.web.req, tc.web.res);
146 | 	try tc.web.expectStatus(404);
147 | 	try tc.web.expectJson(.{.desc = "username or password are invalid", .code = 3});
148 | }
149 | 
150 | test "auth.login" {
151 | 	var tc = t.context(.{});
152 | 	defer tc.deinit();
153 | 
154 | 	const user_id1 = tc.insert.user(.{.username = "leto", .password = "ghanima"});
155 | 	const user_id2 = tc.insert.user(.{.username = "Paul", .password = "chani", .reset_password = true});
156 | 
157 | 	{
158 | 		// wrong password
159 | 		tc.web.json(.{.username = "leto", .password = "paul"});
160 | 		try handler(tc.env(), tc.web.req, tc.web.res);
161 | 		try tc.web.expectStatus(404);
162 | 		try tc.web.expectJson(.{.desc = "username or password are invalid", .code = 3});
163 | 	}
164 | 
165 | 	{
166 | 		// valid
167 | 		tc.reset();
168 | 		tc.web.json(.{.username = "leto", .password = "ghanima"});
169 | 		try handler(tc.env(), tc.web.req, tc.web.res);
170 | 		try tc.web.expectStatus(200);
171 | 		try tc.web.expectJson(.{.user = .{.id = user_id1, .username = "leto"}, .reset_password = false});
172 | 
173 | 		const body = (try tc.web.getJson()).object;
174 | 		const session_id = body.get("session_id").?.string;
175 | 
176 | 		const row = tc.getAuthRow("select user_id, expires from sessions where id = ?1", .{session_id}).?;
177 | 		try t.expectEqual(user_id1, row.get("user_id").?.i64);
178 | 		try t.expectDelta(std.time.timestamp() + 2_592_000, row.get("expires").?.i64, 5);
179 | 	}
180 | 
181 | 	{
182 | 		// valid, different user, with reset_password
183 | 		tc.reset();
184 | 		tc.web.json(.{.username = "PAUL", .password = "chani"});
185 | 		try handler(tc.env(), tc.web.req, tc.web.res);
186 | 		try tc.web.expectStatus(200);
187 | 		try tc.web.expectJson(.{.user = .{.id = user_id2, .username = "PAUL"}, .reset_password = true});
188 | 
189 | 		const body = (try tc.web.getJson()).object;
190 | 		const session_id = body.get("session_id").?.string;
191 | 
192 | 		const row = tc.getAuthRow("select user_id, expires from sessions where id = ?1", .{session_id}).?;
193 | 		try t.expectEqual(user_id2, row.get("user_id").?.i64);
194 | 		try t.expectDelta(std.time.timestamp() + 2_592_000, row.get("expires").?.i64, 5);
195 | 	}
196 | }
197 | 


--------------------------------------------------------------------------------
/src/web/auth/logout.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const httpz = @import("httpz");
 3 | const auth = @import("_auth.zig");
 4 | 
 5 | const web = auth.web;
 6 | const aolium = web.aolium;
 7 | 
 8 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 9 | 	const sql = "delete from sessions where id = ?1";
10 | 	const args = .{web.getSessionId(req)};
11 | 
12 | 	const app = env.app;
13 | 	const conn = app.getAuthConn();
14 | 	defer app.releaseAuthConn(conn);
15 | 
16 | 	conn.exec(sql, args) catch |err| {
17 | 		return aolium.sqliteErr("login.select", err, conn, env.logger);
18 | 	};
19 | 	res.status = 204;
20 | }
21 | 
22 | const t = aolium.testing;
23 | 
24 | test "auth.logout" {
25 | 	var tc = t.context(.{});
26 | 	defer tc.deinit();
27 | 
28 | 	const sid1 = tc.insert.session(.{});
29 | 	const sid2 = tc.insert.session(.{});
30 | 
31 | 	{
32 | 		// unknown session_id is no-op
33 | 		tc.web.header("authorization", "aolium nope");
34 | 		try handler(tc.env(), tc.web.req, tc.web.res);
35 | 		try tc.web.expectStatus(204);
36 | 	}
37 | 
38 | 	{
39 | 		// valid
40 | 		tc.reset();
41 | 		tc.web.header("authorization", try std.fmt.allocPrint(tc.arena, "aolium {s}", .{sid1}));
42 | 		try handler(tc.env(), tc.web.req, tc.web.res);
43 | 		try tc.web.expectStatus(204);
44 | 
45 | 		{
46 | 			const row = tc.getAuthRow("select * from sessions where id = ?1", .{sid1});
47 | 			try t.expectEqual(null, row);
48 | 		}
49 | 
50 | 		{
51 | 			const row = tc.getAuthRow("select 1 from sessions where id = ?1", .{sid2});
52 | 			try t.expectEqual(true, row != null);
53 | 		}
54 | 	}
55 | }
56 | 


--------------------------------------------------------------------------------
/src/web/auth/register.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const httpz = @import("httpz");
  3 | const typed = @import("typed");
  4 | const zqlite = @import("zqlite");
  5 | const validate = @import("validate");
  6 | const auth = @import("_auth.zig");
  7 | const login = @import("login.zig");
  8 | 
  9 | const web = auth.web;
 10 | const aolium = web.aolium;
 11 | const User = aolium.User;
 12 | 
 13 | const argon2 = std.crypto.pwhash.argon2;
 14 | const ARGON_CONFIG = if (aolium.is_test) argon2.Params.fromLimits(1, 1024) else argon2.Params.interactive_2id;
 15 | 
 16 | var register_validator: *validate.Object(void) = undefined;
 17 | 
 18 | const reserved_usernames = [_][]const u8 {
 19 | 	"about",
 20 | 	"account",
 21 | 	"accounts",
 22 | 	"admin",
 23 | 	"auth",
 24 | 	"blog",
 25 | 	"contact",
 26 | 	"feedback",
 27 | 	"help",
 28 | 	"home",
 29 | 	"info",
 30 | 	"karl", // on no he didn't
 31 | 	"login",
 32 | 	"logout",
 33 | 	"news",
 34 | 	"privacy",
 35 | 	"settings",
 36 | 	"site",
 37 | 	"status",
 38 | 	"support",
 39 | 	"terms",
 40 | 	"user",
 41 | 	"users",
 42 | };
 43 | 
 44 | pub fn init(builder: *validate.Builder(void)) void {
 45 | 	register_validator = builder.object(&.{
 46 | 		builder.field("username", builder.string(.{.required = true, .trim = true, .min = 4, .max = aolium.MAX_USERNAME_LEN, .function = validateUsername})),
 47 | 		builder.field("password", builder.string(.{.required = true, .trim = true, .min = 6, .max = 70})),
 48 | 		builder.field("email", builder.string(.{.trim = true, .function = validateEmail, .max = 100})),
 49 | 
 50 | 		// all spam honeypot fields
 51 | 		builder.field("load", builder.int(u32, .{})),
 52 | 		builder.field("drink", builder.string(.{.required = true, .trim = true, .max = 50})),
 53 | 		builder.field("comment", builder.string(.{.trim = true, .max = 100})),
 54 | 		builder.field("choice", builder.string(.{.trim = true, .max = 50})),
 55 | 	}, .{});
 56 | }
 57 | 
 58 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 59 | 	const input = try web.validateJson(req, register_validator, env);
 60 | 	const username = input.get("username").?.string;
 61 | 
 62 | 	var pw_buf: [128]u8 = undefined;
 63 | 	const hashed_password = try argon2.strHash(input.get("password").?.string, .{
 64 | 		.allocator = req.arena,
 65 | 		.params = ARGON_CONFIG,
 66 | 	}, &pw_buf);
 67 | 
 68 | 	var hashed_email: ?[]const u8 = null;
 69 | 	if (input.get("email")) |email| {
 70 | 		var email_buf: [128]u8 = undefined;
 71 | 		hashed_email = try argon2.strHash(email.string, .{
 72 | 			.allocator = req.arena,
 73 | 			.params = ARGON_CONFIG,
 74 | 		}, &email_buf);
 75 | 	}
 76 | 
 77 | 	// load the user row
 78 | 	const sql =
 79 | 		\\ insert into users (
 80 | 		\\   username, password, email, active, reset_password,
 81 | 		\\   spam_js, spam_load, spam_drink, spam_hidden
 82 | 		\\ )
 83 | 		\\ values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
 84 | 	;
 85 | 	const args = .{
 86 | 		username,
 87 | 		hashed_password,
 88 | 		hashed_email,
 89 | 		true,
 90 | 		false,
 91 | 		if (input.get("comment")) |c| c.string else null,
 92 | 		if (input.get("load")) |l| l.u32 else null,
 93 | 		if (input.get("drink")) |d| d.string else null,
 94 | 		if (input.get("choice")) |c| c.string else null,
 95 | 	};
 96 | 
 97 | 	const app = env.app;
 98 | 	const conn = app.getAuthConn();
 99 | 	defer app.releaseAuthConn(conn);
100 | 
101 | 	conn.exec(sql, args) catch |err| {
102 | 		if (!zqlite.isUnique(err)) {
103 | 			return aolium.sqliteErr("register.insert", err, conn, env.logger);
104 | 		}
105 | 		env._validator.?.addInvalidField(.{
106 | 			.field = "username",
107 | 			.err = "is already taken",
108 | 			.code = aolium.val.USERNAME_IN_USE,
109 | 		});
110 | 		return error.Validation;
111 | 	};
112 | 
113 | 	return login.createSession(env, conn, .{
114 | 		.id = conn.lastInsertedRowId(),
115 | 		.username = username,
116 | 		.reset_password = false,
117 | 	}, res);
118 | }
119 | 
120 | fn validateEmail(value: ?[]const u8, context: *validate.Context(void)) !?[]const u8 {
121 | 	// we're ok with a null email, because we're cool
122 | 	const email = value orelse return null;
123 | 
124 | 	// IMO, \S+@\S+\.\S+ is the best email regex, but I don't want to pull
125 | 	// in a regex library just for this, and we can more or less do something similar.
126 | 
127 | 	var valid = true;
128 | 	var at: ?usize = null;
129 | 	var dot: ?usize = null;
130 | 	for (email, 0..) |c, i| {
131 | 		if (c == '.') {
132 | 			dot = i;
133 | 		} else if (c == '@') {
134 | 			if (at != null) {
135 | 				valid = false;
136 | 				break;
137 | 			}
138 | 			at = i;
139 | 		} else if (c == ' ' or c == '\t' or c == '\n' or c == '\r') {
140 | 			valid = false;
141 | 			break;
142 | 		}
143 | 	}
144 | 
145 | 	const at_index = at orelse blk: {
146 | 		valid = false;
147 | 		break :blk 0;
148 | 	};
149 | 
150 | 	const dot_index = dot orelse blk: {
151 | 		valid = false;
152 | 		break :blk 0;
153 | 	};
154 | 
155 | 	if (!valid or at_index == 0 or dot_index == email.len - 1) {
156 | 		try context.add(validate.Invalid{
157 | 			.code = aolium.val.INVALID_EMAIL,
158 | 			.err = "is not valid",
159 | 		});
160 | 	}
161 | 
162 | 	return email;
163 | }
164 | 
165 | fn validateUsername(value: ?[]const u8, context: *validate.Context(void)) !?[]const u8 {
166 | 	const username = value.?;
167 | 	var valid = std.ascii.isAlphabetic(username[0]);
168 | 
169 | 	if (valid) {
170 | 		for (username[1..]) |c| {
171 | 			if (std.ascii.isAlphanumeric(c)) {
172 | 				continue;
173 | 			}
174 | 			if (c == '_' or c == '.' or c == '-') {
175 | 				continue;
176 | 			}
177 | 			valid = false;
178 | 			break;
179 | 		}
180 | 	}
181 | 
182 | 	if (valid == false) {
183 | 		try context.add(validate.Invalid{
184 | 			.code = aolium.val.INVALID_USERNAME,
185 | 			.err = "must begin with a letter, and only contain letters, numbers, unerscore, dot or hyphen",
186 | 		});
187 | 	}
188 | 
189 | 	if (std.sort.binarySearch([]const u8, username, &reserved_usernames, {}, compareString) != null) {
190 | 		try context.add(validate.Invalid{
191 | 			.code = aolium.val.RESERVED_USERNAME,
192 | 			.err = "is reserved",
193 | 		});
194 | 	}
195 | 
196 | 	return username;
197 | }
198 | 
199 | fn compareString(_: void, key: []const u8, value: []const u8) std.math.Order {
200 | 	var key_compare = key;
201 | 	var value_compare = value;
202 | 
203 | 	var result = std.math.Order.eq;
204 | 
205 | 	if (value.len < key.len) {
206 | 		result = std.math.Order.gt;
207 | 		key_compare = key[0..value.len];
208 | 	} else if (value.len > key.len) {
209 | 		result = std.math.Order.lt;
210 | 		value_compare = value[0..key.len];
211 | 	}
212 | 
213 | 	for (key_compare, value_compare) |k, v| {
214 | 		const order = std.math.order(std.ascii.toLower(k), v);
215 | 		if (order != .eq) {
216 | 			return order;
217 | 		}
218 | 	}
219 | 
220 | 	return result;
221 | }
222 | 
223 | const t = aolium.testing;
224 | test "auth.register: empty body" {
225 | 	var tc = t.context(.{});
226 | 	defer tc.deinit();
227 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
228 | }
229 | 
230 | test "auth.register: invalid json body" {
231 | 	var tc = t.context(.{});
232 | 	defer tc.deinit();
233 | 
234 | 	tc.web.body("{hi");
235 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
236 | }
237 | 
238 | test "auth.register: invalid input" {
239 | 	{
240 | 		var tc = t.context(.{});
241 | 		defer tc.deinit();
242 | 
243 | 		tc.web.json(.{.hack = true, .email = "nope"});
244 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
245 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "username"});
246 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "password"});
247 | 		try tc.expectInvalid(.{.code = aolium.val.INVALID_EMAIL, .field = "email"});
248 | 	}
249 | 
250 | 	{
251 | 		var tc = t.context(.{});
252 | 		defer tc.deinit();
253 | 
254 | 		tc.web.json(.{.username = "a2", .password = "12345"});
255 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
256 | 		try tc.expectInvalid(.{.code = validate.codes.STRING_LEN, .field = "username", .data = .{.min = 4, .max = 20}});
257 | 	try tc.expectInvalid(.{.code = validate.codes.STRING_LEN, .field = "password", .data = .{.min = 6, .max = 70}});
258 | 	}
259 | }
260 | 
261 | test "auth.register: duplicate username" {
262 | 	var tc = t.context(.{});
263 | 	defer tc.deinit();
264 | 
265 | 	_ = tc.insert.user(.{.username = "DupeUserTest"});
266 | 
267 | 	tc.web.json(.{.username = "dupeusertest", .password = "1234567", .drink = "no"});
268 | 	try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
269 | 	try tc.expectInvalid(.{.code = aolium.val.USERNAME_IN_USE, .field = "username"});
270 | }
271 | 
272 | test "auth.register: success no email and no spam fields" {
273 | 	var tc = t.context(.{});
274 | 	defer tc.deinit();
275 | 
276 | 	tc.web.json(.{.username = "reg-user", .password = "reg-passwrd", .drink = "tea"});
277 | 	try handler(tc.env(), tc.web.req, tc.web.res);
278 | 	try tc.web.expectStatus(200);
279 | 
280 | 	const body = (try tc.web.getJson()).object;
281 | 	const user_id = body.get("user").?.object.get("id").?.integer;
282 | 	const session_id = body.get("session_id").?.string;
283 | 
284 | 	{
285 | 		const row = tc.getAuthRow("select * from users where id = ?1", .{user_id}).?;
286 | 		try t.expectEqual(1, row.get("active").?.i64);
287 | 		try t.expectEqual(0, row.get("reset_password").?.i64);
288 | 		try t.expectEqual(true, row.get("email").?.isNull());
289 | 		try t.expectEqual(true, row.get("spam_js").?.isNull());
290 | 		try t.expectEqual(true, row.get("spam_load").?.isNull());
291 | 		try t.expectEqual(true, row.get("spam_hidden").?.isNull());
292 | 		try t.expectString("tea", row.get("spam_drink").?.string);
293 | 		try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
294 | 		try argon2.strVerify(row.get("password").?.string, "reg-passwrd", .{.allocator = tc.arena});
295 | 	}
296 | 
297 | 	{
298 | 		const row = tc.getAuthRow("select user_id, expires from sessions where id = ?1", .{session_id}).?;
299 | 		try t.expectEqual(user_id, row.get("user_id").?.i64);
300 | 		try t.expectDelta(std.time.timestamp() + 2_592_000, row.get("expires").?.i64, 5);
301 | 	}
302 | }
303 | 
304 | test "auth.register: success with email and all spam fields" {
305 | 	var tc = t.context(.{});
306 | 	defer tc.deinit();
307 | 
308 | 	tc.web.json(.{
309 | 		.username = "reg-user2",
310 | 		.password = "reg-passwrd2",
311 | 		.email = "leto@aolium.dev",
312 | 		.load = 1690012313,
313 | 		.drink = "coffee",
314 | 		.choice = "should be empty",
315 | 		.comment = "testing"
316 | 	});
317 | 	try handler(tc.env(), tc.web.req, tc.web.res);
318 | 	try tc.web.expectStatus(200);
319 | 
320 | 	const row = tc.getAuthRow("select * from users where username = 'reg-user2'", .{}).?;
321 | 	try argon2.strVerify(row.get("email").?.string, "leto@aolium.dev", .{.allocator = tc.arena});
322 | 	try t.expectString("testing", row.get("spam_js").?.string);
323 | 	try t.expectEqual(1690012313, row.get("spam_load").?.i64);
324 | 	try t.expectString("coffee", row.get("spam_drink").?.string);
325 | 	try t.expectString("should be empty", row.get("spam_hidden").?.string);
326 | }
327 | 
328 | test "auth.validateEmail" {
329 | 	var tc = t.context(.{});
330 | 	defer tc.deinit();
331 | 
332 | 	const validator = try tc.app.validators.acquire({});
333 | 
334 | 	try t.expectEqual(null, try validateEmail(null, validator));
335 | 	try t.expectString("leto@caladan.gov", (try validateEmail("leto@caladan.gov", validator)).?);
336 | 	try t.expectString("a@b.gov.museum", (try validateEmail("a@b.gov.museum", validator)).?);
337 | 
338 | 	const invalid_emails = [_][]const u8{
339 | 		"nope",
340 | 		"has @aspace.com",
341 | 		"has@two@ats.com",
342 | 		"@test.com",
343 | 		"leto@test.",
344 | 	};
345 | 
346 | 	for (invalid_emails) |email| {
347 | 		validator.reset();
348 | 		_ = try validateEmail(email, validator);
349 | 		try validate.testing.expectInvalid(.{.code = aolium.val.INVALID_EMAIL}, validator);
350 | 	}
351 | }
352 | 
353 | test "auth.validateUsername" {
354 | 	var tc = t.context(.{});
355 | 	defer tc.deinit();
356 | 
357 | 	const validator = try tc.app.validators.acquire({});
358 | 
359 | 	try t.expectString("leto", (try validateUsername("leto", validator)).?);
360 | 	try t.expectString("l.e_t-0", (try validateUsername("l.e_t-0", validator)).?);
361 | 
362 | 	{
363 | 		const invalid_usernames = [_][]const u8{
364 | 			"1eto",
365 | 			"_eto",
366 | 			"l eto",
367 | 			"l$eto",
368 | 			"l!te",
369 | 			"l@eto",
370 | 		};
371 | 
372 | 		for (invalid_usernames) |username| {
373 | 			validator.reset();
374 | 			_ = try validateUsername(username, validator);
375 | 			try validate.testing.expectInvalid(.{.code = aolium.val.INVALID_USERNAME}, validator);
376 | 		}
377 | 	}
378 | 
379 | 	{
380 | 		var buf: [20]u8 = undefined;
381 | 		for (reserved_usernames) |reserved| {
382 | 			validator.reset();
383 | 			_ = try validateUsername(reserved, validator);
384 | 
385 | 			try validate.testing.expectInvalid(.{.code = aolium.val.RESERVED_USERNAME}, validator);
386 | 
387 | 			// also test the uppercase version
388 | 			validator.reset();
389 | 			const upper = std.ascii.upperString(&buf, reserved);
390 | 			_ = try validateUsername(upper, validator);
391 | 			try validate.testing.expectInvalid(.{.code = aolium.val.RESERVED_USERNAME}, validator);
392 | 		}
393 | 	}
394 | }
395 | 


--------------------------------------------------------------------------------
/src/web/comments/_comments.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const validate = @import("validate");
 3 | pub const web = @import("../web.zig");
 4 | 
 5 | const aolium = web.aolium;
 6 | const Allocator = std.mem.Allocator;
 7 | 
 8 | // expose nested routes
 9 | pub const _index = @import("index.zig");
10 | pub const _count = @import("count.zig");
11 | pub const _create = @import("create.zig");
12 | pub const _delete = @import("delete.zig");
13 | pub const _approve = @import("approve.zig");
14 | pub const index = _index.handler;
15 | pub const count = _count.handler;
16 | pub const create = _create.handler;
17 | pub const delete = _delete.handler;
18 | pub const approve = _approve.handler;
19 | 
20 | pub var create_validator: *validate.Object(void) = undefined;
21 | 
22 | pub fn init(builder: *validate.Builder(void)) !void {
23 | 	_count.init(builder);
24 | 	_create.init(builder);
25 | }
26 | 


--------------------------------------------------------------------------------
/src/web/comments/approve.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const httpz = @import("httpz");
  4 | const validate = @import("validate");
  5 | const comments = @import("_comments.zig");
  6 | 
  7 | const web = comments.web;
  8 | const aolium = web.aolium;
  9 | 
 10 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 11 | 	const comment_id = try web.parseUUID("id", req.params.get("id").?, env);
 12 | 
 13 | 	const user = env.user.?;
 14 | 	const sql =
 15 | 		\\ update comments set approved = unixepoch()
 16 | 		\\ where id = ?1 and exists (
 17 | 		\\   select 1 from posts where id = comments.post_id and user_id = ?2
 18 | 		\\ )
 19 | 		\\ and approved is null
 20 | 		\\ returning post_id
 21 | 	;
 22 | 	const args = .{&comment_id.bin, user.id};
 23 | 	const app = env.app;
 24 | 
 25 | 	{
 26 | 		// we want conn released ASAP
 27 | 		const conn = app.getDataConn(user.shard_id);
 28 | 		defer app.releaseDataConn(conn, user.shard_id);
 29 | 
 30 | 		const row = conn.row(sql, args) catch |err| {
 31 | 			return aolium.sqliteErr("comments.approve", err, conn, env.logger);
 32 | 		} orelse {
 33 | 			return web.notFound(res, "the comment could not be found");
 34 | 		};
 35 | 		defer row.deinit();
 36 | 
 37 | 		const post_id = row.text(0);
 38 | 		conn.exec("update posts set comments = comments + 1 where id = ?1", .{post_id}) catch |err| {
 39 | 			return aolium.sqliteErr("comments.approve.update", err, conn, env.logger);
 40 | 		};
 41 | 		app.clearPostCache(zul.UUID{.bin = post_id[0..16].*});
 42 | 	}
 43 | 	res.status = 204;
 44 | }
 45 | 
 46 | const t = aolium.testing;
 47 | test "posts.approve: invalid id" {
 48 | 	var tc = t.context(.{});
 49 | 	defer tc.deinit();
 50 | 
 51 | 	tc.user(.{.id = 1});
 52 | 	tc.web.param("id", "nope");
 53 | 	try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 54 | 	try tc.expectInvalid(.{.code = validate.codes.TYPE_UUID, .field = "id"});
 55 | }
 56 | 
 57 | test "posts.approve: unknown id" {
 58 | 	var tc = t.context(.{});
 59 | 	defer tc.deinit();
 60 | 
 61 | 	tc.user(.{.id = 1});
 62 | 	tc.web.param("id", "4b0548fc-7127-438d-a87e-bc283f2d5981");
 63 | 	try handler(tc.env(), tc.web.req, tc.web.res);
 64 | 	try tc.web.expectStatus(404);
 65 | }
 66 | 
 67 | test "posts.approve: post belongs to a different user" {
 68 | 	var tc = t.context(.{});
 69 | 	defer tc.deinit();
 70 | 
 71 | 	tc.user(.{.id = 1});
 72 | 	const pid = tc.insert.post(.{.user_id = 4});
 73 | 	const cid = tc.insert.comment(.{.post_id = pid});
 74 | 
 75 | 	tc.web.param("id", cid);
 76 | 	try handler(tc.env(), tc.web.req, tc.web.res);
 77 | 	try tc.web.expectStatus(404);
 78 | 
 79 | 	const row = tc.getDataRow("select 1 from comments where id = ?1", .{(try zul.UUID.parse(cid)).bin});
 80 | 	try t.expectEqual(true, row != null);
 81 | }
 82 | 
 83 | test "posts.approve: post already approved" {
 84 | 	var tc = t.context(.{});
 85 | 	defer tc.deinit();
 86 | 
 87 | 	tc.user(.{.id = 20});
 88 | 	const pid = tc.insert.post(.{.user_id = 20});
 89 | 	const cid = tc.insert.comment(.{.post_id = pid, .approved = 10});
 90 | 
 91 | 	tc.web.param("id", cid);
 92 | 	try handler(tc.env(), tc.web.req, tc.web.res);
 93 | 	try tc.web.expectStatus(404);
 94 | }
 95 | 
 96 | test "posts.approve: success" {
 97 | 	var tc = t.context(.{});
 98 | 	defer tc.deinit();
 99 | 
100 | 	tc.user(.{.id = 33});
101 | 	const pid = tc.insert.post(.{.user_id = 33});
102 | 	const cid = tc.insert.comment(.{.post_id = pid, .approved = null});
103 | 
104 | 	tc.web.param("id", cid);
105 | 	try handler(tc.env(), tc.web.req, tc.web.res);
106 | 	try tc.web.expectStatus(204);
107 | 
108 | 	const row = tc.getDataRow("select approved from comments where id = ?1", .{(try zul.UUID.parse(cid)).bin}).?;
109 | 	try t.expectDelta(std.time.timestamp(), row.get("approved").?.i64, 2);
110 | }
111 | 


--------------------------------------------------------------------------------
/src/web/comments/count.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const uuid = @import("uuid");
 3 | const httpz = @import("httpz");
 4 | const validate = @import("validate");
 5 | const comments = @import("_comments.zig");
 6 | 
 7 | const web = comments.web;
 8 | const aolium = web.aolium;
 9 | const Allocator = std.mem.Allocator;
10 | 
11 | var count_validator: *validate.Object(void) = undefined;
12 | 
13 | pub fn init(builder: *validate.Builder(void)) void {
14 | 	count_validator = builder.object(&.{
15 | 		builder.field("username", builder.string(.{.required = true, .max = aolium.MAX_USERNAME_LEN, .trim = true})),
16 | 	}, .{});
17 | }
18 | 
19 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
20 | 	const input = try web.validateQuery(req, count_validator, env);
21 | 
22 | 	const app = env.app;
23 | 	const user = try app.getUserFromUsername(input.get("username").?.string) orelse {
24 | 		return web.notFound(res, "username doesn't exist");
25 | 	};
26 | 
27 | 	const shard_id = user.shard_id;
28 | 	const conn = app.getDataConn(shard_id);
29 | 	defer app.releaseDataConn(conn, shard_id);
30 | 
31 | 	const sql =
32 | 		\\ select count(*)
33 | 		\\ from comments c
34 | 		\\   join posts p on c.post_id = p.id
35 | 		\\ where p.user_id = ?1 and c.approved is null
36 | 	;
37 | 	var row = conn.row(sql, .{user.id}) catch |err| {
38 | 		return aolium.sqliteErr("comments.count", err, conn, env.logger);
39 | 	} orelse {
40 | 		res.body = "{\"count\":0}";
41 | 		return;
42 | 	};
43 | 	defer row.deinit();
44 | 	return res.json(.{.count = row.int(0)}, .{});
45 | }
46 | 


--------------------------------------------------------------------------------
/src/web/comments/create.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const httpz = @import("httpz");
  4 | const validate = @import("validate");
  5 | const comments = @import("_comments.zig");
  6 | 
  7 | const web = comments.web;
  8 | const aolium = web.aolium;
  9 | 
 10 | var create_validator: *validate.Object(void) = undefined;
 11 | 
 12 | pub fn init(builder: *validate.Builder(void)) void {
 13 | 	create_validator = builder.object(&.{
 14 | 		builder.field("name", builder.string(.{.trim = true, .max = 100})),
 15 | 		builder.field("comment", builder.string(.{.required = true, .trim = true, .max = 2000, .function = validateComment})),
 16 | 		builder.field("username", builder.string(.{.required = true, .max = aolium.MAX_USERNAME_LEN, .trim = true})),
 17 | 	}, .{});
 18 | }
 19 | 
 20 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 21 | 	const input = try web.validateJson(req, create_validator, env);
 22 | 	const post_id = try web.parseUUID("id", req.params.get("id").?, env);
 23 | 
 24 | 	const app = env.app;
 25 | 	const post_author = try app.getUserFromUsername(input.get("username").?.string) orelse {
 26 | 		return web.notFound(res, "username doesn't exist");
 27 | 	};
 28 | 	const post_author_id = post_author.id;
 29 | 
 30 | 	const comment = input.get("comment").?.string;
 31 | 
 32 | 	var approved: ?i64 = null;
 33 | 	var commentor_id: ?i64 = null;
 34 | 	var name: ?[]const u8 = null;
 35 | 
 36 | 	if (env.user) |u| {
 37 | 		name = u.username;
 38 | 		commentor_id = u.id;
 39 | 		if (u.id == post_author_id) {
 40 | 			approved = std.time.timestamp();
 41 | 		}
 42 | 	} else if (input.get("name")) |n| {
 43 | 		name = n.string;
 44 | 	}
 45 | 
 46 | 	const comment_id = zul.UUID.v4();
 47 | 
 48 | 	{
 49 | 		const conn = app.getDataConn(post_author.shard_id);
 50 | 		defer app.releaseDataConn(conn, post_author.shard_id);
 51 | 
 52 | 		const get_post_sql = "select 1 from posts where id = ?1 and user_id = ?2";
 53 | 		const row = conn.row(get_post_sql, .{&post_id.bin, post_author_id}) catch |err| {
 54 | 			return aolium.sqliteErr("comments.select", err, conn, env.logger);
 55 | 		} orelse {
 56 | 			return web.notFound(res, "post doesn't exist");
 57 | 		};
 58 | 		row.deinit();
 59 | 
 60 | 		const insert_comment_sql = "insert into comments (id, post_id, user_id, name, comment, approved) values (?1, ?2, ?3, ?4, ?5, ?6)";
 61 | 		conn.exec(insert_comment_sql, .{&comment_id.bin, &post_id.bin, commentor_id, name, comment, approved}) catch |err| {
 62 | 			return aolium.sqliteErr("comments.insert", err, conn, env.logger);
 63 | 		};
 64 | 
 65 | 		if (approved != null) {
 66 | 			const update_post_sql = "update posts set comments = comments + 1 where id = ?1";
 67 | 			conn.exec(update_post_sql, .{&post_id.bin}) catch |err| {
 68 | 				return aolium.sqliteErr("comments.post_update", err, conn, env.logger);
 69 | 			};
 70 | 			app.clearPostCache(post_id);
 71 | 		}
 72 | 	}
 73 | 
 74 | 	return res.json(.{.id = comment_id}, .{});
 75 | }
 76 | 
 77 | fn validateComment(value: ?[]const u8, context: *validate.Context(void)) !?[]const u8 {
 78 | 	const comment = value.?;
 79 | 	const pos = std.ascii.indexOfIgnoreCase(comment, "http") orelse return comment;
 80 | 	if (pos + 10 > comment.len) {
 81 | 		return comment;
 82 | 	}
 83 | 
 84 | 	var next = pos + 4;
 85 | 	if (comment[next] == 's') {
 86 | 			next += 1;
 87 | 	}
 88 | 
 89 | 	if (std.mem.eql(u8, comment[next..next+3], "://") == false) {
 90 | 		return comment;
 91 | 	}
 92 | 
 93 | 	try context.add(.{
 94 | 		.code = aolium.val.LINK_IN_COMMENT,
 95 | 		.err = "links are not allowed in comments",
 96 | 	});
 97 | 
 98 | 	return comment;
 99 | }
100 | 
101 | const t = aolium.testing;
102 | test "comments.create: empty body" {
103 | 	var tc = t.context(.{});
104 | 	defer tc.deinit();
105 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
106 | }
107 | 
108 | test "comments.create: invalid json body" {
109 | 	var tc = t.context(.{});
110 | 	defer tc.deinit();
111 | 
112 | 	tc.web.body("{hi");
113 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
114 | }
115 | 
116 | test "comments.create: invalid input" {
117 | 	var tc = t.context(.{});
118 | 	defer tc.deinit();
119 | 
120 | 	tc.web.json(.{.x = 1, .name = 32});
121 | 	try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
122 | 	try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "comment"});
123 | 	try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "username"});
124 | 	try tc.expectInvalid(.{.code = validate.codes.TYPE_STRING, .field = "name"});
125 | }
126 | 
127 | test "comments.create: unknown user" {
128 | 	var tc = t.context(.{});
129 | 	defer tc.deinit();
130 | 
131 | 	tc.web.param("id", try zul.UUID.v4().toHexAlloc(tc.arena, .lower));
132 | 	tc.web.json(.{.comment = "It", .username = "unknown-user"});
133 | 	try handler(tc.env(), tc.web.req, tc.web.res);
134 | 	try tc.web.expectStatus(404);
135 | }
136 | 
137 | test "comments.create: unknown post" {
138 | 	var tc = t.context(.{});
139 | 	defer tc.deinit();
140 | 
141 | 	_ = tc.insert.user(.{.username = "common-unknown-post"});
142 | 	const post_id = tc.insert.post(.{.user_id = 0, });
143 | 
144 | 	tc.web.param("id", post_id);
145 | 	tc.web.json(.{.comment = "It", .username = "common-unknown-post"});
146 | 	try handler(tc.env(), tc.web.req, tc.web.res);
147 | 	try tc.web.expectStatus(404);
148 | }
149 | 
150 | test "comments.create: link in comment" {
151 | 	var tc = t.context(.{});
152 | 	defer tc.deinit();
153 | 
154 | 	const uid1 = tc.insert.user(.{.username = "anon-comment-1"});
155 | 	const post_id = tc.insert.post(.{.user_id = uid1, });
156 | 
157 | 	{
158 | 		tc.web.param("id", post_id);
159 | 		tc.web.json(.{.comment = "my spam http://www.example.com", .username = "anon-comment-1"});
160 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
161 | 		try tc.expectInvalid(.{.code = aolium.val.LINK_IN_COMMENT, .field = "comment"});
162 | 	}
163 | 
164 | 	{
165 | 		// https
166 | 		tc.reset();
167 | 		tc.web.param("id", post_id);
168 | 		tc.web.json(.{.comment = "my spam https://www.example.com", .username = "anon-comment-1"});
169 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
170 | 		try tc.expectInvalid(.{.code = aolium.val.LINK_IN_COMMENT, .field = "comment"});
171 | 	}
172 | }
173 | 
174 | test "comments.create: anonymous" {
175 | 	var tc = t.context(.{});
176 | 	defer tc.deinit();
177 | 
178 | 	const uid1 = tc.insert.user(.{.username = "anon-comment-1"});
179 | 	const post_id = tc.insert.post(.{.user_id = uid1, });
180 | 
181 | 	tc.web.param("id", post_id);
182 | 	tc.web.json(.{.comment = "I think you are wrong and stupid!", .username = "anon-comment-1"});
183 | 	try handler(tc.env(), tc.web.req, tc.web.res);
184 | 
185 | 	const body = (try tc.web.getJson()).object;
186 | 	const id = try zul.UUID.parse(body.get("id").?.string);
187 | 
188 | 	const row = tc.getDataRow("select * from comments where id = ?1", .{&id.bin}).?;
189 | 	try t.expectEqual(true, row.get("user_id").?.isNull());
190 | 	try t.expectEqual(true, row.get("name").?.isNull());
191 | 	try t.expectEqual(true, row.get("approved").?.isNull());
192 | 	try t.expectSlice(u8, &(try zul.UUID.parse(post_id)).bin, row.get("post_id").?.string);
193 | 	try t.expectString("I think you are wrong and stupid!", row.get("comment").?.string);
194 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
195 | }
196 | 
197 | test "comments.create: from non-author user" {
198 | 	var tc = t.context(.{});
199 | 	defer tc.deinit();
200 | 
201 | 	const uid1 = tc.insert.user(.{.username = "user-comment-2a"});
202 | 	const uid2 = tc.insert.user(.{.username = "user-comment-2b"});
203 | 	const post_id = tc.insert.post(.{.user_id = uid2});
204 | 
205 | 	tc.user(.{.id = uid1, .username = "user-comment-2a"});
206 | 	tc.web.param("id", post_id);
207 | 	tc.web.json(.{.comment = "no you are", .username = "user-comment-2b"});
208 | 	try handler(tc.env(), tc.web.req, tc.web.res);
209 | 
210 | 	const body = (try tc.web.getJson()).object;
211 | 	const id = try zul.UUID.parse(body.get("id").?.string);
212 | 
213 | 	const row = tc.getDataRow("select * from comments where id = ?1", .{&id.bin}).?;
214 | 	try t.expectEqual(uid1, row.get("user_id").?.i64);
215 | 	try t.expectString("user-comment-2a", row.get("name").?.string);
216 | 	try t.expectString("no you are", row.get("comment").?.string);
217 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
218 | 	try t.expectSlice(u8, &(try zul.UUID.parse(post_id)).bin, row.get("post_id").?.string);
219 | 	try t.expectEqual(true, row.get("approved").?.isNull());
220 | }
221 | 
222 | test "comments.create: from author" {
223 | 	var tc = t.context(.{});
224 | 	defer tc.deinit();
225 | 
226 | 
227 | 	const uid1 = tc.insert.user(.{.username = "author-comment-1"});
228 | 	const post_id = tc.insert.post(.{.user_id = uid1, });
229 | 
230 | 	tc.user(.{.id = uid1, .username = "author-comment-1"});
231 | 	tc.web.param("id", post_id);
232 | 	tc.web.json(.{.comment = "no you are", .username = "author-comment-1"});
233 | 	try handler(tc.env(), tc.web.req, tc.web.res);
234 | 
235 | 	const body = (try tc.web.getJson()).object;
236 | 	const id = try zul.UUID.parse(body.get("id").?.string);
237 | 
238 | 	const row = tc.getDataRow("select * from comments where id = ?1", .{&id.bin}).?;
239 | 	try t.expectEqual(uid1, row.get("user_id").?.i64);
240 | 	try t.expectString("author-comment-1", row.get("name").?.string);
241 | 	try t.expectString("no you are", row.get("comment").?.string);
242 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
243 | 	try t.expectSlice(u8, &(try zul.UUID.parse(post_id)).bin, row.get("post_id").?.string);
244 | 	try t.expectDelta(std.time.timestamp(), row.get("approved").?.i64, 2);
245 | }
246 | 


--------------------------------------------------------------------------------
/src/web/comments/delete.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const zul = @import("zul");
 3 | const httpz = @import("httpz");
 4 | const validate = @import("validate");
 5 | const comments = @import("_comments.zig");
 6 | 
 7 | const web = comments.web;
 8 | const aolium = web.aolium;
 9 | 
10 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
11 | 	const comment_id = try web.parseUUID("id", req.params.get("id").?, env);
12 | 
13 | 	const user = env.user.?;
14 | 	const sql =
15 | 		\\ delete from comments
16 | 		\\ where id = ?1 and exists (
17 | 		\\   select 1 from posts where id = comments.post_id and user_id = ?2
18 | 		\\ )
19 | 	;
20 | 
21 | 	const args = .{&comment_id.bin, user.id};
22 | 	const app = env.app;
23 | 
24 | 	{
25 | 		// we want conn released ASAP
26 | 		const conn = app.getDataConn(user.shard_id);
27 | 		defer app.releaseDataConn(conn, user.shard_id);
28 | 
29 | 		conn.exec(sql, args) catch |err| {
30 | 			return aolium.sqliteErr("comments.delete", err, conn, env.logger);
31 | 		};
32 | 
33 | 		if (conn.changes() == 0) {
34 | 			return web.notFound(res, "the comment could not be found");
35 | 		}
36 | 	}
37 | 	res.status = 204;
38 | }
39 | 
40 | const t = aolium.testing;
41 | test "posts.delete: invalid id" {
42 | 	var tc = t.context(.{});
43 | 	defer tc.deinit();
44 | 
45 | 	tc.user(.{.id = 1});
46 | 	tc.web.param("id", "nope");
47 | 	try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
48 | 	try tc.expectInvalid(.{.code = validate.codes.TYPE_UUID, .field = "id"});
49 | }
50 | 
51 | test "posts.delete: unknown id" {
52 | 	var tc = t.context(.{});
53 | 	defer tc.deinit();
54 | 
55 | 	tc.user(.{.id = 1});
56 | 	tc.web.param("id", "4b0548fc-7127-438d-a87e-bc283f2d5981");
57 | 	try handler(tc.env(), tc.web.req, tc.web.res);
58 | 	try tc.web.expectStatus(404);
59 | }
60 | 
61 | test "posts.delete: post belongs to a different user" {
62 | 	var tc = t.context(.{});
63 | 	defer tc.deinit();
64 | 
65 | 	tc.user(.{.id = 1});
66 | 	const pid = tc.insert.post(.{.user_id = 4});
67 | 	const cid = tc.insert.comment(.{.post_id = pid});
68 | 
69 | 	tc.web.param("id", cid);
70 | 	try handler(tc.env(), tc.web.req, tc.web.res);
71 | 	try tc.web.expectStatus(404);
72 | 
73 | 	const row = tc.getDataRow("select 1 from comments where id = ?1", .{(try zul.UUID.parse(cid)).bin});
74 | 	try t.expectEqual(true, row != null);
75 | }
76 | 
77 | test "posts.delete: success" {
78 | 	var tc = t.context(.{});
79 | 	defer tc.deinit();
80 | 
81 | 	tc.user(.{.id = 3});
82 | 	const pid = tc.insert.post(.{.user_id = 3});
83 | 	const cid = tc.insert.comment(.{.post_id = pid});
84 | 
85 | 	tc.web.param("id", cid);
86 | 	try handler(tc.env(), tc.web.req, tc.web.res);
87 | 	try tc.web.expectStatus(204);
88 | 
89 | 	const row = tc.getDataRow("select 1 from comments where id = ?1", .{(try zul.UUID.parse(cid)).bin});
90 | 	try t.expectEqual(true, row == null);
91 | }
92 | 


--------------------------------------------------------------------------------
/src/web/comments/index.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const zul = @import("zul");
 3 | const httpz = @import("httpz");
 4 | const validate = @import("validate");
 5 | const comments = @import("_comments.zig");
 6 | const posts = @import("../posts/_posts.zig");
 7 | 
 8 | const web = comments.web;
 9 | const aolium = web.aolium;
10 | const Allocator = std.mem.Allocator;
11 | 
12 | pub fn handler(env: *aolium.Env, _: *httpz.Request, res: *httpz.Response) !void {
13 | 	const app = env.app;
14 | 	var sb = try app.buffers.acquire();
15 | 	defer sb.release();
16 | 
17 | 	const prefix = "{\"comments\": [";
18 | 	try sb.write(prefix);
19 | 	const writer = sb.writer();
20 | 
21 | 	{
22 | 		const user = env.user.?;
23 | 		const shard_id = user.shard_id;
24 | 		const conn = app.getDataConn(shard_id);
25 | 		defer app.releaseDataConn(conn, shard_id);
26 | 
27 | 		const sql =
28 | 			\\ select c.id, c.name, c.comment, c.post_id, coalesce(p.title, substr(p.text, 0, 100)), c.created
29 | 			\\ from comments c
30 | 			\\   join posts p on c.post_id = p.id
31 | 			\\ where p.user_id = ?1
32 | 			\\   and c.approved is null
33 | 			\\ order by c.created desc
34 | 			\\ limit 20
35 | 		;
36 | 		var rows = conn.rows(sql, .{user.id}) catch |err| {
37 | 			return aolium.sqliteErr("comments.select", err, conn, env.logger);
38 | 		};
39 | 		defer rows.deinit();
40 | 
41 | 		while (rows.next()) |row| {
42 | 			const comment_value = posts.maybeRenderHTML(true, "comment", row, 2);
43 | 			defer comment_value.deinit();
44 | 
45 | 			try std.json.stringify(.{
46 | 				.id = try zul.UUID.binToHex(row.blob(0), .lower),
47 | 				.name = row.nullableText(1),
48 | 				.comment = comment_value.value(),
49 | 				.post_id = try zul.UUID.binToHex(row.blob(3), .lower),
50 | 				.post = row.text(4),
51 | 				.created = row.int(5),
52 | 			}, .{.emit_null_optional_fields = false}, writer);
53 | 
54 | 			try sb.write(",\n");
55 | 		}
56 | 
57 | 		if (rows.err) |err| {
58 | 			return aolium.sqliteErr("comments.select.rows", err, conn, env.logger);
59 | 		}
60 | 	}
61 | 
62 | 	if (sb.len() > prefix.len) {
63 | 		//truncate the trailing ",\n"
64 | 		sb.truncate(2);
65 | 	}
66 | 	try sb.write("\n]}");
67 | 
68 | 	res.body = sb.string();
69 | 	try res.write();
70 | }
71 | 


--------------------------------------------------------------------------------
/src/web/dispatcher.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const logz = @import("logz");
  4 | const cache = @import("cache");
  5 | const httpz = @import("httpz");
  6 | const web = @import("web.zig");
  7 | 
  8 | const aolium = web.aolium;
  9 | const App = aolium.App;
 10 | const Env = aolium.Env;
 11 | const User = aolium.User;
 12 | 
 13 | // TODO: randomize on startup
 14 | var request_id: u32 = 0;
 15 | 
 16 | pub const Dispatcher = struct {
 17 | 	app: *App,
 18 | 
 19 | 	// whether to try loading the user or not, this implies requires_user = false
 20 | 	load_user: bool = true,
 21 | 
 22 | 	// whether a user is required. When false, if we have a token, the user is still
 23 | 	// loaded (unless load_user = false).
 24 | 	requires_user: bool = false,
 25 | 
 26 | 	// whether or not to log HTTP request info (method, path, time, ...)
 27 | 	log_http: bool = false,
 28 | 
 29 | 	pub fn dispatch(self: *const Dispatcher, action: httpz.Action(*Env), req: *httpz.Request, res: *httpz.Response) !void {
 30 | 		const start_time = std.time.milliTimestamp();
 31 | 
 32 | 		const app = self.app;
 33 | 		const encoded_request_id = encodeRequestId(app.config.instance_id, @atomicRmw(u32, &request_id, .Add, 1, .monotonic));
 34 | 		var logger = logz.logger().stringSafe("$rid", &encoded_request_id).multiuse();
 35 | 
 36 | 		var env = Env{
 37 | 			.app = app,
 38 | 			.logger = logger,
 39 | 		};
 40 | 		defer env.deinit();
 41 | 
 42 | 		var code: i32 = 0;
 43 | 		var log_request = self.log_http;
 44 | 
 45 | 		self.doDispatch(action, req, res, &env) catch |err| switch (err) {
 46 | 			error.BrokenPipe, error.ConnectionResetByPeer => code = aolium.codes.CONNECTION_RESET,
 47 | 			error.InvalidJson => code = web.errors.InvalidJson.write(res),
 48 | 			error.UserRequired => code = web.errors.AccessDenied.write(res),
 49 | 			error.Validation => {
 50 | 				code = aolium.codes.VALIDATION_ERROR;
 51 | 				res.status = 400;
 52 | 				try res.json(.{
 53 | 					.err = "validation error",
 54 | 					.code = code,
 55 | 					.validation = env._validator.?.errors(),
 56 | 				}, .{.emit_null_optional_fields = false});
 57 | 			},
 58 | 			else => {
 59 | 				code = aolium.codes.INTERNAL_SERVER_ERROR_CAUGHT;
 60 | 				const error_id = try zul.UUID.random().toHexAlloc(res.arena, .lower);
 61 | 
 62 | 				res.status = 500;
 63 | 				res.header("Error-Id", error_id);
 64 | 				try res.json(.{
 65 | 					.code = code,
 66 | 					.error_id = error_id,
 67 | 					.err = "internal server error",
 68 | 				}, .{});
 69 | 
 70 | 				log_request = true;
 71 | 				_ = logger.stringSafe("error_id", error_id).err(err);
 72 | 			},
 73 | 		};
 74 | 
 75 | 		if (log_request) {
 76 | 			logger.
 77 | 				stringSafe("@l", "REQ").
 78 | 				stringSafe("method", @tagName(req.method)).
 79 | 				string("path", req.url.path).
 80 | 				int("status", res.status).
 81 | 				int("code", code).
 82 | 				int("uid", if (env.user) |u| u.id else 0).
 83 | 				int("ms", std.time.milliTimestamp() - start_time).
 84 | 				log();
 85 | 		}
 86 | 	}
 87 | 
 88 | 	fn doDispatch(self: *const Dispatcher, action: httpz.Action(*Env), req: *httpz.Request, res: *httpz.Response, env: *Env) !void {
 89 | 		if (self.load_user) {
 90 | 			const user_entry = try loadUser(env.app, web.getSessionId(req));
 91 | 			env._cached_user_entry = user_entry;
 92 | 
 93 | 			if (user_entry) |ue| {
 94 | 				env.user = ue.value;
 95 | 			} else if (self.requires_user) {
 96 | 				return error.UserRequired;
 97 | 			}
 98 | 		}
 99 | 
100 | 		try action(env, req, res);
101 | 	}
102 | };
103 | 
104 | fn loadUser(app: *App, optional_session_id: ?[]const u8) !?*cache.Entry(User) {
105 | 	const session_id = optional_session_id orelse return null;
106 | 	if (try app.session_cache.fetch(*App, session_id, loadUserFromSessionId, app, .{.ttl = 1800})) |entry| {
107 | 		return entry;
108 | 	}
109 | 	return null;
110 | }
111 | 
112 | fn loadUserFromSessionId(app: *App, session_id: []const u8) !?User {
113 | 	const sql =
114 | 		\\ select s.user_id, s.expires, u.username
115 | 		\\ from sessions s join users u on s.user_id = u.id
116 | 		\\ where u.active and s.id = $1
117 | 	;
118 | 	const args = .{session_id};
119 | 
120 | 	const conn = app.getAuthConn();
121 | 	defer app.releaseAuthConn(conn);
122 | 
123 | 	const row = conn.row(sql, args) catch |err| {
124 | 		return aolium.sqliteErr("Dispatcher.loadUser", err, conn, logz.logger());
125 | 	} orelse return null;
126 | 	defer row.deinit();
127 | 
128 | 	if (row.int(1) < std.time.timestamp()) {
129 | 		return null;
130 | 	}
131 | 
132 | 	return try User.init(app.session_cache.allocator, row.int(0), row.text(2));
133 | }
134 | 
135 | fn encodeRequestId(instance_id: u8, rid: u32) [8]u8 {
136 | 	const REQUEST_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
137 | 	const encoded_requested_id = std.mem.asBytes(&rid);
138 | 
139 | 	var encoded: [8]u8 = undefined;
140 | 	encoded[7] = REQUEST_ID_ALPHABET[instance_id&0x1F];
141 | 	encoded[6] = REQUEST_ID_ALPHABET[(instance_id>>5|(encoded_requested_id[0]<<3))&0x1F];
142 | 	encoded[5] = REQUEST_ID_ALPHABET[(encoded_requested_id[0]>>2)&0x1F];
143 | 	encoded[4] = REQUEST_ID_ALPHABET[(encoded_requested_id[0]>>7|(encoded_requested_id[1]<<1))&0x1F];
144 | 	encoded[3] = REQUEST_ID_ALPHABET[((encoded_requested_id[1]>>4)|(encoded_requested_id[2]<<4))&0x1F];
145 | 	encoded[2] = REQUEST_ID_ALPHABET[(encoded_requested_id[2]>>1)&0x1F];
146 | 	encoded[1] = REQUEST_ID_ALPHABET[((encoded_requested_id[2]>>6)|(encoded_requested_id[3]<<2))&0x1F];
147 | 	encoded[0] = REQUEST_ID_ALPHABET[encoded_requested_id[3]>>3];
148 | 	return encoded;
149 | }
150 | 
151 | const t = aolium.testing;
152 | test "dispatcher: encodeRequestId" {
153 | 	try t.expectString("AAAAAAYA", &encodeRequestId(0, 3));
154 | 	try t.expectString("AAAAABAA", &encodeRequestId(0, 4));
155 | 	try t.expectString("AAAAAAYC", &encodeRequestId(2, 3));
156 | 	try t.expectString("AAAAABAC", &encodeRequestId(2, 4));
157 | }
158 | 
159 | test "dispatcher: handles action error" {
160 | 	var tc = t.context(.{});
161 | 	defer tc.deinit();
162 | 
163 | 	t.noLogs();
164 | 	defer t.restoreLogs();
165 | 
166 | 	const dispatcher = Dispatcher{.app = tc.app};
167 | 	try dispatcher.dispatch(testErrorAction, tc.web.req, tc.web.res);
168 | 	try tc.web.expectStatus(500);
169 | 	try tc.web.expectJson(.{.code = 1});
170 | }
171 | 
172 | test "dispatcher: handles action validation error" {
173 | 	var tc = t.context(.{});
174 | 	defer tc.deinit();
175 | 
176 | 	const dispatcher = Dispatcher{.app = tc.app};
177 | 	try dispatcher.dispatch(testValidationAction, tc.web.req, tc.web.res);
178 | 	try tc.web.expectStatus(400);
179 | 	try tc.web.expectJson(.{.code = 5, .validation = &.{.{.code = 322, .err = "i cannot do that"}}});
180 | }
181 | 
182 | test "dispatcher: handles action invalidJson error" {
183 | 	var tc = t.context(.{});
184 | 	defer tc.deinit();
185 | 
186 | 	const dispatcher = Dispatcher{.app = tc.app};
187 | 	try dispatcher.dispatch(testInvalidJson, tc.web.req, tc.web.res);
188 | 	try tc.web.expectStatus(400);
189 | 	try tc.web.expectJson(.{.code = 4, .err = "invalid JSON"});
190 | }
191 | 
192 | test "dispatcher: dispatch to actions" {
193 | 	var tc = t.context(.{});
194 | 	defer tc.deinit();
195 | 
196 | 	tc.web.url("/test_1");
197 | 
198 | 	request_id = 958589;
199 | 	const dispatcher = Dispatcher{.app = tc.app};
200 | 	try dispatcher.dispatch(callableAction, tc.web.req, tc.web.res);
201 | 	try tc.web.expectStatus(200);
202 | 	try tc.web.expectJson(.{.url = "/test_1"});
203 | }
204 | 
205 | test "dispatcher: load user" {
206 | 	var tc = t.context(.{});
207 | 	defer tc.deinit();
208 | 
209 | 	const user_id1 = tc.insert.user(.{});
210 | 
211 | 	const dispatcher = Dispatcher{.app = tc.app};
212 | 	const req_dispatcher = Dispatcher{.app = tc.app, .requires_user = true};
213 | 
214 | 	{
215 | 		// no authorization header on public route, no problem
216 | 		try dispatcher.dispatch(testEchoUser, tc.web.req, tc.web.res);
217 | 		try tc.web.expectStatus(200);
218 | 		try tc.web.expectJson(.{.is_null = true});
219 | 	}
220 | 
221 | 	{
222 | 		tc.reset();
223 | 		// no authorization header on non-public route,  problem
224 | 		try req_dispatcher.dispatch(testErrorAction, tc.web.req, tc.web.res);
225 | 		try tc.web.expectStatus(401);
226 | 		try tc.web.expectJson(.{.code = 8});
227 | 	}
228 | 
229 | 	{
230 | 		// unknown token
231 | 		tc.reset();
232 | 		tc.web.header("authorization", "aolium abc12345558");
233 | 		try req_dispatcher.dispatch(testErrorAction, tc.web.req, tc.web.res);
234 | 		try tc.web.expectStatus(401);
235 | 		try tc.web.expectJson(.{.code = 8});
236 | 	}
237 | 
238 | 	{
239 | 		// expired token
240 | 		tc.reset();
241 | 		const sid = tc.insert.session(.{.user_id = user_id1, .ttl = - 1});
242 | 		tc.web.header("authorization", try std.fmt.allocPrint(tc.arena, "aolium {s}", .{sid}));
243 | 		try req_dispatcher.dispatch(testErrorAction, tc.web.req, tc.web.res);
244 | 		try tc.web.expectStatus(401);
245 | 		try tc.web.expectJson(.{.code = 8});
246 | 	}
247 | 
248 | 	{
249 | 		// valid token
250 | 		tc.reset();
251 | 		const sid = tc.insert.session(.{.user_id = user_id1, .ttl = 2});
252 | 		tc.web.header("authorization", try std.fmt.allocPrint(tc.arena, "aolium {s}", .{sid}));
253 | 		try req_dispatcher.dispatch(testEchoUser, tc.web.req, tc.web.res);
254 | 		try tc.web.expectJson(.{.id = user_id1});
255 | 	}
256 | }
257 | 
258 | fn testErrorAction(_: *Env, _: *httpz.Request, _: *httpz.Response) !void {
259 | 	return error.Nope;
260 | }
261 | 
262 | fn testValidationAction(env: *Env, _: *httpz.Request, _: *httpz.Response) !void {
263 | 	var validator = try env.validator();
264 | 	try validator.add(.{.code = 322, .err = "i cannot do that"});
265 | 	return error.Validation;
266 | }
267 | 
268 | fn testInvalidJson( _: *Env, _: *httpz.Request, _: *httpz.Response) !void {
269 | 	return error.InvalidJson;
270 | }
271 | 
272 | fn callableAction(env: *Env, req: *httpz.Request, res: *httpz.Response) !void {
273 | 	var arr = std.ArrayList(u8).init(t.allocator);
274 | 	defer arr.deinit();
275 | 
276 | 	if (std.mem.eql(u8, req.url.path, "/test_1")) {
277 | 		try env.logger.logTo(arr.writer());
278 | 		try t.expectString("@ts=9999999999999 $rid=AAHKA7IA\n", arr.items);
279 | 	} else {
280 | 		unreachable;
281 | 	}
282 | 	return res.json(.{.url = req.url.path}, .{});
283 | }
284 | 
285 | fn testEchoUser(env: *Env, _: *httpz.Request, res: *httpz.Response) !void {
286 | 	const user = env.user orelse {
287 | 		return res.json(.{.is_null = true}, .{});
288 | 	};
289 | 
290 | 	return res.json(.{.id = user.id}, .{});
291 | }
292 | 


--------------------------------------------------------------------------------
/src/web/misc/_misc.zig:
--------------------------------------------------------------------------------
 1 | const std = @import("std");
 2 | const httpz = @import("httpz");
 3 | const web = @import("../web.zig");
 4 | 
 5 | const aolium = web.aolium;
 6 | 
 7 | pub fn ping(env: *aolium.Env, _: *httpz.Request, res: *httpz.Response) !void {
 8 | 	const app = env.app;
 9 | 	const shard_id = @as(usize, @intCast(std.time.timestamp())) % app.data_pools.len;
10 | 	const conn = app.getDataConn(shard_id);
11 | 	defer app.releaseDataConn(conn, shard_id);
12 | 
13 | 	const row = conn.row("select 1", .{}) catch |err| {
14 | 		return aolium.sqliteErr("ping.select", err, conn, env.logger);
15 | 	} orelse return error.PingSelectError;
16 | 	defer row.deinit();
17 | 
18 | 	res.body = try std.fmt.allocPrint(res.arena, "db: {d}", .{row.int(0)});
19 | }
20 | 


--------------------------------------------------------------------------------
/src/web/posts/_posts.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const typed = @import("typed");
  3 | const zqlite = @import("zqlite");
  4 | const validate = @import("validate");
  5 | pub const web = @import("../web.zig");
  6 | const markdown = @import("../../markdown.zig");
  7 | 
  8 | const aolium = web.aolium;
  9 | const Allocator = std.mem.Allocator;
 10 | 
 11 | // expose nested routes
 12 | pub const _index = @import("index.zig");
 13 | pub const _show = @import("show.zig");
 14 | pub const _create = @import("create.zig");
 15 | pub const _update = @import("update.zig");
 16 | 
 17 | pub const index = _index.handler;
 18 | pub const show = _show.handler;
 19 | pub const create = _create.handler;
 20 | pub const update = _update.handler;
 21 | 
 22 | pub var create_validator: *validate.Object(void) = undefined;
 23 | var simple_validator: *validate.Object(void) = undefined;
 24 | var link_validator: *validate.Object(void) = undefined;
 25 | var long_validator: *validate.Object(void) = undefined;
 26 | 
 27 | pub fn init(builder: *validate.Builder(void)) !void {
 28 | 	_index.init(builder);
 29 | 	_show.init(builder);
 30 | 
 31 | 	const tag_validator = builder.string(.{.trim = true, .max = 20});
 32 | 
 33 | 	create_validator = builder.object(&.{
 34 | 		builder.field("type", builder.string(.{.required = true, .choices = &.{"simple", "link", "long"}})),
 35 | 		builder.field("tags", builder.array(tag_validator, .{.max = 10})),
 36 | 	}, .{.function = validatePost});
 37 | 
 38 | 	simple_validator = builder.object(&.{
 39 | 		builder.field("text", builder.string(.{.required = true, .max = 500, .trim = true})),
 40 | 	}, .{});
 41 | 
 42 | 	link_validator = builder.object(&.{
 43 | 		builder.field("title", builder.string(.{.required = true, .max = 200, .trim = true})),
 44 | 		builder.field("text", builder.string(.{.required = true, .max = 200, .trim = true})),
 45 | 	}, .{});
 46 | 
 47 | 	long_validator = builder.object(&.{
 48 | 		builder.field("title", builder.string(.{.required = true, .max = 200, .trim = true})),
 49 | 		builder.field("text", builder.string(.{.required = true, .max = 10000, .trim = true})),
 50 | 	}, .{});
 51 | }
 52 | 
 53 | pub const Post = struct {
 54 | 	type: []const u8,
 55 | 	text: []const u8,
 56 | 	title: ?[]const u8,
 57 | 	tags: ?[]const u8,  // serialized JSON array
 58 | 
 59 | 	pub fn create(arena: Allocator, input: typed.Map) !Post {
 60 | 		// type and text are always required
 61 | 		const tpe = input.get("type").?.string;
 62 | 		var text: ?[]const u8 = if (input.get("text")) |_t| _t.string else null;
 63 | 		if (std.mem.eql(u8, tpe, "link")) {
 64 | 			text = try normalizeLink(arena, text.?);
 65 | 		}
 66 | 
 67 | 		var tags: ?[]const u8 = null;
 68 | 		if (input.get("tags")) |tgs| {
 69 | 			tags = try std.json.stringifyAlloc(arena, tgs.array.items, .{});
 70 | 		}
 71 | 
 72 | 		return .{
 73 | 			.type = tpe,
 74 | 			.text = text.?,
 75 | 			.tags = tags,
 76 | 			.title = if (input.get("title")) |title| title.string else null,
 77 | 		};
 78 | 	}
 79 | 
 80 | 
 81 | 	// we know allocator is an arena
 82 | 	fn normalizeLink(allocator: std.mem.Allocator, link: []const u8) ![]const u8 {
 83 | 		const has_http_prefix = blk: {
 84 | 			if (link.len < 8) {
 85 | 				break :blk false;
 86 | 			}
 87 | 			if (std.ascii.startsWithIgnoreCase(link, "http") == false) {
 88 | 				break :blk false;
 89 | 			}
 90 | 			if (link[4] == ':' and link[5] == '/' and link[6] == '/') {
 91 | 				break :blk true;
 92 | 			}
 93 | 
 94 | 			break :blk (link[4] == 's' or link[4] == 'S') and link[5] == ':' and link[6] == '/' and link[7] == '/';
 95 | 		};
 96 | 
 97 | 		if (has_http_prefix) {
 98 | 			return link;
 99 | 		}
100 | 
101 | 		var prefixed = try allocator.alloc(u8, link.len + 8);
102 | 		@memcpy(prefixed[0..8], "https://");
103 | 		@memcpy(prefixed[8..], link);
104 | 		return prefixed;
105 | 	}
106 | };
107 | 
108 | // How the object is validated depends on the `type`
109 | fn validatePost(optional: ?typed.Map, ctx: *validate.Context(void)) !?typed.Map {
110 | 	// validator won't come this far if the root isn't an object
111 | 	const input = optional.?;
112 | 
113 | 	const PostType = enum {
114 | 		simple, link, long
115 | 	};
116 | 
117 | 	const string_type = input.get("type") orelse return null;
118 | 	if (std.meta.activeTag(string_type) == .null) {
119 | 		return null;
120 | 	}
121 | 
122 | 	// if type isn't valid, this will fail anyways, so return early
123 | 	const post_type = std.meta.stringToEnum(PostType, string_type.string) orelse return null;
124 | 
125 | 	switch (post_type) {
126 | 		.simple => _ = return simple_validator.validate(input, ctx),
127 | 		.link => _ = return link_validator.validate(input, ctx),
128 | 		.long => _ = return long_validator.validate(input, ctx),
129 | 	}
130 | }
131 | 
132 | // Wraps a nullable text column which may be raw or may be rendered html from
133 | // markdown. This wrapper allows our caller to call .value() and .deinit()
134 | // without having to know anything.
135 | pub const RenderResult = union(enum) {
136 | 	raw: ?[]const u8,
137 | 	html: markdown.Result,
138 | 
139 | 	pub fn value(self: RenderResult) ?[]const u8 {
140 | 		return switch (self) {
141 | 			.raw => |v| v,
142 | 			.html => |html| std.mem.span(html.value),
143 | 		};
144 | 	}
145 | 
146 | 	pub fn deinit(self: RenderResult) void {
147 | 		switch (self) {
148 | 			.raw => {},
149 | 			.html => |html| html.deinit(),
150 | 		}
151 | 	}
152 | };
153 | 
154 | // We optionally render markdown to HTML. If we _don't_, then things are
155 | // straightforward and we return the text column from sqlite as-is.
156 | // However, if we _are_ rendering, we (a) need a null-terminated string
157 | // from sqlite (because that's what cmark wants) and we need to free the
158 | // result.
159 | pub fn maybeRenderHTML(html: bool, tpe: []const u8, row: zqlite.Row, col: usize) RenderResult {
160 | 	if (!html or std.mem.eql(u8, tpe, "link")) {
161 | 		return .{.raw = row.nullableText(col)};
162 | 	}
163 | 
164 | 	const value = row.nullableTextZ(col) orelse {
165 | 		return .{.raw = null};
166 | 	};
167 | 
168 | 	// we're here because we've been asked to render the value to HTML and
169 | 	// we actually have a value
170 | 	return .{.html = markdown.toHTML(value, row.textLen(col))};
171 | }
172 | 
173 | const t = aolium.testing;
174 | test "posts: normalizeLink" {
175 | 	try t.expectString("http://aolium.dev", try Post.normalizeLink(undefined, "http://aolium.dev"));
176 | 	try t.expectString("HTTP://aolium.dev", try Post.normalizeLink(undefined, "HTTP://aolium.dev"));
177 | 	try t.expectString("https://www.openmymind.net", try Post.normalizeLink(undefined, "https://www.openmymind.net"));
178 | 	try t.expectString("HTTPS://www.openmymind.net", try Post.normalizeLink(undefined, "HTTPS://www.openmymind.net"));
179 | 
180 | 	{
181 | 		const link = try Post.normalizeLink(t.allocator, "aolium.dev");
182 | 		defer t.allocator.free(link);
183 | 		try t.expectString("https://aolium.dev", link);
184 | 	}
185 | }
186 | 


--------------------------------------------------------------------------------
/src/web/posts/create.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const httpz = @import("httpz");
  4 | const validate = @import("validate");
  5 | const posts = @import("_posts.zig");
  6 | 
  7 | const web = posts.web;
  8 | const aolium = web.aolium;
  9 | 
 10 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 11 | 	const input = try web.validateJson(req, posts.create_validator, env);
 12 | 	const post = try posts.Post.create(req.arena, input);
 13 | 
 14 | 	const user = env.user.?;
 15 | 	const post_id = zul.UUID.v4();
 16 | 	const sql = "insert into posts (id, user_id, title, text, type, tags) values (?1, ?2, ?3, ?4, ?5, ?6)";
 17 | 	const args = .{&post_id.bin, user.id, post.title, post.text, post.type, post.tags};
 18 | 
 19 | 	const app = env.app;
 20 | 
 21 | 	{
 22 | 		// we want conn released ASAP
 23 | 		const conn = app.getDataConn(user.shard_id);
 24 | 		defer app.releaseDataConn(conn, user.shard_id);
 25 | 
 26 | 		conn.exec(sql, args) catch |err| {
 27 | 			return aolium.sqliteErr("posts.insert", err, conn, env.logger);
 28 | 		};
 29 | 	}
 30 | 
 31 | 	app.clearUserCache(user.id);
 32 | 	return res.json(.{.id = post_id}, .{});
 33 | }
 34 | 
 35 | const t = aolium.testing;
 36 | test "posts.create: empty body" {
 37 | 	var tc = t.context(.{});
 38 | 	defer tc.deinit();
 39 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
 40 | }
 41 | 
 42 | test "posts.create: invalid json body" {
 43 | 	var tc = t.context(.{});
 44 | 	defer tc.deinit();
 45 | 
 46 | 	tc.web.body("{hi");
 47 | 	try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res));
 48 | }
 49 | 
 50 | test "posts.create: invalid input" {
 51 | 	{
 52 | 		var tc = t.context(.{});
 53 | 		defer tc.deinit();
 54 | 
 55 | 		tc.web.json(.{.x = 1, .tags = 32});
 56 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 57 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "type"});
 58 | 		try tc.expectInvalid(.{.code = validate.codes.TYPE_ARRAY, .field = "tags"});
 59 | 	}
 60 | 
 61 | 	{
 62 | 		var tc = t.context(.{});
 63 | 		defer tc.deinit();
 64 | 
 65 | 		tc.web.json(.{.type = "melange", .tags = .{"hi", 3}});
 66 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 67 | 		try tc.expectInvalid(.{.code = validate.codes.STRING_CHOICE, .field = "type"});
 68 | 		try tc.expectInvalid(.{.code = validate.codes.TYPE_STRING, .field = "tags.1"});
 69 | 	}
 70 | 
 71 | 	{
 72 | 		var tc = t.context(.{});
 73 | 		defer tc.deinit();
 74 | 
 75 | 		tc.web.json(.{.type = "simple", .tags = .{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a"}});
 76 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 77 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"});
 78 | 		try tc.expectInvalid(.{.code = validate.codes.ARRAY_LEN_MAX, .field = "tags", .data = .{.max = 10}});
 79 | 	}
 80 | 
 81 | 	{
 82 | 		var tc = t.context(.{});
 83 | 		defer tc.deinit();
 84 | 
 85 | 		tc.web.json(.{.type = "link"});
 86 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 87 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "title"});
 88 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"});
 89 | 	}
 90 | 
 91 | 	{
 92 | 		var tc = t.context(.{});
 93 | 		defer tc.deinit();
 94 | 
 95 | 		tc.web.json(.{.type = "long"});
 96 | 		try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
 97 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "title"});
 98 | 		try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"});
 99 | 	}
100 | }
101 | 
102 | test "posts.create: simple" {
103 | 	var tc = t.context(.{});
104 | 	defer tc.deinit();
105 | 
106 | 	tc.user(.{.id = 3913});
107 | 	tc.web.json(.{.type = "simple", .text = "hello world!"});
108 | 	try handler(tc.env(), tc.web.req, tc.web.res);
109 | 
110 | 	const body = (try tc.web.getJson()).object;
111 | 	const id = try zul.UUID.parse(body.get("id").?.string);
112 | 
113 | 	const row = tc.getDataRow("select * from posts where id = ?1", .{id.bin}).?;
114 | 	try t.expectEqual(3913, row.get("user_id").?.i64);
115 | 	try t.expectString("simple", row.get("type").?.string);
116 | 	try t.expectString("hello world!", row.get("text").?.string);
117 | 	try t.expectEqual(true, row.get("title").?.isNull());
118 | 	try t.expectEqual(true, row.get("tags").?.isNull());
119 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
120 | 	try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2);
121 | }
122 | 
123 | test "posts.create: link" {
124 | 	var tc = t.context(.{});
125 | 	defer tc.deinit();
126 | 
127 | 	tc.user(.{.id = 3914});
128 | 	tc.web.json(.{.type = "link", .title = "FFmpeg - The Ultimate Guide", .text = "img.ly/blog/ultimate-guide-to-ffmpeg/", .tags = .{"tag1", "Tag2"}});
129 | 	try handler(tc.env(), tc.web.req, tc.web.res);
130 | 
131 | 	const body = (try tc.web.getJson()).object;
132 | 	const id = try zul.UUID.parse(body.get("id").?.string);
133 | 
134 | 	const row = tc.getDataRow("select * from posts where id = ?1", .{id.bin}).?;
135 | 	try t.expectEqual(3914, row.get("user_id").?.i64);
136 | 	try t.expectString("link", row.get("type").?.string);
137 | 	try t.expectString("https://img.ly/blog/ultimate-guide-to-ffmpeg/", row.get("text").?.string);
138 | 	try t.expectString("FFmpeg - The Ultimate Guide", row.get("title").?.string);
139 | 	try t.expectString("[\"tag1\",\"Tag2\"]", row.get("tags").?.string);
140 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
141 | 	try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2);
142 | }
143 | 
144 | test "posts.create: long" {
145 | 	var tc = t.context(.{});
146 | 	defer tc.deinit();
147 | 
148 | 	tc.user(.{.id = 3914});
149 | 	tc.web.json(.{.type = "long", .title = "A Title", .text = "Some content\nOk"});
150 | 	try handler(tc.env(), tc.web.req, tc.web.res);
151 | 
152 | 	const body = (try tc.web.getJson()).object;
153 | 	const id = try zul.UUID.parse(body.get("id").?.string);
154 | 
155 | 	const row = tc.getDataRow("select * from posts where id = ?1", .{id.bin}).?;
156 | 	try t.expectEqual(3914, row.get("user_id").?.i64);
157 | 	try t.expectString("long", row.get("type").?.string);
158 | 	try t.expectString("Some content\nOk", row.get("text").?.string);
159 | 	try t.expectString("A Title", row.get("title").?.string);
160 | 	try t.expectDelta(std.time.timestamp(), row.get("created").?.i64, 2);
161 | 	try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2);
162 | }
163 | 


--------------------------------------------------------------------------------
/src/web/posts/index.zig:
--------------------------------------------------------------------------------
  1 | const std = @import("std");
  2 | const zul = @import("zul");
  3 | const httpz = @import("httpz");
  4 | const zqlite = @import("zqlite");
  5 | const raw_json = @import("raw_json");
  6 | const validate = @import("validate");
  7 | const posts = @import("_posts.zig");
  8 | 
  9 | const web = posts.web;
 10 | const aolium = web.aolium;
 11 | const Allocator = std.mem.Allocator;
 12 | 
 13 | const DEFAULT_UPDATED_AT = zul.DateTime.parse("2023-07-23T00:00:00Z", .rfc3339) catch unreachable;
 14 | 
 15 | var index_validator: *validate.Object(void) = undefined;
 16 | 
 17 | pub fn init(builder: *validate.Builder(void)) void {
 18 | 	index_validator = builder.object(&.{
 19 | 		builder.field("username", builder.string(.{.required = true, .max = aolium.MAX_USERNAME_LEN, .trim = true})),
 20 | 		builder.field("atom", builder.boolean(.{.parse = true})),
 21 | 		builder.field("html", builder.boolean(.{.parse = true})),
 22 | 		builder.field("full", builder.boolean(.{.parse = true})),
 23 | 		builder.field("page", builder.int(u16, .{.parse = true, .min = 1})),
 24 | 	}, .{});
 25 | }
 26 | 
 27 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void {
 28 | 	const input = try web.validateQuery(req, index_validator, env);
 29 | 
 30 | 	const app = env.app;
 31 | 	const username = input.get("username").?.string;
 32 | 	const user = try app.getUserFromUsername(username) orelse {
 33 | 		return web.notFound(res, "username doesn't exist");
 34 | 	};
 35 | 
 36 | 	const atom = if (input.get("atom")) |i| i.bool else false;
 37 | 	const html = if (input.get("html")) |i| i.bool else true;
 38 | 	const full = if (input.get("full")) |i| i.bool else true;
 39 | 	// only do first page for atom
 40 | 	const page = if (atom) 1 else if (input.get("page")) |p| p.u16 else 1;
 41 | 
 42 | 	const fetcher = PostsFetcher.init(req.arena, env, user, username, page, html, atom, full);
 43 | 
 44 | 	const cached_response = (try app.http_cache.fetch(*const PostsFetcher, &fetcher.cache_key, PostsFetcher.getPosts, &fetcher, .{.ttl = 300})).?;
 45 | 	defer cached_response.release();
 46 | 	res.header("Cache-Control", "public,max-age=30");
 47 | 	try cached_response.value.write(res);
 48 | }
 49 | 
 50 | const PostsFetcher = struct {
 51 | 	atom: bool,
 52 | 	html: bool,
 53 | 	full: bool,
 54 | 	page: u16,
 55 | 	env: *aolium.Env,
 56 | 	user: aolium.User,
 57 | 	cache_key: [11]u8,
 58 | 	username: []const u8,
 59 | 	arena: std.mem.Allocator,
 60 | 
 61 | 	fn init(arena: Allocator, env: *aolium.Env, user: aolium.User, username: []const u8, page: u16, html: bool, atom: bool, full: bool) PostsFetcher {
 62 | 		// user_id + page + (html | atom | full)
 63 | 		// 8       + 2    + 1
 64 | 		var cache_key: [11]u8 = undefined;
 65 | 		@memcpy(cache_key[0..8], std.mem.asBytes(&user.id));
 66 | 		@memcpy(cache_key[8..10], std.mem.asBytes(&page));
 67 | 		cache_key[10] = if (html) 1 else 0;
 68 | 		cache_key[10] |= if (atom) 2 else 0;
 69 | 		cache_key[10] |= if (full) 4 else 0;
 70 | 
 71 | 		return .{
 72 | 			.env = env,
 73 | 			.user = user,
 74 | 			.page = page,
 75 | 			.html = html,
 76 | 			.atom = atom,
 77 | 			.full = full,
 78 | 			.arena = arena,
 79 | 			.username = username,
 80 | 			.cache_key = cache_key,
 81 | 		};
 82 | 	}
 83 | 
 84 | 	fn getPosts(self: *const PostsFetcher, _: []const u8) !?web.CachedResponse {
 85 | 		const env = self.env;
 86 | 		const atom = self.atom;
 87 | 		const shard_id = self.user.shard_id;
 88 | 
 89 | 		const app = env.app;
 90 | 		var sb = try app.buffers.acquire();
 91 | 		defer sb.release();
 92 | 
 93 | 		{
 94 | 			// this block exists so that conn is released ASAP, specifically avoiding
 95 | 			// the sb.copy
 96 | 
 97 | 			const conn = app.getDataConn(shard_id);
 98 | 			defer app.releaseDataConn(conn, shard_id);
 99 | 
100 | 			if (atom) {
101 | 				try self.generateAtom(conn, sb);
102 | 			} else {
103 | 				try self.generateJSON(conn, sb);
104 | 			}
105 | 		}
106 | 
107 | 		return .{
108 | 			.status = 200,
109 | 			.content_type = if (atom) .XML else .JSON,
110 | 			.body = try sb.copy(app.http_cache.allocator),
111 | 		};
112 | 	}
113 | 
114 | 	fn generateJSON(self: *const PostsFetcher, conn: zqlite.Conn, buf: *zul.StringBuilder) !void {
115 | 		const html = self.html;
116 | 		const user = self.user;
117 | 		const username = self.username;
118 | 
119 | 		const prefix = "{\"posts\":[\n";
120 | 		try buf.write(prefix);
121 | 		const writer = buf.writer();
122 | 
123 | 		const offset = (self.page - 1) * 20;
124 | 
125 | 		const sql =
126 | 			\\ select id, type, title, tags,
127 | 			\\   case
128 | 			\\     when ?3 or type != 'long' then text
129 | 			\\     else null
130 | 			\\   end as text,
131 | 			\\   comments, created, updated
132 | 			\\ from posts
133 | 			\\ where user_id = ?1
134 | 			\\ order by created desc, rowid
135 | 			\\ limit 21 offset ?2
136 | 		;
137 | 		var more = false;
138 | 		const args = .{user.id, offset, self.full};
139 | 		{
140 | 			var rows = conn.rows(sql, args) catch |err| {
141 | 				return aolium.sqliteErr("posts.select.json", err, conn, self.env.logger);
142 | 			};
143 | 			defer rows.deinit();
144 | 
145 | 			// It would be simpler to loop through rows, collecting "Posts" into an arraylist
146 | 			// and then using json.stringify(.{.posts = posts}). But if we did that, we'd
147 | 			// need to dupe the text values so that they outlive a single iteration of the
148 | 			// loop. Instead, we serialize each post immediately and glue everything together.
149 | 
150 | 			// TODO: can/should heavily optimzie this, namely by storing pre-generated
151 | 			// json and html blobs that we just glue together.
152 | 			var count: usize = 0;
153 | 			while (rows.next()) |row| {
154 | 				const tpe = row.text(1);
155 | 
156 | 				const text_value = posts.maybeRenderHTML(html, tpe, row, 4);
157 | 				defer text_value.deinit();
158 | 
159 | 				const id = try zul.UUID.binToHex(row.blob(0), .lower);
160 | 				var url_buf: [aolium.MAX_WEB_POST_URL]u8 = undefined;
161 | 
162 | 				try std.json.stringify(.{
163 | 					.id = id,
164 | 					.type = tpe,
165 | 					.title = row.nullableText(2),
166 | 					.text = text_value.value(),
167 | 					.tags = raw_json.init(row.nullableText(3)),
168 | 					.comment_count = row.int(5),
169 | 					.created = row.int(6),
170 | 					.updated = row.int(7),
171 | 					.web_url = try std.fmt.bufPrint(&url_buf, "https://www.aolium.com/{s}/{s}", .{username, id}),
172 | 				}, .{.emit_null_optional_fields = false}, writer);
173 | 
174 | 				try buf.write(",\n");
175 | 				count += 1;
176 | 				if (count == 20) {
177 | 					more = rows.next() != null;
178 | 					break;
179 | 				}
180 | 			}
181 | 
182 | 			if (rows.err) |err| {
183 | 				return aolium.sqliteErr("posts.select.json.rows", err, conn, self.env.logger);
184 | 			}
185 | 		}
186 | 
187 | 		// strip out the last comma and newline, if we wrote anything
188 | 		if (buf.len() > prefix.len) {
189 | 			buf.truncate(2);
190 | 		}
191 | 		try buf.write("],\n\"more\":");
192 | 		try buf.write(if (more) "true}" else "false}");
193 | 	}
194 | 
195 | 	fn generateAtom(self: *const PostsFetcher, conn: zqlite.Conn , buf: *zul.StringBuilder) !void {
196 | 		const user = self.user;
197 | 
198 | 		const sql =
199 | 			\\ select id, title, text, created, updated
200 | 			\\ from posts
201 | 			\\ where user_id = ?1
202 | 			\\ order by created desc
203 | 			\\ limit 20
204 | 		;
205 | 		const args = .{user.id};
206 | 
207 | 		{
208 | 			var rows = conn.rows(sql, args) catch |err| {
209 | 				return aolium.sqliteErr("posts.select.xml", err, conn, self.env.logger);
210 | 			};
211 | 			defer rows.deinit();
212 | 
213 | 			if (rows.err) |err| {
214 | 				return aolium.sqliteErr("posts.select.xml.rows", err, conn, self.env.logger);
215 | 			}
216 | 
217 | 			// We need the latest created time to generate the atom envelop, so we're
218 | 			// gonna have to get the first row first
219 | 			{
220 | 				const row = rows.next() orelse {
221 | 					try self.atomEnvelop(buf, DEFAULT_UPDATED_AT);
222 | 					try buf.write("");
223 | 					return;
224 | 				};
225 | 
226 | 				try self.atomEnvelop(buf, try zul.DateTime.fromUnix(row.int(4), .seconds));
227 | 				try self.atomEntry(buf, row);
228 | 			}
229 | 
230 | 			while (rows.next()) |row| {
231 | 				try self.atomEntry(buf, row);
232 | 			}
233 | 		}
234 | 
235 | 		try buf.write("");
236 | 	}
237 | 
238 | 	fn atomEnvelop(self: *const PostsFetcher, buf: *zul.StringBuilder, updated: zul.DateTime) !void {
239 | 		const username = self.username;
240 | 
241 | 		try std.fmt.format(buf.writer(),
242 | 			\\
243 | 			\\
244 | 			\\
245 | 			\\	{s} - aolium
246 | 			\\	
247 | 			\\	
248 | 			\\	{s}
249 | 			\\	https://www.aolium.com/{s}
250 | 			\\	{s}
251 | 			\\
252 | 		, .{username, username, username, updated, username, username});
253 | 	}
254 | 
255 | 	fn atomEntry(self: *const PostsFetcher, buf: *zul.StringBuilder, row: zqlite.Row) !void {
256 | 		const html = self.html;
257 | 
258 | 		const id = try zul.UUID.binToHex(row.blob(0), .lower);
259 | 		const created = try zul.DateTime.fromUnix(row.int(3), .seconds);
260 | 		const updated = try zul.DateTime.fromUnix(row.int(4), .seconds);
261 | 
262 | 		const content_type = if (html) "html" else "text";
263 | 		try buf.write("\t\n\t\t");
264 | 
265 | 		const title = if (row.nullableText(1)) |tt| tt else row.text(2);
266 | 		if (title.len < 80) {
267 | 			try writeEscapeXML(buf, title, false);
268 | 		} else {
269 | 			try writeEscapeXML(buf, title[0..80], false);
270 | 			try buf.write(" ...");
271 | 		}
272 | 
273 | 		try std.fmt.format(buf.writer(), \\
274 | 		\\		
275 | 		\\		{s}
276 | 		\\		{s}
277 | 		\\		https://www.aolium.com/{s}/{s}
278 | 		\\		
279 | 		, .{self.username, id, created, updated, self.username, id, content_type});
280 | 
281 | 		// don't pass a type, we want to render html even for a link
282 | 		const content = posts.maybeRenderHTML(html, "", row, 2);
283 | 		defer content.deinit();
284 | 		const content_value = std.mem.trim(u8, content.value().?, &std.ascii.whitespace);
285 | 
286 | 		try writeEscapeXML(buf, content_value, html);
287 | 		try buf.write("\n\t\n");
288 | 	}
289 | };
290 | 
291 | // our buffer likely has plenty of spare space, but we can't be sure. We'll use
292 | // the ensureUnusedCapacity function which lets us write using XAssumeCapacity
293 | // variants, which lets us avoid _a lot_of bound checking. But we aren't sure
294 | // how much capacity to reserve. Worst case is html.len * 5, if the html is
295 | // just a bunch of opening and closing tags. So we'll:
296 | //    ensureUnusedCapacity(html.len * 5)
297 | // but in small chunks of 1000.
298 | // Note: when the we're writing from the output of cmark, & is alreay escaped
299 | fn writeEscapeXML(buf: *zul.StringBuilder, content: []const u8, amp_escaped: bool) !void {
300 | 	var raw = content;
301 | 	while (raw.len > 0) {
302 | 		const chunk_size = if (raw.len > 1000) 1000 else raw.len;
303 | 		try buf.ensureUnusedCapacity(chunk_size * 4);
304 | 		for (raw[0..chunk_size]) |b| {
305 | 			switch (b) {
306 | 				'>' => buf.writeAssumeCapacity(">"),
307 | 				'<' => buf.writeAssumeCapacity("<"),
308 | 				'&' => if (amp_escaped) buf.writeByteAssumeCapacity(b) else buf.writeAssumeCapacity("&"),
309 | 				else => buf.writeByteAssumeCapacity(b),
310 | 			}
311 | 		}
312 | 		raw = raw[chunk_size..];
313 | 	}
314 | }
315 | 
316 | const t = aolium.testing;
317 | test "posts.index: missing username" {
318 | 	var tc = t.context(.{});
319 | 	defer tc.deinit();
320 | 	try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res));
321 | 	try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "username"});
322 | }
323 | 
324 | test "posts.index: unknown username" {
325 | 	var tc = t.context(.{});
326 | 	defer tc.deinit();
327 | 	tc.web.query("username", "unknown");
328 | 	try handler(tc.env(), tc.web.req, tc.web.res);
329 | 	try tc.web.expectStatus(404);
330 | 	try tc.web.expectJson(.{.desc = "username doesn't exist", .code = 3});
331 | }
332 | 
333 | test "posts.index: no posts json" {
334 | 	var tc = t.context(.{});
335 | 	defer tc.deinit();
336 | 
337 | 	_ = tc.insert.user(.{.username = "index_no_post"});
338 | 	tc.web.query("username", "index_no_post");
339 | 
340 | 	try handler(tc.env(), tc.web.req, tc.web.res);
341 | 	try tc.web.expectJson(.{.posts = .{}});
342 | }
343 | 
344 | test "posts.index: json list" {
345 | 	var tc = t.context(.{});
346 | 	defer tc.deinit();
347 | 
348 | 	const uid = tc.insert.user(.{.username = "index_post_list"});
349 | 	const p1 = tc.insert.post(.{.user_id = uid, .created = 10, .type = "simple", .text = "the spice must flow"});
350 | 	const p2 = tc.insert.post(.{.user_id = uid, .created = 15, .type = "link", .title = "t2", .text = "https://www.aolium.com", .tags = &.{"t2", "t3"}, .comments = 3});
351 | 	const p3 = tc.insert.post(.{.user_id = uid, .created = 12, .type = "long", .title = "t1", .text = "### c1\n\nhi\n\n"});
352 | 	_ = tc.insert.post(.{.created = 10});
353 | 
354 | 	// test the cache too
355 | 	for (0..2) |_| {
356 | 		{
357 | 			// raw output
358 | 			tc.reset();
359 | 			tc.web.query("html", "false");
360 | 			tc.web.query("username", "index_post_list");
361 | 
362 | 			try handler(tc.env(), tc.web.req, tc.web.res);
363 | 			try tc.web.expectJson(.{.posts = .{
364 | 				.{
365 | 					.id = p2,
366 | 					.type = "link",
367 | 					.title = "t2",
368 | 					.comment_count = 3,
369 | 					.tags = &.{"t2", "t3"},
370 | 					.text = "https://www.aolium.com",
371 | 				},
372 | 				.{
373 | 					.id = p3,
374 | 					.type = "long",
375 | 					.title = "t1",
376 | 					.tags = null,
377 | 					.comment_count = 0,
378 | 					.text = "### c1\n\nhi\n\n"
379 | 				},
380 | 				.{
381 | 					.id = p1,
382 | 					.type = "simple",
383 | 					.tags = null,
384 | 					.comment_count = 0,
385 | 					.text = "the spice must flow",
386 | 				}
387 | 			}});
388 | 		}
389 | 
390 | 		{
391 | 			// html output
392 | 			tc.reset();
393 | 			tc.web.query("username", "index_post_list");
394 | 
395 | 			try handler(tc.env(), tc.web.req, tc.web.res);
396 | 			try tc.web.expectJson(.{.posts = .{
397 | 				.{
398 | 					.id = p2,
399 | 					.type = "link",
400 | 					.title = "t2",
401 | 					.text = "https://www.aolium.com",
402 | 					.tags = &.{"t2", "t3"},
403 | 					.comment_count = 3,
404 | 					.web_url = try std.fmt.allocPrint(tc.arena, "https://www.aolium.com/index_post_list/{s}", .{p2}),
405 | 				},
406 | 				.{
407 | 					.id = p3,
408 | 					.type = "long",
409 | 					.title = "t1",
410 | 					.text = "

c1

\n

hi

\n", 411 | .tags = null, 412 | .comment_count = 0, 413 | .web_url = try std.fmt.allocPrint(tc.arena, "https://www.aolium.com/index_post_list/{s}", .{p3}), 414 | }, 415 | .{ 416 | .id = p1, 417 | .type = "simple", 418 | .tags = null, 419 | .comment_count = 0, 420 | .text = "

the spice must flow

\n", 421 | .web_url = try std.fmt.allocPrint(tc.arena, "https://www.aolium.com/index_post_list/{s}", .{p1}), 422 | } 423 | }}); 424 | } 425 | } 426 | } 427 | 428 | test "posts.index: no posts atom" { 429 | var tc = t.context(.{}); 430 | defer tc.deinit(); 431 | 432 | _ = tc.insert.user(.{.username = "index_no_post"}); 433 | tc.web.query("username", "index_no_post"); 434 | tc.web.query("atom", "true"); 435 | 436 | try handler(tc.env(), tc.web.req, tc.web.res); 437 | try tc.web.expectHeader("Content-Type", "application/xml"); 438 | try tc.web.expectBody( 439 | \\ 440 | \\ 441 | \\ 442 | \\ index_no_post - aolium 443 | \\ 444 | \\ 445 | \\ 2023-07-23T00:00:00Z 446 | \\ https://www.aolium.com/index_no_post 447 | \\ index_no_post 448 | \\ 449 | ); 450 | } 451 | 452 | test "posts.index: atom list" { 453 | var tc = t.context(.{}); 454 | defer tc.deinit(); 455 | 456 | const uid = tc.insert.user(.{.username = "index_post_atom"}); 457 | const p1 = tc.insert.post(.{.user_id = uid, .created = 1281323924, .updated = 1291323924, .type = "simple", .text = "the spice & must flow this is a really long text that won't fit as a title if it's large than 80 characters"}); 458 | const p2 = tc.insert.post(.{.user_id = uid, .created = 1670081558, .updated = 1690081558, .type = "link", .title = "t2", .text = "https://www.aolium.com"}); 459 | const p3 = tc.insert.post(.{.user_id = uid, .created = 1670081558, .updated = 1680081558, .type = "long", .title = "t1", .text = "### c1\n\nhi\n\n"}); 460 | _ = tc.insert.post(.{.created = 1890081558, .updated = 1890081558}); 461 | 462 | // test the cache too 463 | for (0..2) |_| { 464 | { 465 | // raw output 466 | tc.reset(); 467 | tc.web.query("username", "index_post_atom"); 468 | tc.web.query("html", "false"); 469 | tc.web.query("atom", "1"); 470 | 471 | try handler(tc.env(), tc.web.req, tc.web.res); 472 | try tc.web.expectHeader("Content-Type", "application/xml"); 473 | try tc.web.expectBody(try std.fmt.allocPrint(tc.arena, 474 | \\ 475 | \\ 476 | \\ 477 | \\ index_post_atom - aolium 478 | \\ 479 | \\ 480 | \\ 2023-07-23T03:05:58Z 481 | \\ https://www.aolium.com/index_post_atom 482 | \\ index_post_atom 483 | \\ 484 | \\ t2 485 | \\ 486 | \\ 2022-12-03T15:32:38Z 487 | \\ 2023-07-23T03:05:58Z 488 | \\ https://www.aolium.com/index_post_atom/{s} 489 | \\ https://www.aolium.com 490 | \\ 491 | \\ 492 | \\ t1 493 | \\ 494 | \\ 2022-12-03T15:32:38Z 495 | \\ 2023-03-29T09:19:18Z 496 | \\ https://www.aolium.com/index_post_atom/{s} 497 | \\ ### c1 498 | \\ 499 | \\hi 500 | \\ 501 | \\ 502 | \\ the spice & must flow this is a really long text that won't fit as a title if it ... 503 | \\ 504 | \\ 2010-08-09T03:18:44Z 505 | \\ 2010-12-02T21:05:24Z 506 | \\ https://www.aolium.com/index_post_atom/{s} 507 | \\ the spice & must flow this is a really long text that won't fit as a title if it's large than 80 characters 508 | \\ 509 | \\ 510 | , .{p2, p2, p3, p3, p1, p1})); 511 | } 512 | 513 | { 514 | // html output 515 | tc.reset(); 516 | tc.web.query("username", "index_post_atom"); 517 | tc.web.query("atom", "True"); 518 | 519 | try handler(tc.env(), tc.web.req, tc.web.res); 520 | try tc.web.expectBody(try std.fmt.allocPrint(tc.arena, 521 | \\ 522 | \\ 523 | \\ 524 | \\ index_post_atom - aolium 525 | \\ 526 | \\ 527 | \\ 2023-07-23T03:05:58Z 528 | \\ https://www.aolium.com/index_post_atom 529 | \\ index_post_atom 530 | \\ 531 | \\ t2 532 | \\ 533 | \\ 2022-12-03T15:32:38Z 534 | \\ 2023-07-23T03:05:58Z 535 | \\ https://www.aolium.com/index_post_atom/{s} 536 | \\ <p><a href="https://www.aolium.com">https://www.aolium.com</a></p> 537 | \\ 538 | \\ 539 | \\ t1 540 | \\ 541 | \\ 2022-12-03T15:32:38Z 542 | \\ 2023-03-29T09:19:18Z 543 | \\ https://www.aolium.com/index_post_atom/{s} 544 | \\ <h3>c1</h3> 545 | \\<p>hi</p> 546 | \\ 547 | \\ 548 | \\ the spice & must flow this is a really long text that won't fit as a title if it ... 549 | \\ 550 | \\ 2010-08-09T03:18:44Z 551 | \\ 2010-12-02T21:05:24Z 552 | \\ https://www.aolium.com/index_post_atom/{s} 553 | \\ <p>the spice & must flow this is a really long text that won't fit as a title if it's large than 80 characters</p> 554 | \\ 555 | \\ 556 | , .{p2, p2, p3, p3, p1, p1})); 557 | } 558 | } 559 | } 560 | -------------------------------------------------------------------------------- /src/web/posts/show.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zul = @import("zul"); 3 | const httpz = @import("httpz"); 4 | const validate = @import("validate"); 5 | const raw_json = @import("raw_json"); 6 | const posts = @import("_posts.zig"); 7 | 8 | const web = posts.web; 9 | const aolium = web.aolium; 10 | const Allocator = std.mem.Allocator; 11 | 12 | var show_validator: *validate.Object(void) = undefined; 13 | 14 | pub fn init(builder: *validate.Builder(void)) void { 15 | show_validator = builder.object(&.{ 16 | builder.field("username", builder.string(.{.required = true, .max = aolium.MAX_USERNAME_LEN, .trim = true})), 17 | builder.field("html", builder.boolean(.{.parse = true})), 18 | builder.field("comments", builder.boolean(.{.parse = true})), 19 | }, .{}); 20 | } 21 | 22 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void { 23 | const input = try web.validateQuery(req, show_validator, env); 24 | const post_id = try web.parseUUID("id", req.params.get("id").?, env); 25 | 26 | const app = env.app; 27 | const username = input.get( "username").?.string; 28 | const user = try app.getUserFromUsername(username) orelse { 29 | return web.notFound(res, "username doesn't exist"); 30 | }; 31 | 32 | const html = if (input.get("html")) |i| i.bool else false; 33 | const comments = if (input.get("comments")) |i| i.bool else false; 34 | const fetcher = PostFetcher.init(req.arena, env, post_id, user, html, comments); 35 | 36 | const cached_response = (try app.http_cache.fetch(*const PostFetcher, &fetcher.cache_key, getPost, &fetcher, .{.ttl = 300})) orelse { 37 | return web.notFound(res, "post not found"); 38 | }; 39 | defer cached_response.release(); 40 | res.header("Cache-Control", "public,max-age=30"); 41 | try cached_response.value.write(res); 42 | } 43 | 44 | const PostFetcher = struct { 45 | html: bool, 46 | comments: bool, 47 | post_id: zul.UUID, 48 | env: *aolium.Env, 49 | user: aolium.User, 50 | cache_key: [17]u8, 51 | arena: std.mem.Allocator, 52 | 53 | fn init(arena: Allocator, env: *aolium.Env, post_id: zul.UUID, user: aolium.User, html: bool, comments: bool) PostFetcher { 54 | // post_id + (html | comments) 55 | // 16 + 1 56 | var cache_key: [17]u8 = undefined; 57 | @memcpy(cache_key[0..16], &post_id.bin); 58 | cache_key[16] = if (html) 1 else 0; 59 | cache_key[16] |= if (comments) 2 else 0; 60 | 61 | return .{ 62 | .env = env, 63 | .user = user, 64 | .html = html, 65 | .arena = arena, 66 | .post_id = post_id, 67 | .comments = comments, 68 | .cache_key = cache_key, 69 | }; 70 | } 71 | }; 72 | 73 | fn getPost(fetcher: *const PostFetcher, _: []const u8) !?web.CachedResponse { 74 | const env = fetcher.env; 75 | const html = fetcher.html; 76 | const user = fetcher.user; 77 | const post_id = fetcher.post_id; 78 | 79 | const app = env.app; 80 | var sb = try app.buffers.acquire(); 81 | defer sb.release(); 82 | 83 | const prefix = "{\"post\":\n"; 84 | try sb.write(prefix); 85 | const writer = sb.writer(); 86 | 87 | { 88 | // this block exists so that conn is released ASAP 89 | 90 | const conn = app.getDataConn(user.shard_id); 91 | defer app.releaseDataConn(conn, user.shard_id); 92 | 93 | { 94 | const sql = 95 | \\ select type, title, text, tags, created, updated 96 | \\ from posts where id = ?1 and user_id = ?2 97 | ; 98 | var row = conn.row(sql, .{&post_id.bin, user.id}) catch |err| { 99 | return aolium.sqliteErr("posts.get", err, conn, env.logger); 100 | } orelse { 101 | return null; 102 | }; 103 | defer row.deinit(); 104 | 105 | const tpe = row.text(0); 106 | const text_value = posts.maybeRenderHTML(html, tpe, row, 2); 107 | defer text_value.deinit(); 108 | 109 | try std.json.stringify(.{ 110 | .id = post_id, 111 | .type = tpe, 112 | .title = row.nullableText(1), 113 | .text = text_value.value(), 114 | .tags = raw_json.init(row.nullableText(3)), 115 | .created = row.int(4), 116 | .updated = row.int(5), 117 | .user_id = user.id, 118 | }, .{.emit_null_optional_fields = false}, writer); 119 | } 120 | 121 | if (fetcher.comments) { 122 | try sb.write(",\n\"comments\":[\n"); 123 | 124 | { 125 | const sql = 126 | \\ select id, name, comment, created 127 | \\ from comments where post_id = ?1 and approved is not null 128 | \\ order by created 129 | ; 130 | var rows = conn.rows(sql, .{&post_id.bin}) catch |err| { 131 | return aolium.sqliteErr("posts.get.comments", err, conn, env.logger); 132 | }; 133 | defer rows.deinit(); 134 | 135 | var has_comments = false; 136 | while (rows.next()) |row| { 137 | const comment_value = posts.maybeRenderHTML(html, "comment", row, 2); 138 | defer comment_value.deinit(); 139 | 140 | try std.json.stringify(.{ 141 | .id = try zul.UUID.binToHex(row.blob(0), .lower), 142 | .name = row.text(1), 143 | .created = row.int(3), 144 | .comment = comment_value.value(), 145 | }, .{.emit_null_optional_fields = false}, writer); 146 | 147 | try sb.write(",\n"); 148 | has_comments = true; 149 | } 150 | 151 | if (rows.err) |err| { 152 | return aolium.sqliteErr("posts.get.comments.rows", err, conn, env.logger); 153 | } 154 | 155 | if (has_comments) { 156 | sb.truncate(2); 157 | } 158 | } 159 | 160 | try sb.write("\n]"); 161 | } 162 | } 163 | 164 | try sb.writeByte('}'); 165 | return .{ 166 | .status = 200, 167 | .content_type = .JSON, 168 | .body = try sb.copy(app.http_cache.allocator), 169 | }; 170 | } 171 | 172 | const t = aolium.testing; 173 | test "posts.show: missing username" { 174 | var tc = t.context(.{}); 175 | defer tc.deinit(); 176 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 177 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "username"}); 178 | } 179 | 180 | test "posts.show: invalid id" { 181 | var tc = t.context(.{}); 182 | defer tc.deinit(); 183 | 184 | tc.user(.{.id = 1}); 185 | tc.web.param("id", "nope"); 186 | tc.web.query("username", "unknown"); 187 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 188 | try tc.expectInvalid(.{.code = validate.codes.TYPE_UUID, .field = "id"}); 189 | } 190 | 191 | test "posts.show: unknown username" { 192 | var tc = t.context(.{}); 193 | defer tc.deinit(); 194 | 195 | tc.web.param("id", "bfddbd53-97ab-4531-9671-8bad4af425f4"); 196 | tc.web.query("username", "unknown"); 197 | try handler(tc.env(), tc.web.req, tc.web.res); 198 | try tc.web.expectStatus(404); 199 | try tc.web.expectJson(.{.desc = "username doesn't exist", .code = 3}); 200 | } 201 | 202 | test "posts.show: no posts" { 203 | var tc = t.context(.{}); 204 | defer tc.deinit(); 205 | 206 | _ = tc.insert.user(.{.username = "post_show_missing"}); 207 | tc.web.param("id", "b646aead-8f41-4c96-bdee-bff025b0816c"); 208 | tc.web.query("username", "post_show_missing"); 209 | 210 | try handler(tc.env(), tc.web.req, tc.web.res); 211 | try tc.web.expectStatus(404); 212 | } 213 | 214 | test "posts.show: show" { 215 | var tc = t.context(.{}); 216 | defer tc.deinit(); 217 | 218 | const uid = tc.insert.user(.{.username = "index_post_show"}); 219 | const p1 = tc.insert.post(.{.user_id = uid, .type = "simple", .text = "the spice must flow", .tags = &. {"tag1", "tag2"}}); 220 | const p2 = tc.insert.post(.{.user_id = uid, .type = "link", .title = "t2", .text = "https://www.aolium.dev"}); 221 | const p3 = tc.insert.post(.{.user_id = uid, .type = "long", .title = "t1", .text = "### c1\n\nhi\n\n"}); 222 | 223 | // test the cache too 224 | for (0..2) |_| { 225 | { 226 | tc.reset(); 227 | tc.web.param("id", p1); 228 | tc.web.query("username", "index_post_show"); 229 | 230 | try handler(tc.env(), tc.web.req, tc.web.res); 231 | try tc.web.expectJson(.{ 232 | .post = .{ 233 | .id = p1, 234 | .type = "simple", 235 | .text = "the spice must flow", 236 | .tags = .{"tag1", "tag2"}, 237 | } 238 | }); 239 | } 240 | 241 | { 242 | tc.reset(); 243 | tc.web.param("id", p2); 244 | tc.web.query("username", "index_post_show"); 245 | 246 | try handler(tc.env(), tc.web.req, tc.web.res); 247 | try tc.web.expectJson(.{ 248 | .post = .{ 249 | .id = p2, 250 | .type = "link", 251 | .title = "t2", .text = "https://www.aolium.dev", 252 | .tags = null 253 | } 254 | }); 255 | } 256 | 257 | { 258 | tc.reset(); 259 | tc.web.param("id", p3); 260 | tc.web.query("username", "index_post_show"); 261 | 262 | try handler(tc.env(), tc.web.req, tc.web.res); 263 | try tc.web.expectJson(.{ 264 | .post = .{ 265 | .id = p3, 266 | .type = "long", 267 | .title = "t1", 268 | .tags = null, 269 | .text = "### c1\n\nhi\n\n" 270 | } 271 | }); 272 | } 273 | 274 | // Now with html=true 275 | { 276 | tc.reset(); 277 | tc.web.param("id", p1); 278 | tc.web.query("username", "index_post_show"); 279 | tc.web.query("html", "true"); 280 | 281 | try handler(tc.env(), tc.web.req, tc.web.res); 282 | try tc.web.expectJson(.{.post = .{.id = p1, .type = "simple", .text = "

the spice must flow

\n"}}); 283 | } 284 | 285 | { 286 | tc.reset(); 287 | tc.web.param("id", p2); 288 | tc.web.query("username", "index_post_show"); 289 | tc.web.query("html", "true"); 290 | 291 | try handler(tc.env(), tc.web.req, tc.web.res); 292 | try tc.web.expectJson(.{.post = .{.id = p2, .type = "link", .title = "t2", .text = "https://www.aolium.dev"}}); 293 | } 294 | 295 | { 296 | tc.reset(); 297 | tc.web.param("id", p3); 298 | tc.web.query("username", "index_post_show"); 299 | tc.web.query("html", "true"); 300 | 301 | try handler(tc.env(), tc.web.req, tc.web.res); 302 | try tc.web.expectJson(.{.post = .{.id = p3, .type = "long", .title = "t1", .text = "

c1

\n

hi

\n"}}); 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/web/posts/update.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zul = @import("zul"); 3 | const httpz = @import("httpz"); 4 | const validate = @import("validate"); 5 | const posts = @import("_posts.zig"); 6 | 7 | const web = posts.web; 8 | const aolium = web.aolium; 9 | 10 | pub fn handler(env: *aolium.Env, req: *httpz.Request, res: *httpz.Response) !void { 11 | const input = try web.validateJson(req, posts.create_validator, env); 12 | const post_id = try web.parseUUID("id", req.params.get("id").?, env); 13 | 14 | const user = env.user.?; 15 | const post = try posts.Post.create(req.arena, input); 16 | 17 | const sql = 18 | \\ update posts 19 | \\ set title = ?3, text = ?4, type = ?5, tags = ?6, updated = unixepoch() 20 | \\ where id = ?1 and user_id = ?2 21 | ; 22 | 23 | const args = .{&post_id.bin, user.id, post.title, post.text, post.type, post.tags}; 24 | 25 | const app = env.app; 26 | 27 | { 28 | // we want conn released ASAP 29 | const conn = app.getDataConn(user.shard_id); 30 | defer app.releaseDataConn(conn, user.shard_id); 31 | 32 | conn.exec(sql, args) catch |err| { 33 | return aolium.sqliteErr("posts.update", err, conn, env.logger); 34 | }; 35 | 36 | if (conn.changes() == 0) { 37 | return web.notFound(res, "the post could not be found"); 38 | } 39 | } 40 | 41 | app.clearUserCache(user.id); 42 | app.clearPostCache(post_id); 43 | res.status = 204; 44 | } 45 | 46 | const t = aolium.testing; 47 | test "posts.update: empty body" { 48 | var tc = t.context(.{}); 49 | defer tc.deinit(); 50 | try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res)); 51 | } 52 | 53 | test "posts.update: invalid json body" { 54 | var tc = t.context(.{}); 55 | defer tc.deinit(); 56 | 57 | tc.web.body("{hi"); 58 | try t.expectError(error.InvalidJson, handler(tc.env(), tc.web.req, tc.web.res)); 59 | } 60 | 61 | test "posts.update: invalid input" { 62 | { 63 | var tc = t.context(.{}); 64 | defer tc.deinit(); 65 | 66 | tc.web.json(.{.x = 1, .tags = 32}); 67 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 68 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "type"}); 69 | try tc.expectInvalid(.{.code = validate.codes.TYPE_ARRAY, .field = "tags"}); 70 | } 71 | 72 | { 73 | var tc = t.context(.{}); 74 | defer tc.deinit(); 75 | 76 | tc.web.json(.{.type = "melange", .tags = .{"hi", 3}}); 77 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 78 | try tc.expectInvalid(.{.code = validate.codes.STRING_CHOICE, .field = "type"}); 79 | try tc.expectInvalid(.{.code = validate.codes.TYPE_STRING, .field = "tags.1"}); 80 | } 81 | 82 | { 83 | var tc = t.context(.{}); 84 | defer tc.deinit(); 85 | 86 | tc.web.json(.{.type = "simple", .tags = .{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a"}}); 87 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 88 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"}); 89 | try tc.expectInvalid(.{.code = validate.codes.ARRAY_LEN_MAX, .field = "tags", .data = .{.max = 10}}); 90 | } 91 | 92 | { 93 | var tc = t.context(.{}); 94 | defer tc.deinit(); 95 | 96 | tc.web.json(.{.type = "link"}); 97 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 98 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "title"}); 99 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"}); 100 | } 101 | 102 | { 103 | var tc = t.context(.{}); 104 | defer tc.deinit(); 105 | 106 | tc.web.json(.{.type = "long"}); 107 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 108 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "title"}); 109 | try tc.expectInvalid(.{.code = validate.codes.REQUIRED, .field = "text"}); 110 | } 111 | } 112 | test "posts.update: invalid id" { 113 | var tc = t.context(.{}); 114 | defer tc.deinit(); 115 | 116 | tc.user(.{.id = 1}); 117 | tc.web.param("id", "nope"); 118 | tc.web.json(.{.type = "simple", .text = "a"}); 119 | try t.expectError(error.Validation, handler(tc.env(), tc.web.req, tc.web.res)); 120 | try tc.expectInvalid(.{.code = validate.codes.TYPE_UUID, .field = "id"}); 121 | } 122 | 123 | test "posts.update: unknown id" { 124 | var tc = t.context(.{}); 125 | defer tc.deinit(); 126 | 127 | tc.user(.{.id = 1}); 128 | tc.web.param("id", "4b0548fc-7127-438d-a87e-bc283f2d5981"); 129 | tc.web.json(.{.type = "simple", .text = "hello world!!"}); 130 | try handler(tc.env(), tc.web.req, tc.web.res); 131 | try tc.web.expectStatus(404); 132 | } 133 | 134 | test "posts.update: post belongs to a different user" { 135 | var tc = t.context(.{}); 136 | defer tc.deinit(); 137 | 138 | tc.user(.{.id = 1}); 139 | const id = tc.insert.post(.{.user_id = 4, .text = "hack-proof", .updated = 52, .created = 50}); 140 | 141 | tc.web.param("id", id); 142 | tc.web.json(.{.type = "simple", .text = "hello world!!"}); 143 | try handler(tc.env(), tc.web.req, tc.web.res); 144 | try tc.web.expectStatus(404); 145 | 146 | const row = tc.getDataRow("select * from posts where id = ?1", .{(try zul.UUID.parse(id)).bin}).?; 147 | try t.expectEqual(4, row.get("user_id").?.i64); 148 | try t.expectString("simple", row.get("type").?.string); 149 | try t.expectString("hack-proof", row.get("text").?.string); 150 | try t.expectEqual(true, row.get("title").?.isNull()); 151 | try t.expectEqual(50, row.get("created").?.i64); 152 | try t.expectEqual(52, row.get("updated").?.i64); 153 | } 154 | 155 | test "posts.update: simple" { 156 | var tc = t.context(.{}); 157 | defer tc.deinit(); 158 | 159 | tc.user(.{.id = 3913}); 160 | const id = tc.insert.post(.{.user_id = 3913, .updated = 1000, .created = 33}); 161 | 162 | tc.web.param("id", id); 163 | tc.web.json(.{.type = "simple", .text = "hello world!!"}); 164 | try handler(tc.env(), tc.web.req, tc.web.res); 165 | try tc.web.expectStatus(204); 166 | 167 | const row = tc.getDataRow("select * from posts where id = ?1", .{(try zul.UUID.parse(id)).bin}).?; 168 | try t.expectEqual(3913, row.get("user_id").?.i64); 169 | try t.expectString("simple", row.get("type").?.string); 170 | try t.expectString("hello world!!", row.get("text").?.string); 171 | try t.expectEqual(true, row.get("title").?.isNull()); 172 | try t.expectEqual(33, row.get("created").?.i64); 173 | try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2); 174 | } 175 | 176 | test "posts.update: link" { 177 | var tc = t.context(.{}); 178 | defer tc.deinit(); 179 | 180 | tc.user(.{.id = 3914}); 181 | const id = tc.insert.post(.{.user_id = 3914, .updated = 1500, .created = 222}); 182 | 183 | tc.web.param("id", id); 184 | tc.web.json(.{.type = "link", .title = "FFmpeg - The Ultimate Guide", .text = "img.ly/blog/ultimate-guide-to-ffmpeg/"}); 185 | try handler(tc.env(), tc.web.req, tc.web.res); 186 | 187 | const row = tc.getDataRow("select * from posts where id = ?1", .{(try zul.UUID.parse(id)).bin}).?; 188 | try t.expectEqual(3914, row.get("user_id").?.i64); 189 | try t.expectString("link", row.get("type").?.string); 190 | try t.expectString("https://img.ly/blog/ultimate-guide-to-ffmpeg/", row.get("text").?.string); 191 | try t.expectString("FFmpeg - The Ultimate Guide", row.get("title").?.string); 192 | try t.expectEqual(222, row.get("created").?.i64); 193 | try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2); 194 | } 195 | 196 | test "posts.update: long" { 197 | var tc = t.context(.{}); 198 | defer tc.deinit(); 199 | 200 | tc.user(.{.id = 441}); 201 | const id = tc.insert.post(.{.user_id = 441, .updated = -500, .created = 503}); 202 | 203 | tc.web.param("id", id); 204 | tc.web.json(.{.type = "long", .title = "A Title!", .text = "Some !content\nOk", .tags = .{"t1", "soup"}}); 205 | try handler(tc.env(), tc.web.req, tc.web.res); 206 | 207 | const row = tc.getDataRow("select * from posts where id = ?1", .{(try zul.UUID.parse(id)).bin}).?; 208 | try t.expectEqual(441, row.get("user_id").?.i64); 209 | try t.expectString("long", row.get("type").?.string); 210 | try t.expectString("Some !content\nOk", row.get("text").?.string); 211 | try t.expectString("A Title!", row.get("title").?.string); 212 | try t.expectString("[\"t1\",\"soup\"]", row.get("tags").?.string); 213 | try t.expectEqual(503, row.get("created").?.i64); 214 | try t.expectDelta(std.time.timestamp(), row.get("updated").?.i64, 2); 215 | } 216 | -------------------------------------------------------------------------------- /src/web/web.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zul = @import("zul"); 3 | const logz = @import("logz"); 4 | const httpz = @import("httpz"); 5 | const typed = @import("typed"); 6 | const validate = @import("validate"); 7 | pub const aolium = @import("../aolium.zig"); 8 | 9 | const App = aolium.App; 10 | const Env = aolium.Env; 11 | const Allocator = std.mem.Allocator; 12 | const Dispatcher = @import("dispatcher.zig").Dispatcher; 13 | 14 | // handlers 15 | const auth = @import("auth/_auth.zig"); 16 | const misc = @import("misc/_misc.zig"); 17 | const posts = @import("posts/_posts.zig"); 18 | const comments = @import("comments/_comments.zig"); 19 | 20 | pub fn start(app: *App) !void { 21 | const config = app.config; 22 | const allocator = app.allocator; 23 | 24 | var server = try httpz.ServerCtx(*const Dispatcher, *Env).init(allocator, .{ 25 | .cors = config.cors, 26 | .port = config.port, 27 | .address = config.address, 28 | }, undefined); 29 | server.notFound(routerNotFound); 30 | server.errorHandler(errorHandler); 31 | server.dispatcher(Dispatcher.dispatch); 32 | 33 | const router = server.router(); 34 | { 35 | // publicly accessible API endpoints 36 | var routes = router.group("/api/1/", .{.ctx = &Dispatcher{ 37 | .app = app, 38 | .requires_user = false, 39 | .log_http = config.log_http, 40 | }}); 41 | 42 | routes.post("/auth/login", auth.login); 43 | routes.post("/auth/register", auth.register); 44 | routes.get("/posts", posts.index); 45 | routes.get("/posts/:id", posts.show); 46 | routes.post("/posts/:id/comments", comments.create); 47 | routes.get("/ping", misc.ping); 48 | routes.get("/comments/count", comments.count); 49 | } 50 | 51 | { 52 | // technically, logout should require a logged in user, but it's easier 53 | // for clients if we special-case is so that they don't have to deal with 54 | // a 401 on invalid or expired tokens. 55 | var routes = router.group("/api/1/", .{.ctx = &Dispatcher{ 56 | .app = app, 57 | .load_user = false, 58 | .log_http = config.log_http, 59 | }}); 60 | 61 | routes.get("/auth/logout", auth.logout); 62 | } 63 | 64 | { 65 | // routes that require a logged in user 66 | var routes = router.group("/api/1/", .{.ctx = &Dispatcher{ 67 | .app = app, 68 | .requires_user = true, 69 | .log_http = config.log_http, 70 | }}); 71 | routes.head("/auth/check", auth.check); 72 | routes.post("/posts", posts.create); 73 | routes.post("/posts/:id", posts.update); 74 | routes.post("/posts/:id", posts.update); 75 | routes.get("/comments/:id/delete", comments.delete); 76 | routes.get("/comments/:id/approve", comments.approve); 77 | routes.get("/comments", comments.index); 78 | } 79 | 80 | const http_address = try std.fmt.allocPrint(allocator, "http://{s}:{d}", .{config.address, config.port}); 81 | logz.info().ctx("http").string("address", http_address).log(); 82 | allocator.free(http_address); 83 | 84 | // blocks 85 | defer server.deinit(); 86 | try server.listen(); 87 | } 88 | 89 | // Since our dispatcher handles action errors, this should not happen unless 90 | // the dispatcher itself, or the underlying http framework, fails. 91 | fn errorHandler(_: *const Dispatcher, req: *httpz.Request, res: *httpz.Response, err: anyerror) void { 92 | const code = errors.ServerError.write(res); 93 | logz.err().err(err).ctx("errorHandler").string("path", req.url.raw).int("code", code).log(); 94 | } 95 | 96 | // Not found specifically related to the method/path, this is passed to our 97 | // http framework as a fallback. 98 | fn routerNotFound(_: *const Dispatcher, _: *httpz.Request, res: *httpz.Response) !void { 99 | _ = errors.RouterNotFound.write(res); 100 | } 101 | 102 | // An application-level 404, e.g. a call to DELETE /blah/:id and the :id wasn't 103 | // found. Always nice to include a brief description of exactly what wasn't found 104 | // to help developers. 105 | pub fn notFound(res: *httpz.Response, desc: []const u8) !void { 106 | res.status = 404; 107 | return res.json(.{ 108 | .desc = desc, 109 | .err = "not found", 110 | .code = aolium.codes.NOT_FOUND, 111 | }, .{}); 112 | } 113 | 114 | pub fn validateJson(req: *httpz.Request, v: *validate.Object(void), env: *Env) !typed.Map { 115 | const body = req.body() orelse return error.InvalidJson; 116 | var validator = try env.validator(); 117 | const input = try v.validateJsonS(body, validator); 118 | if (!validator.isValid()) { 119 | return error.Validation; 120 | } 121 | return input; 122 | } 123 | 124 | // This isn't great, but we turn out querystring args into a typed.Map where every 125 | // value is a typed.Value.string. Validators can be configured to parse strings. 126 | pub fn validateQuery(req: *httpz.Request, v: *validate.Object(void), env: *Env) !typed.Map { 127 | const q = try req.query(); 128 | 129 | var map = typed.Map.init(req.arena); 130 | try map.ensureTotalCapacity(@intCast(q.len)); 131 | 132 | for (q.keys[0..q.len], q.values[0..q.len]) |name, value| { 133 | try map.putAssumeCapacity(name, value); 134 | } 135 | 136 | var validator = try env.validator(); 137 | const input = try v.validate(map, validator); 138 | if (!validator.isValid()) { 139 | return error.Validation; 140 | } 141 | return input orelse typed.Map.readonlyEmpty(); 142 | } 143 | 144 | pub fn parseUUID(field: []const u8, raw: []const u8, env: *Env) !zul.UUID { 145 | return zul.UUID.parse(raw) catch { 146 | (try env.validator()).addInvalidField(.{ 147 | .field = field, 148 | .err = "is not valid", 149 | .code = validate.codes.TYPE_UUID, 150 | }); 151 | return error.Validation; 152 | }; 153 | } 154 | 155 | pub fn getSessionId(req: *httpz.Request) ?[]const u8 { 156 | const header = req.header("authorization") orelse return null; 157 | if (header.len < 11 or std.mem.startsWith(u8, header, "aolium ") == false) return null; 158 | return header[7..]; 159 | } 160 | 161 | // pre-generated error messages 162 | pub const Error = struct { 163 | code: i32, 164 | status: u16, 165 | body: []const u8, 166 | 167 | fn init(status: u16, comptime code: i32, comptime message: []const u8) Error { 168 | const body = std.fmt.comptimePrint("{{\"code\": {d}, \"err\": \"{s}\"}}", .{code, message}); 169 | return .{ 170 | .code = code, 171 | .body = body, 172 | .status = status, 173 | }; 174 | } 175 | 176 | pub fn write(self: Error, res: *httpz.Response) i32 { 177 | res.status = self.status; 178 | res.content_type = httpz.ContentType.JSON; 179 | res.body = self.body; 180 | return self.code; 181 | } 182 | }; 183 | 184 | // bunch of static errors that we can serialize at comptime 185 | pub const errors = struct { 186 | const codes = aolium.codes; 187 | pub const ServerError = Error.init(500, codes.INTERNAL_SERVER_ERROR_UNCAUGHT, "internal server error"); 188 | pub const RouterNotFound = Error.init(404, codes.ROUTER_NOT_FOUND, "not found"); 189 | pub const InvalidJson = Error.init(400, codes.INVALID_JSON, "invalid JSON"); 190 | pub const AccessDenied = Error.init(401, codes.ACCESS_DENIED, "access denied"); 191 | }; 192 | 193 | pub const CachedResponse = struct { 194 | status: u16, 195 | body: []const u8, 196 | content_type: httpz.ContentType, 197 | 198 | pub fn removedFromCache(self: CachedResponse, allocator: Allocator) void { 199 | allocator.free(self.body); 200 | } 201 | 202 | pub fn write(self: CachedResponse, res: *httpz.Response) !void { 203 | res.status = self.status; 204 | res.content_type = self.content_type; 205 | res.body = self.body; 206 | 207 | // It's important that we explicitly write out the response, because our 208 | // cached entry is only guaranteed to be valid until it's released, which 209 | // happens before httpz gets back control. 210 | return res.write(); 211 | } 212 | 213 | // used by cache library 214 | pub fn size(self: CachedResponse) u32 { 215 | return @intCast(self.body.len); 216 | } 217 | }; 218 | 219 | const t = aolium.testing; 220 | test "web: Error.write" { 221 | var tc = t.context(.{}); 222 | defer tc.deinit(); 223 | 224 | try t.expectEqual(0, errors.ServerError.write(tc.web.res)); 225 | try tc.web.expectStatus(500); 226 | try tc.web.expectJson(.{.code = 0, .err = "internal server error"}); 227 | } 228 | 229 | test "web: notFound" { 230 | var tc = t.context(.{}); 231 | defer tc.deinit(); 232 | 233 | try notFound(tc.web.res, "no spice"); 234 | try tc.web.expectStatus(404); 235 | try tc.web.expectJson(.{.code = 3, .err = "not found", .desc = "no spice"}); 236 | } 237 | 238 | test "web: getSessionID" { 239 | var tc = t.context(.{}); 240 | defer tc.deinit(); 241 | 242 | try t.expectEqual(null, getSessionId(tc.web.req)); 243 | } 244 | 245 | test "web: CachedResponse.write" { 246 | var wt = t.web.init(.{}); 247 | defer wt.deinit(); 248 | 249 | const cr = CachedResponse{ 250 | .status = 123, 251 | .content_type = .ICO, 252 | .body = "some content" 253 | }; 254 | 255 | try cr.write(wt.res); 256 | try wt.expectStatus(123); 257 | try wt.expectBody("some content"); 258 | try wt.expectHeader("Content-Type", "image/vnd.microsoft.icon"); 259 | } 260 | -------------------------------------------------------------------------------- /test_runner.zig: -------------------------------------------------------------------------------- 1 | // in your build.zig, you can specify a custom test runner: 2 | // const tests = b.addTest(.{ 3 | // .target = target, 4 | // .optimize = optimize, 5 | // .test_runner = "test_runner.zig", // add this line 6 | // .root_source_file = .{ .path = "src/main.zig" }, 7 | // }); 8 | 9 | const std = @import("std"); 10 | const builtin = @import("builtin"); 11 | 12 | const Allocator = std.mem.Allocator; 13 | 14 | const BORDER = "=" ** 80; 15 | 16 | // use in custom panic handler 17 | var current_test: ?[]const u8 = null; 18 | 19 | pub fn main() !void { 20 | var mem: [4096]u8 = undefined; 21 | var fba = std.heap.FixedBufferAllocator.init(&mem); 22 | 23 | const allocator = fba.allocator(); 24 | 25 | const env = Env.init(allocator); 26 | defer env.deinit(allocator); 27 | 28 | var slowest = SlowTracker.init(allocator, 5); 29 | defer slowest.deinit(); 30 | 31 | var pass: usize = 0; 32 | var fail: usize = 0; 33 | var skip: usize = 0; 34 | var leak: usize = 0; 35 | 36 | const printer = Printer.init(); 37 | printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line 38 | 39 | for (builtin.test_functions) |t| { 40 | std.testing.allocator_instance = .{}; 41 | var status = Status.pass; 42 | slowest.startTiming(); 43 | 44 | const is_unnamed_test = std.mem.endsWith(u8, t.name, ".test_0"); 45 | if (env.filter) |f| { 46 | if (!is_unnamed_test and std.mem.indexOf(u8, t.name, f) == null) { 47 | continue; 48 | } 49 | } 50 | 51 | const friendly_name = blk: { 52 | const name = t.name; 53 | var it = std.mem.splitScalar(u8, name, '.'); 54 | while (it.next()) |value| { 55 | if (std.mem.eql(u8, value, "test")) { 56 | const rest = it.rest(); 57 | break :blk if (rest.len > 0) rest else name; 58 | } 59 | } 60 | break :blk name; 61 | }; 62 | 63 | current_test = friendly_name; 64 | const result = t.func(); 65 | current_test = null; 66 | 67 | if (is_unnamed_test) { 68 | continue; 69 | } 70 | 71 | const ns_taken = slowest.endTiming(friendly_name); 72 | 73 | if (std.testing.allocator_instance.deinit() == .leak) { 74 | leak += 1; 75 | printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{BORDER, friendly_name, BORDER}); 76 | } 77 | 78 | if (result) |_| { 79 | pass += 1; 80 | } else |err| switch (err) { 81 | error.SkipZigTest => { 82 | skip += 1; 83 | status = .skip; 84 | }, 85 | else => { 86 | status = .fail; 87 | fail += 1; 88 | printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{BORDER, friendly_name, @errorName(err), BORDER}); 89 | if (@errorReturnTrace()) |trace| { 90 | std.debug.dumpStackTrace(trace.*); 91 | } 92 | if (env.fail_first) { 93 | break; 94 | } 95 | } 96 | } 97 | 98 | if (env.verbose) { 99 | const ms = @as(f64, @floatFromInt(ns_taken)) / 100_000.0; 100 | printer.status(status, "{s} ({d:.2}ms)\n", .{friendly_name, ms}); 101 | } else { 102 | printer.status(status, ".", .{}); 103 | } 104 | } 105 | 106 | const total_tests = pass + fail; 107 | const status = if (fail == 0) Status.pass else Status.fail; 108 | printer.status(status, "\n{d} of {d} test{s} passed\n", .{pass, total_tests, if (total_tests != 1) "s" else ""}); 109 | if (skip > 0) { 110 | printer.status(.skip, "{d} test{s} skipped\n", .{skip, if (skip != 1) "s" else ""}); 111 | } 112 | if (leak > 0) { 113 | printer.status(.fail, "{d} test{s} leaked\n", .{leak, if (leak != 1) "s" else ""}); 114 | } 115 | printer.fmt("\n", .{}); 116 | try slowest.display(printer); 117 | printer.fmt("\n", .{}); 118 | std.posix.exit(if (fail == 0) 0 else 1); 119 | } 120 | 121 | const Printer = struct { 122 | out: std.fs.File.Writer, 123 | 124 | fn init() Printer { 125 | return .{ 126 | .out = std.io.getStdErr().writer(), 127 | }; 128 | } 129 | 130 | fn fmt(self: Printer, comptime format: []const u8, args: anytype) void { 131 | std.fmt.format(self.out, format, args) catch unreachable; 132 | } 133 | 134 | fn status(self: Printer, s: Status, comptime format: []const u8, args: anytype) void { 135 | const color = switch (s) { 136 | .pass => "\x1b[32m", 137 | .fail => "\x1b[31m", 138 | .skip => "\x1b[33m", 139 | else => "", 140 | }; 141 | const out = self.out; 142 | out.writeAll(color) catch @panic("writeAll failed?!"); 143 | std.fmt.format(out, format, args) catch @panic("std.fmt.format failed?!"); 144 | self.fmt("\x1b[0m", .{}); 145 | } 146 | }; 147 | 148 | const Status = enum { 149 | pass, 150 | fail, 151 | skip, 152 | text, 153 | }; 154 | 155 | const SlowTracker = struct { 156 | const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); 157 | max: usize, 158 | slowest: SlowestQueue, 159 | timer: std.time.Timer, 160 | 161 | fn init(allocator: Allocator, count: u32) SlowTracker { 162 | const timer = std.time.Timer.start() catch @panic("failed to start timer"); 163 | var slowest = SlowestQueue.init(allocator, {}); 164 | slowest.ensureTotalCapacity(count) catch @panic("OOM"); 165 | return .{ 166 | .max = count, 167 | .timer = timer, 168 | .slowest = slowest, 169 | }; 170 | } 171 | 172 | const TestInfo = struct { 173 | ns: u64, 174 | name: []const u8, 175 | }; 176 | 177 | fn deinit(self: SlowTracker) void { 178 | self.slowest.deinit(); 179 | } 180 | 181 | fn startTiming(self: *SlowTracker) void { 182 | self.timer.reset(); 183 | } 184 | 185 | fn endTiming(self: *SlowTracker, test_name: []const u8) u64 { 186 | var timer = self.timer; 187 | const ns = timer.lap(); 188 | 189 | var slowest = &self.slowest; 190 | 191 | if (slowest.count() < self.max) { 192 | // Capacity is fixed to the # of slow tests we want to track 193 | // If we've tracked fewer tests than this capacity, than always add 194 | slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); 195 | return ns; 196 | } 197 | 198 | { 199 | // Optimization to avoid shifting the dequeue for the common case 200 | // where the test isn't one of our slowest. 201 | const fastest_of_the_slow = slowest.peekMin() orelse unreachable; 202 | if (fastest_of_the_slow.ns > ns) { 203 | // the test was faster than our fastest slow test, don't add 204 | return ns; 205 | } 206 | } 207 | 208 | // the previous fastest of our slow tests, has been pushed off. 209 | _ = slowest.removeMin(); 210 | slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); 211 | return ns; 212 | } 213 | 214 | fn display(self: *SlowTracker, printer: Printer) !void { 215 | var slowest = self.slowest; 216 | const count = slowest.count(); 217 | printer.fmt("Slowest {d} test{s}: \n", .{count, if (count != 1) "s" else ""}); 218 | while (slowest.removeMinOrNull()) |info| { 219 | const ms = @as(f64, @floatFromInt(info.ns)) / 100_000.0; 220 | printer.fmt(" {d:.2}ms\t{s}\n", .{ms, info.name}); 221 | } 222 | } 223 | 224 | fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order { 225 | _ = context; 226 | return std.math.order(a.ns, b.ns); 227 | } 228 | }; 229 | 230 | const Env = struct { 231 | verbose: bool, 232 | fail_first: bool, 233 | filter: ?[]const u8, 234 | 235 | fn init(allocator: Allocator) Env { 236 | return .{ 237 | .verbose = readEnvBool(allocator, "TEST_VERBOSE", true), 238 | .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), 239 | .filter = readEnv(allocator, "TEST_FILTER"), 240 | }; 241 | } 242 | 243 | fn deinit(self: Env, allocator: Allocator) void { 244 | if (self.filter) |f| { 245 | allocator.free(f); 246 | } 247 | } 248 | 249 | fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { 250 | const v = std.process.getEnvVarOwned(allocator, key) catch |err| { 251 | if (err == error.EnvironmentVariableNotFound) { 252 | return null; 253 | } 254 | std.log.warn("failed to get env var {s} due to err {}", .{key, err}); 255 | return null; 256 | }; 257 | return v; 258 | } 259 | 260 | fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { 261 | const value = readEnv(allocator, key) orelse return deflt; 262 | defer allocator.free(value); 263 | return std.ascii.eqlIgnoreCase(value, "true"); 264 | } 265 | }; 266 | 267 | pub fn panic(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { 268 | if (current_test) |ct| { 269 | std.debug.print("\x1b[31m{s}\npanic running \"{s}\"\n{s}\x1b[0m\n", .{BORDER, ct, BORDER}); 270 | } 271 | std.builtin.default_panic(msg, error_return_trace, ret_addr); 272 | } 273 | --------------------------------------------------------------------------------