├── INSTALL ├── README ├── Static ├── archive.png ├── audio.png ├── epub.png ├── favicon.png ├── logo.png ├── logo.svg ├── nanochan dark.css ├── nanochan.css ├── photon.css ├── picochan.css ├── spoiler.png ├── style.css └── txt.png ├── dbmaint.sql ├── lib ├── argon2.lua ├── cgi.lua ├── ioext.lua ├── openbsd.lua ├── random.lua ├── sha.lua ├── sqlite3.lua └── stringext.lua ├── pico.cgi ├── pico.lua ├── picochan.sql └── picoengine.lua /INSTALL: -------------------------------------------------------------------------------- 1 | DEPENDENCY BUILD CONFIGURATION HOMEPAGE 2 | --------------------------------------------------------------------------------------- 3 | LuaJIT https://luajit.org/ 4 | SQLite https://sqlite.org/ 5 | Argon2 https://github.com/P-H-C/phc-winner-argon2 6 | OpenSSL https://www.openssl.org/ 7 | FFmpeg mp3 opus vorbis theora https://ffmpeg.org/ 8 | vpx x264 x265 9 | ImageMagick jpeg png webp svg zlib https://imagemagick.org/ 10 | 11 | One of the following dependencies must be installed to provide the Courier font 12 | which is required for captcha generation. 13 | 14 | DEPENDENCY HOMEPAGE 15 | --------------------------------------------------- 16 | corefonts http://corefonts.sourceforge.net/ 17 | ghostscript https://ghostscript.com/ 18 | 19 | Note that Picochan uses the functions os.execute() and io.popen(), which make 20 | use of /bin/sh to execute commands. It is recommended to use dash or ksh as the 21 | default system shell. 22 | 23 | Create the database: 24 | 25 | $ sqlite3 picochan.db 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | C 12 | H 13 | A 14 | N 15 | 16 | 17 | P 18 | I 19 | C 20 | O 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Static/nanochan dark.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #222; 3 | color: #FFF; 4 | } 5 | 6 | a, a:visited { 7 | color: #03A9F4; 8 | } 9 | 10 | nav { 11 | background-color: #333; 12 | border-bottom-color: #777; 13 | } 14 | 15 | .header, .catalog-thread-subject, .post-subject, .redtext { 16 | color: #F44336; 17 | } 18 | 19 | .subheader, .catalog-thread-lastbumpdate, .post-date { 20 | color: #FFC107; 21 | } 22 | 23 | .container, .post, .catalog-thread, #postform { 24 | background-color: #333; 25 | border-color: #777; 26 | } 27 | 28 | .barheader, tr:nth-child(odd) { 29 | background-color: #444; 30 | } 31 | 32 | th { 33 | background-color: #222; 34 | } 35 | 36 | hr { 37 | border-bottom-color: #777; 38 | } 39 | 40 | form input, form input[type=file]::file-selector-button, form select, form textarea { 41 | background-color: #444; 42 | color: #FFF; 43 | border-color: #777; 44 | } 45 | 46 | .catalog-thread { 47 | scrollbar-color: #777 #333; 48 | } 49 | 50 | .post-container:target .post { 51 | border: 1px solid #03A9F4 !important; 52 | } 53 | 54 | .post-container:target .thread { 55 | background-color: #333 !important; 56 | } 57 | 58 | .post-name { 59 | color: #8BC34A; 60 | } 61 | 62 | .post-capcode { 63 | color: #BA68C8; 64 | } 65 | 66 | .greentext { 67 | color: #4CAF50; 68 | } 69 | 70 | .pinktext { 71 | color: #EC407A; 72 | } 73 | 74 | .kiketext { 75 | background-color: #FFF; 76 | color: #33D; 77 | } 78 | 79 | .spoiler { 80 | background-color: #000; 81 | color: #000; 82 | } 83 | 84 | .spoiler:hover { 85 | color: #FFF; 86 | } 87 | 88 | code, .code { 89 | background-color: #222; 90 | } 91 | 92 | code { 93 | border-color: #777; 94 | } 95 | 96 | .code { 97 | box-shadow: inset 0 0 0 1px #777; 98 | } 99 | -------------------------------------------------------------------------------- /Static/nanochan.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #DFE; 3 | color: #000; 4 | } 5 | 6 | a, a:visited { 7 | color: #05A; 8 | } 9 | 10 | nav { 11 | background-color: #CED; 12 | border-bottom-color: #000; 13 | } 14 | 15 | .header { 16 | color: #C22; 17 | } 18 | 19 | .subheader { 20 | color: #800; 21 | } 22 | 23 | .container { 24 | background-color: #CED; 25 | border-color: #000; 26 | } 27 | 28 | .barheader, th, form label { 29 | background-color: #ACB; 30 | } 31 | 32 | tr:nth-child(odd) { 33 | background-color: #BDC; 34 | } 35 | 36 | hr { 37 | border-bottom-color: #000; 38 | } 39 | 40 | form input, form input[type=file]::file-selector-button, form select, form textarea { 41 | border-color: #000; 42 | } 43 | 44 | .post, .catalog-thread, #postform { 45 | background-color: #BDC; 46 | border-color: #000; 47 | } 48 | 49 | .catalog-thread { 50 | scrollbar-color: #9BA #ACB; 51 | } 52 | 53 | .post-container:target .post { 54 | background-color: #9BA !important; 55 | } 56 | 57 | .catalog-thread-subject, .post-subject { 58 | color: #016; 59 | } 60 | 61 | .post-name, .greentext { 62 | color: #070; 63 | } 64 | 65 | .post-capcode { 66 | color: #F22; 67 | } 68 | 69 | .post-number a { 70 | color: #000; 71 | } 72 | 73 | .post-number a:hover { 74 | color: #B11; 75 | } 76 | 77 | .post-comment a, .catalog-thread-comment a { 78 | color: #C00; 79 | } 80 | 81 | .pinktext { 82 | color: #A66; 83 | } 84 | 85 | .kiketext { 86 | background-color: #FFF; 87 | color: #33D; 88 | } 89 | 90 | .redtext { 91 | color: #E33; 92 | } 93 | 94 | .spoiler { 95 | background-color: #000; 96 | color: #000; 97 | } 98 | 99 | .spoiler:hover { 100 | color: #FFF; 101 | } 102 | 103 | code, .code { 104 | background-color: #9BA; 105 | } 106 | 107 | code { 108 | border-color: #000; 109 | } 110 | 111 | .code { 112 | box-shadow: inset 0 0 0 1px #000; 113 | } 114 | -------------------------------------------------------------------------------- /Static/photon.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #EEE; 3 | color: #333; 4 | } 5 | 6 | a, a:visited { 7 | color: #666; 8 | } 9 | 10 | nav { 11 | background-color: #98E; 12 | border-bottom-color: #000; 13 | } 14 | 15 | nav a[accesskey] { 16 | color: #EEE; 17 | } 18 | 19 | nav a[title] { 20 | color: #DDD; 21 | } 22 | 23 | .header { 24 | color: #E44; 25 | } 26 | 27 | .subheader { 28 | color: #E00; 29 | } 30 | 31 | .container, .post, .catalog-thread, #postform { 32 | background-color: #DDD; 33 | border-color: #000; 34 | } 35 | 36 | .barheader, th, form label { 37 | background-color: #BBB; 38 | } 39 | 40 | tr:nth-child(odd) { 41 | background-color: #EEE; 42 | } 43 | 44 | hr { 45 | border-bottom-color: #000; 46 | } 47 | 48 | form input, form input[type=file]::file-selector-button, form select, form textarea { 49 | border-color: #000; 50 | } 51 | 52 | .catalog-thread { 53 | scrollbar-color: #666 #BBB; 54 | } 55 | 56 | .post-container:target .post { 57 | background-color: #BBB !important; 58 | } 59 | 60 | .catalog-thread-subject, .post-subject { 61 | color: #016; 62 | } 63 | 64 | .post-name { 65 | color: #174; 66 | } 67 | 68 | .post-capcode { 69 | color: #26A; 70 | } 71 | 72 | .post-number a:hover { 73 | color: #F33; 74 | } 75 | 76 | .post-comment a, .catalog-thread-comment a { 77 | color: #D00; 78 | } 79 | 80 | .greentext { 81 | color: #792; 82 | } 83 | 84 | .pinktext { 85 | color: #D78; 86 | } 87 | 88 | .kiketext { 89 | background-color: #F8F8F8; 90 | color: #36A; 91 | } 92 | 93 | .redtext { 94 | color: #E33; 95 | } 96 | 97 | .spoiler { 98 | background-color: #000; 99 | color: #000; 100 | } 101 | 102 | .spoiler:hover { 103 | color: #FFF; 104 | } 105 | 106 | code, .code { 107 | background-color: #BBB; 108 | } 109 | 110 | code { 111 | border-color: #000; 112 | } 113 | 114 | .code { 115 | box-shadow: inset 0 0 0 1px #000; 116 | } 117 | -------------------------------------------------------------------------------- /Static/picochan.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #111; 3 | color: #DDD; 4 | } 5 | 6 | b { 7 | color: #FFF; 8 | } 9 | 10 | a, a:visited { 11 | color: #5CF; 12 | } 13 | 14 | nav { 15 | background-color: #222; 16 | border-bottom-color: #000; 17 | } 18 | 19 | .header { 20 | color: #E44; 21 | } 22 | 23 | .subheader { 24 | color: #E00; 25 | } 26 | 27 | .container, .post, .catalog-thread, #postform { 28 | background-color: #222; 29 | border-color: #000; 30 | } 31 | 32 | .barheader, tr:nth-child(odd), form label { 33 | background-color: #333; 34 | } 35 | 36 | th { 37 | background-color: #444; 38 | } 39 | 40 | hr { 41 | border-bottom-color: #000; 42 | } 43 | 44 | form input, form input[type=file]::file-selector-button, form select, form textarea { 45 | border-color: #000; 46 | } 47 | 48 | .catalog-thread { 49 | scrollbar-color: #444 #333; 50 | } 51 | 52 | .post-container:target .post { 53 | background-color: #333 !important; 54 | } 55 | 56 | .catalog-thread-subject, .post-subject { 57 | color: #28F; 58 | } 59 | 60 | .post-name { 61 | color: #0D0; 62 | } 63 | 64 | .post-capcode { 65 | color: #F22; 66 | } 67 | 68 | .post-number a { 69 | color: #DDD; 70 | } 71 | 72 | .post-number a:hover, .post-comment a, .catalog-thread-comment a { 73 | color: #F33; 74 | } 75 | 76 | .greentext { 77 | color: #0C0; 78 | } 79 | 80 | .pinktext { 81 | color: #F64; 82 | } 83 | 84 | .kiketext { 85 | background-color: #FFF; 86 | color: #33D; 87 | } 88 | 89 | .redtext { 90 | color: #E33; 91 | } 92 | 93 | .spoiler { 94 | background-color: #000; 95 | color: #000; 96 | } 97 | 98 | .spoiler:hover { 99 | color: #FFF; 100 | } 101 | 102 | code, .code { 103 | background-color: #444; 104 | } 105 | 106 | code { 107 | border-color: #000; 108 | } 109 | 110 | .code { 111 | box-shadow: inset 0 0 0 1px #000; 112 | } 113 | -------------------------------------------------------------------------------- /Static/spoiler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neeshy/picochan/9ded8adaa7b85bf1d5348f471a1704ba7aebf8e9/Static/spoiler.png -------------------------------------------------------------------------------- /Static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: calc(1.5em + 1.8ch + 1px) 1.3ch 1.3ch 1.3ch; 3 | font-family: monospace; 4 | } 5 | 6 | a, a:visited { 7 | text-decoration: none; 8 | } 9 | 10 | a:hover { 11 | text-decoration: underline; 12 | } 13 | 14 | nav { 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | z-index: 1; 19 | border-bottom-width: 1px; 20 | border-bottom-style: solid; 21 | padding: 0.25ch 1.5ch; 22 | width: 100%; 23 | line-height: 1.5; 24 | } 25 | 26 | nav:not(:hover):not(:focus-within) { 27 | white-space: nowrap; 28 | } 29 | 30 | nav a[accesskey] { 31 | font-weight: bold; 32 | } 33 | 34 | .logged-in-notification { 35 | float: right; 36 | margin-right: 3ch; 37 | } 38 | 39 | .banner { 40 | display: block; 41 | margin-right: auto; 42 | margin-left: auto; 43 | margin-bottom: 5px; 44 | } 45 | 46 | .header { 47 | margin-top: 0; 48 | margin-bottom: 0; 49 | text-align: center; 50 | } 51 | 52 | .header a { 53 | color: inherit; 54 | } 55 | 56 | .subheader { 57 | margin-top: 0; 58 | margin-bottom: 10px; 59 | text-align: center; 60 | font-weight: normal; 61 | font-size: 10pt; 62 | } 63 | 64 | .announcement { 65 | margin-bottom: 10px; 66 | text-align: center; 67 | font-weight: bold; 68 | font-size: 13pt; 69 | } 70 | 71 | .container { 72 | margin: auto; 73 | border-width: 1px; 74 | border-style: solid; 75 | padding: 10px; 76 | } 77 | 78 | .container.narrow { 79 | max-width: 650px; 80 | } 81 | 82 | .container.wide { 83 | max-width: 1150px; 84 | } 85 | 86 | .barheader { 87 | margin-right: -10px; 88 | margin-left: -10px; 89 | padding-left: 10px; 90 | font-size: 12pt; 91 | } 92 | 93 | ul { 94 | padding-left: 16px; 95 | list-style: none; 96 | } 97 | 98 | ul > li::before { 99 | padding-right: 8px; 100 | content: "*"; 101 | } 102 | 103 | table { 104 | width: 100%; 105 | border-spacing: 2px; 106 | } 107 | 108 | th { 109 | text-align: left; 110 | font-weight: bold; 111 | } 112 | 113 | th, td { 114 | padding: 3px 5px; 115 | } 116 | 117 | hr { 118 | clear: both; 119 | margin-top: 1.3ch; 120 | margin-bottom: 1.3ch; 121 | border: none; 122 | border-bottom-width: 1px; 123 | border-bottom-style: solid; 124 | } 125 | 126 | form label { 127 | display: inline-block; 128 | margin-right: 5px; 129 | margin-bottom: 5px; 130 | padding: 3px 5px; 131 | min-width: 18ch; 132 | vertical-align: top; 133 | } 134 | 135 | form input, 136 | form input[type=file]::file-selector-button { 137 | font-family: inherit; 138 | } 139 | 140 | form input[type=text], 141 | form input[type=password], 142 | form input[type=number], 143 | form input[type=checkbox], 144 | form input[type=submit], 145 | form input[type=file]::file-selector-button, 146 | form select, 147 | form textarea { 148 | border-width: 1px; 149 | border-style: solid; 150 | } 151 | 152 | form textarea { 153 | resize: both; 154 | } 155 | 156 | form input[type=radio] { 157 | margin-left: 18ch; 158 | vertical-align: middle; 159 | } 160 | 161 | form input[type=radio] + label { 162 | margin-bottom: 8px; 163 | padding: 8px; 164 | vertical-align: middle; 165 | } 166 | 167 | form input[type=radio] + label > img { 168 | display: block; 169 | max-height: 100px; 170 | } 171 | 172 | .post-container + form { 173 | margin-top: 5px; 174 | } 175 | 176 | #postform { 177 | display: none; 178 | position: fixed; 179 | top: calc(1.5em + 1.8ch + 1px); 180 | right: 1.3ch; 181 | border-width: 1px; 182 | border-style: solid; 183 | padding: 5px; 184 | max-width: calc(100% - 3.9ch - 3px); 185 | } 186 | 187 | #postform:target { 188 | display: block; 189 | } 190 | 191 | #postform a[href='##'] { 192 | position: absolute; 193 | top: 5px; 194 | right: 5px; 195 | font-weight: bold; 196 | } 197 | 198 | #postform label { 199 | width: 7ch; 200 | min-width: min-content; 201 | } 202 | 203 | #postform textarea { 204 | margin: 0 0 5px 0; 205 | width: calc(100% - 7ch - 25px); 206 | min-width: calc(100% - 7ch - 25px); 207 | max-width: calc(100% - 7ch - 25px); 208 | } 209 | 210 | #postform input[type=submit] { 211 | margin-left: 5px; 212 | } 213 | 214 | #postform input[type=file] { 215 | margin-right: 5px; 216 | max-width: 300px; 217 | vertical-align: top; 218 | } 219 | 220 | .new-post { 221 | display: block; 222 | text-align: center; 223 | font-weight: bold; 224 | font-size: 15pt; 225 | } 226 | 227 | .catalog-container { 228 | text-align: center; 229 | } 230 | 231 | .catalog-thread { 232 | display: inline-block; 233 | margin-right: 1.3ch; 234 | margin-bottom: calc(1.3ch - 4px); 235 | border-width: 1px; 236 | border-style: solid; 237 | padding: 10px; 238 | width: 150px; 239 | height: 200px; 240 | overflow: auto; 241 | scrollbar-width: thin; 242 | } 243 | 244 | .catalog-thread-info, .catalog-thread-subject { 245 | font-weight: bold; 246 | } 247 | 248 | .catalog-thread-lastbumpdate { 249 | font-size: 7.5pt; 250 | } 251 | 252 | .index-thread-summary { 253 | margin-left: 1.3ch; 254 | } 255 | 256 | .post-container { 257 | scroll-margin-top: calc(1.5em + 1.8ch + 1px); 258 | } 259 | 260 | .post { 261 | display: table; 262 | margin-top: 1.3ch; 263 | border-width: 1px; 264 | border-style: solid; 265 | padding: 1.3ch; 266 | } 267 | 268 | .thread { 269 | display: block; 270 | background: none !important; 271 | margin-top: 0; 272 | border: none; 273 | } 274 | 275 | .post-container:target .thread { 276 | display: table; 277 | margin-top: 1.3ch; 278 | } 279 | 280 | .post-thread-link, .post-subject, .post-name { 281 | font-weight: bold; 282 | } 283 | 284 | .post-number a { 285 | text-decoration: none; 286 | } 287 | 288 | .referrer, .post-attachment-info { 289 | font-size: 7.5pt; 290 | } 291 | 292 | .post-attachment { 293 | display: inline-block; 294 | margin-right: 1.3ch; 295 | margin-bottom: 1.3ch; 296 | } 297 | 298 | .post-attachment-info { 299 | margin-bottom: 1.3ch; 300 | } 301 | 302 | .post-attachment-info > a:first-child { 303 | display: inline-block; 304 | max-width: 200px; 305 | overflow: hidden; 306 | text-overflow: ellipsis; 307 | white-space: nowrap; 308 | } 309 | 310 | .post-attachment-info > a:first-child:hover { 311 | max-width: 100%; 312 | } 313 | 314 | .post-thumbnail, :not(img).post-file, .post-attachment-single img.post-file { 315 | float: left; 316 | } 317 | 318 | .post-thumbnail, .post-file { 319 | margin-right: 1.3ch; 320 | margin-bottom: 1.3ch; 321 | } 322 | 323 | .post-thumbnail { 324 | cursor: pointer; 325 | } 326 | 327 | img.post-file { 328 | display: none; 329 | max-width: 100%; 330 | cursor: pointer; 331 | } 332 | 333 | input[type=checkbox]:checked + .post-thumbnail { 334 | display: none; 335 | } 336 | 337 | input[type=checkbox]:checked + .post-thumbnail + img.post-file { 338 | display: block; 339 | } 340 | 341 | video.post-file { 342 | max-width: 320px; 343 | max-height: 240px; 344 | } 345 | 346 | .post-comment, .catalog-thread-comment { 347 | display: block; 348 | margin: 1.3ch 1.3ch 0.7ch 1.3ch; 349 | max-width: 150ch; 350 | overflow-wrap: break-word; 351 | white-space: pre-wrap; 352 | } 353 | 354 | .post-comment a, .catalog-thread-comment a { 355 | text-decoration: underline; 356 | } 357 | 358 | .redtext { 359 | letter-spacing: -0.7px; 360 | font-weight: bolder; 361 | font-size: 11pt; 362 | } 363 | 364 | .spoiler { 365 | cursor: none; 366 | } 367 | 368 | code, .code { 369 | display: inline-block; 370 | max-width: max-content; 371 | } 372 | 373 | code { 374 | margin-top: 1ch; 375 | margin-bottom: 1ch; 376 | border-width: 1px; 377 | border-style: solid; 378 | padding: 1ch; 379 | } 380 | 381 | .code { 382 | padding-right: 0.5ch; 383 | padding-left: 0.5ch; 384 | } 385 | 386 | .page-switcher { 387 | text-align: center; 388 | } 389 | 390 | .invisible { 391 | display: none; 392 | } 393 | 394 | .float-right { 395 | float: right; 396 | } 397 | -------------------------------------------------------------------------------- /Static/txt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neeshy/picochan/9ded8adaa7b85bf1d5348f471a1704ba7aebf8e9/Static/txt.png -------------------------------------------------------------------------------- /dbmaint.sql: -------------------------------------------------------------------------------- 1 | -- Run these commands periodically (e.g. every hour, or every day depending 2 | -- on activity) to maintain database performance and detect bugs. 3 | VACUUM; 4 | ANALYZE; 5 | PRAGMA integrity_check; 6 | PRAGMA foreign_key_check; 7 | PRAGMA optimize; 8 | -------------------------------------------------------------------------------- /lib/argon2.lua: -------------------------------------------------------------------------------- 1 | -- Argon2 FFI bindings 2 | 3 | local random = require("lib.random") 4 | local ffi = require("ffi") 5 | ffi.argon2 = ffi.load("argon2") 6 | local argon2 = {} 7 | 8 | ffi.cdef[[ 9 | typedef enum Argon2_type { 10 | Argon2_d = 0, 11 | Argon2_i = 1, 12 | Argon2_id = 2 13 | } argon2_type; 14 | 15 | typedef enum Argon2_version { 16 | ARGON2_VERSION_10 = 0x10, 17 | ARGON2_VERSION_13 = 0x13, 18 | ARGON2_VERSION_NUMBER = ARGON2_VERSION_13 19 | } argon2_version; 20 | 21 | size_t argon2_encodedlen(uint32_t t_cost, uint32_t m_cost, 22 | uint32_t parallelism, uint32_t saltlen, 23 | uint32_t hashlen, argon2_type type); 24 | int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, 25 | const uint32_t parallelism, const void *pwd, 26 | const size_t pwdlen, const void *salt, 27 | const size_t saltlen, void *hash, 28 | const size_t hashlen, char *encoded, 29 | const size_t encodedlen, argon2_type type, 30 | const uint32_t version); 31 | int argon2_verify(const char *encoded, const void *pwd, 32 | const size_t pwdlen, argon2_type type); 33 | ]] 34 | 35 | local hashtype_lut = { 36 | argon2d = ffi.argon2.Argon2_d, 37 | argon2i = ffi.argon2.Argon2_i, 38 | argon2id = ffi.argon2.Argon2_id, 39 | } 40 | 41 | -- only the password parameter is mandatory, the others are optional and will have 42 | -- sensible defaults set if they are not provided 43 | function argon2.digest(password, argon2_type, salt, params) 44 | assert(type(password) == "string", "incorrect datatype for parameter 'password'") 45 | assert(argon2_type == nil or hashtype_lut[argon2_type], "invalid value for parameter 'argon2_type'") 46 | assert(type(salt) == "string" or salt == nil, "invalid datatype for parameter 'salt'") 47 | assert(type(params) == "table" or params == nil, "invalid datatype for parameter 'params'") 48 | 49 | params = params or {} 50 | assert(type(params.t_cost) == "number" or params.t_cost == nil, "invalid datatype for parameter 'params.t_cost'") 51 | assert(type(params.m_cost) == "number" or params.m_cost == nil, "invalid datatype for parameter 'params.m_cost'") 52 | assert(type(params.parallelism) == "number" or params.parallelism == nil, "invalid datatype for parameter 'params.parallelism'") 53 | assert(type(params.hashlen) == "number" or params.hashlen == nil, "invalid datatype for parameter 'params.hashlen'") 54 | assert(type(params.version) == "number" or params.version == nil, "invalid datatype for parameter 'params.version'") 55 | 56 | argon2_type = argon2_type and hashtype_lut[argon2_type] or ffi.argon2.Argon2_id 57 | salt = salt or random.string(16) 58 | params.t_cost = params.t_cost or 16 59 | params.m_cost = params.m_cost or 2^16 60 | params.parallelism = params.parallelism or 4 61 | params.hashlen = params.hashlen or 64 62 | params.version = params.version or ffi.argon2.ARGON2_VERSION_NUMBER 63 | 64 | local resultlen = ffi.argon2.argon2_encodedlen(params.t_cost, params.m_cost, params.parallelism, 65 | #salt, params.hashlen, argon2_type) 66 | local result = ffi.new("char[?]", resultlen) 67 | local errcode = ffi.argon2.argon2_hash(params.t_cost, params.m_cost, params.parallelism, 68 | password, #password, salt, #salt, nil, params.hashlen, 69 | result, resultlen, argon2_type, params.version) 70 | if errcode ~= 0 then 71 | return nil, errcode 72 | end 73 | return ffi.string(result, resultlen) 74 | end 75 | 76 | function argon2.verify(password, hash, argon2_type) 77 | assert(type(password) == "string", "incorrect datatype for parameter 'password'") 78 | assert(type(hash) == "string", "incorrect datatype for parameter 'hash'") 79 | assert(argon2_type == nil or hashtype_lut[argon2_type], "invalid value for parameter 'argon2_type'") 80 | 81 | argon2_type = argon2_type and hashtype_lut[argon2_type] or ffi.argon2.Argon2_id 82 | local errcode = ffi.argon2.argon2_verify(hash, password, #password, argon2_type) 83 | if errcode ~= 0 then 84 | return false, errcode 85 | end 86 | return true 87 | end 88 | 89 | return argon2 90 | -------------------------------------------------------------------------------- /lib/cgi.lua: -------------------------------------------------------------------------------- 1 | -- CGI functions 2 | 3 | require("lib.stringext") 4 | require("lib.ioext") 5 | local cgi = {} 6 | cgi.headers = {} 7 | cgi.outputbuf = {} 8 | cgi.GET = {} 9 | cgi.POST = {} 10 | cgi.FILE = {} 11 | cgi.COOKIE = {} 12 | 13 | local maxinput = 268435456 14 | local chunksize = 131072 15 | 16 | local function emptyinput() 17 | while io.read(chunksize) do end 18 | end 19 | 20 | local function unescape(s) 21 | local a = ("a"):byte() 22 | local zero = ("0"):byte() 23 | return s:gsub("%%(%x%x)", function(x) 24 | x = x:lower() 25 | local digit = 0 26 | for i = 1, 2 do 27 | local b = x:byte(i) 28 | digit = bit.lshift(digit, 4) + b - (b >= a and (a - 10) or zero) 29 | end 30 | return string.char(digit) 31 | end) 32 | end 33 | 34 | local function parsequery(s) 35 | -- Convert pluses to spaces, and split on both & and ; 36 | local query = s:gsub("%+", " "):gsub(";", "&"):tokenize("&") 37 | local ret = {} 38 | for i = 1, #query do 39 | local kv = query[i]:tokenize("=", 1) 40 | if #kv == 2 then 41 | kv[1] = unescape(kv[1]) 42 | kv[2] = unescape(kv[2]) 43 | ret[kv[1]] = kv[2] 44 | end 45 | end 46 | return ret 47 | end 48 | 49 | local function parseform(boundary, maxread) 50 | local post = {} 51 | local file = {} 52 | local bytesread = 0 53 | local lineboundary = "\r\n" .. boundary 54 | 55 | local function checklength() 56 | if bytesread > maxread then 57 | emptyinput() 58 | error("sent data length exceeds allowed upload limit") 59 | end 60 | end 61 | 62 | local function discard(s) 63 | bytesread = bytesread + #s 64 | checklength() 65 | end 66 | 67 | local function collect(t) 68 | return function(s) 69 | bytesread = bytesread + #s 70 | checklength() 71 | t[#t + 1] = s 72 | end 73 | end 74 | 75 | local function output(f) 76 | return function(s) 77 | bytesread = bytesread + #s 78 | checklength() 79 | f:write(s) 80 | end 81 | end 82 | 83 | local buf 84 | local function readandcheck(delimiter, func) 85 | buf = io.input():readuntil(buf, delimiter, chunksize, func) 86 | if buf then 87 | bytesread = bytesread + #delimiter 88 | checklength() 89 | end 90 | end 91 | 92 | readandcheck(boundary, discard) 93 | while buf do 94 | local headers = {} 95 | readandcheck("\r\n\r\n", collect(headers)) 96 | if not buf then break end 97 | headers = table.concat(headers):tokenize("\r\n") 98 | if headers[1] == "--" then break end 99 | local disposition = {} 100 | for i = 2, #headers do -- skip first, will always be empty string 101 | local kv = headers[i]:tokenize(":", 1) 102 | if #kv == 2 and kv[1]:lower() == "content-disposition" then 103 | for k, v in kv[2]:gmatch(";%s*([^%s=]+)=\"(.-)\"") do 104 | disposition[k] = v 105 | end 106 | break 107 | end 108 | end 109 | local name = disposition.name 110 | if name then 111 | local filename = disposition.filename 112 | if filename then 113 | local tmpfile = assert(io.tmpfile()) 114 | readandcheck(lineboundary, output(tmpfile)) 115 | if assert(tmpfile:seek("end")) ~= 0 then 116 | assert(tmpfile:seek("set")) 117 | file[name] = { filename = filename, file = tmpfile } 118 | end 119 | else 120 | local value = {} 121 | readandcheck(lineboundary, collect(value)) 122 | post[name] = table.concat(value) 123 | end 124 | else 125 | readandcheck(lineboundary, discard) 126 | end 127 | end 128 | return post, file 129 | end 130 | 131 | function cgi.initialize() 132 | local cookie = os.getenv("HTTP_COOKIE") 133 | if cookie then 134 | local cookies = cookie:tokenize(";") 135 | for i = 1, #cookies do 136 | local kv = cookies[i]:gsub("^ +", ""):tokenize("=", 1) 137 | if #kv == 2 then 138 | cgi.COOKIE[kv[1]] = kv[2] 139 | end 140 | end 141 | end 142 | 143 | local method = os.getenv("REQUEST_METHOD") 144 | if not method then 145 | return 146 | elseif method == "GET" or method == "DELETE" then 147 | local query = os.getenv("QUERY_STRING") 148 | if query then 149 | cgi.GET = parsequery(query) 150 | end 151 | elseif method == "POST" or method == "PUT" then 152 | local maxread = maxinput 153 | local content_length = tonumber(os.getenv("CONTENT_LENGTH")) 154 | if content_length then 155 | if content_length > maxinput then 156 | emptyinput() 157 | error("content length exceeds allowed upload limit") 158 | end 159 | maxread = content_length 160 | end 161 | 162 | local content_type = os.getenv("CONTENT_TYPE") 163 | if not content_type then 164 | emptyinput() 165 | error("content type not given") 166 | elseif content_type:sub(1, 19) == "multipart/form-data" then 167 | local start, stop = content_type:find("boundary=", 20, true) 168 | if not start then 169 | emptyinput() 170 | error("boundary parameter not specified") 171 | end 172 | local boundary = content_type:sub(stop + 1) 173 | if #boundary == 0 then 174 | emptyinput() 175 | error("boundary paramter may not be emtpy") 176 | end 177 | cgi.POST, cgi.FILE = parseform("--" .. boundary, maxread) 178 | elseif content_type:sub(1, 33) == "application/x-www-form-urlencoded" then 179 | local query = io.read(chunksize) -- maximum for query strings 180 | if io.read(0) then 181 | emptyinput() 182 | error("sent data length exceeds allowed query length") 183 | end 184 | cgi.POST = parsequery(query) 185 | else -- some other blob 186 | emptyinput() 187 | error("request content type not supported") 188 | end 189 | end 190 | end 191 | 192 | function cgi.finalize() 193 | for k, v in pairs(cgi.headers) do 194 | io.write(k, ": ", v, "\r\n") 195 | end 196 | 197 | io.write("\r\n") 198 | 199 | io.write(table.concat(cgi.outputbuf)) 200 | os.exit(0) 201 | end 202 | 203 | return cgi 204 | -------------------------------------------------------------------------------- /lib/ioext.lua: -------------------------------------------------------------------------------- 1 | -- io extension functions 2 | 3 | local file = getmetatable(io.stdin) 4 | 5 | -- guarantee n bytes are read or EOF is reached 6 | function file:readall(n) 7 | local read 8 | while n > 0 do 9 | local r = self:read(n) 10 | if not r then 11 | break 12 | end 13 | if read then 14 | read = read .. r 15 | else 16 | read = r 17 | end 18 | n = n - #r 19 | end 20 | return read 21 | end 22 | 23 | function file:readuntil(buf, delimiter, chunksize, out) 24 | if delimiter == "" then 25 | return nil 26 | end 27 | 28 | self = self or io.input() 29 | buf = buf or self:read(chunksize) 30 | delimiter = delimiter or " " 31 | 32 | local pos = 1 33 | local first, last 34 | while buf do 35 | first, last = buf:find(delimiter, pos, true) 36 | if first then 37 | out(buf:sub(pos, first - 1)) 38 | return buf:sub(last + 1) 39 | end 40 | 41 | -- Read in enough to determine if delmiter exists. 42 | -- This is required for the case where the delimiter is 43 | -- a multi-character string present between two chunks. 44 | local read = self:readall(#delimiter) 45 | if not read then 46 | out(buf:sub(pos)) 47 | return nil 48 | end 49 | 50 | local tmp = buf .. read 51 | first, last = tmp:find(delimiter, pos, true) 52 | if first then 53 | out(tmp:sub(pos, first - 1)) 54 | return tmp:sub(last + 1) 55 | end 56 | 57 | out(buf:sub(pos)) 58 | buf = read 59 | pos = 1 60 | if #buf < chunksize then 61 | read = self:read(chunksize - #buf) 62 | if read then 63 | buf = buf .. read 64 | end 65 | end 66 | end 67 | end 68 | 69 | function io.readall(file, n) 70 | return file:readall(n) 71 | end 72 | 73 | function io.readuntil(file, buf, delimiter, chunksize, out) 74 | return file:readuntil(buf, delimiter, chunksize, out) 75 | end 76 | 77 | function io.exists(path) 78 | local f = io.open(path, "r") 79 | if f then 80 | f:close() 81 | return true 82 | end 83 | return false 84 | end 85 | -------------------------------------------------------------------------------- /lib/openbsd.lua: -------------------------------------------------------------------------------- 1 | -- OpenBSD FFI bindings 2 | 3 | local ffi = require("ffi") 4 | local openbsd = {} 5 | 6 | ffi.cdef[[ 7 | int pledge(const char *promises, const char *execpromises); 8 | int unveil(const char *path, const char *permissions); 9 | char *strerror(int errnum); 10 | ]] 11 | 12 | function openbsd.pledge(promises, execpromises) 13 | assert(type(promises) == "string" or promises == nil, "incorrect datatype for parameter 'promises'") 14 | assert(type(execpromises) == "string" or execpromises == nil, "incorrect datatype for parameter 'execpromises'") 15 | 16 | local retval = ffi.C.pledge(promises, execpromises) 17 | if retval == -1 then 18 | return false, ffi.string(ffi.C.strerror(ffi.errno())) 19 | end 20 | return true 21 | end 22 | 23 | function openbsd.unveil(path, permissions) 24 | assert(type(path) == "string" or path == nil, "incorrect datatype for parameter 'path'") 25 | assert(type(permissions) == "string" or permissions == nil, "incorrect datatype for parameter 'permissions'") 26 | 27 | local retval = ffi.C.unveil(path, permissions) 28 | if retval == -1 then 29 | return false, ffi.string(ffi.C.strerror(ffi.errno())) 30 | end 31 | return true 32 | end 33 | 34 | return openbsd 35 | -------------------------------------------------------------------------------- /lib/random.lua: -------------------------------------------------------------------------------- 1 | -- Random functions 2 | 3 | local ffi = require("ffi") 4 | local random = {} 5 | 6 | function random.data(n) 7 | local f = assert(io.open("/dev/urandom")) 8 | local data = f:read(n) 9 | f:close() 10 | return data 11 | end 12 | 13 | function random.int(min, max) 14 | local random_data = random.data(8) 15 | local n = ffi.cast("uint64_t *", ffi.new("uint8_t[8]", random_data))[0] 16 | return tonumber(n % (max - min + 1)) + min 17 | end 18 | 19 | local ascii = {} 20 | for i = 0, 255 do 21 | ascii[#ascii + 1] = string.char(i) 22 | end 23 | ascii = table.concat(ascii) 24 | 25 | function random.string(length, pattern) 26 | length = length or 64 27 | pattern = pattern or "%w" 28 | local result = "" 29 | 30 | local dict = ascii:gsub("[^" .. pattern .. "]", "") 31 | while #result < length do 32 | local randidx = random.int(1, #dict) 33 | local randbyte = dict:byte(randidx) 34 | result = result .. string.char(randbyte) 35 | end 36 | 37 | return result 38 | end 39 | 40 | return random 41 | -------------------------------------------------------------------------------- /lib/sha.lua: -------------------------------------------------------------------------------- 1 | -- OpenSSL SHA FFI bindings 2 | 3 | local ffi = require("ffi") 4 | ffi.ssl = ffi.load("ssl") 5 | local sha = {} 6 | 7 | ffi.cdef[[ 8 | unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md); 9 | unsigned char *SHA224(const unsigned char *d, size_t n, unsigned char *md); 10 | unsigned char *SHA256(const unsigned char *d, size_t n, unsigned char *md); 11 | unsigned char *SHA384(const unsigned char *d, size_t n, unsigned char *md); 12 | unsigned char *SHA512(const unsigned char *d, size_t n, unsigned char *md); 13 | ]] 14 | 15 | local hashfunc_lut = { 16 | sha1 = ffi.ssl.SHA1, 17 | sha224 = ffi.ssl.SHA224, 18 | sha256 = ffi.ssl.SHA256, 19 | sha384 = ffi.ssl.SHA384, 20 | sha512 = ffi.ssl.SHA512, 21 | } 22 | 23 | local hashlen_lut = { 24 | sha1 = 20, 25 | sha224 = 28, 26 | sha256 = 32, 27 | sha384 = 48, 28 | sha512 = 64, 29 | } 30 | 31 | local function hex(data) 32 | local result = {} 33 | for i = 1, #data do 34 | result[#result + 1] = ("%02x"):format(data:byte(i)) 35 | end 36 | return table.concat(result) 37 | end 38 | 39 | function sha.hash(hashtype, data) 40 | assert(type(data) == "string", "incorrect datatype for parameter 'data'") 41 | local hashfunc = assert(hashfunc_lut[hashtype], "incorrect value for parameter 'hashtype'") 42 | return hex(ffi.string(hashfunc(data, #data, nil), hashlen_lut[hashtype])) 43 | end 44 | 45 | return sha 46 | -------------------------------------------------------------------------------- /lib/sqlite3.lua: -------------------------------------------------------------------------------- 1 | -- SQLite3 FFI bindings 2 | 3 | local ffi = require("ffi") 4 | ffi.sqlite3 = ffi.load("sqlite3") 5 | local sqlite3 = {} 6 | 7 | ffi.cdef[[ 8 | typedef struct sqlite3 sqlite3; 9 | typedef struct sqlite3_stmt sqlite3_stmt; 10 | 11 | static const int SQLITE_OPEN_READONLY = 1; 12 | static const int SQLITE_OPEN_READWRITE = 2; 13 | static const int SQLITE_OPEN_CREATE = 4; 14 | 15 | static const int SQLITE_OK = 0; 16 | static const int SQLITE_ROW = 100; 17 | static const int SQLITE_DONE = 101; 18 | 19 | static const int SQLITE_INTEGER = 1; 20 | static const int SQLITE_FLOAT = 2; 21 | static const int SQLITE_TEXT = 3; 22 | static const int SQLITE_BLOB = 4; 23 | static const int SQLITE_NULL = 5; 24 | 25 | const char *sqlite3_errmsg(sqlite3 *db); 26 | int sqlite3_open_v2(const char *filename, sqlite3 **db, int flags, const char *zvfs); 27 | int sqlite3_close_v2(sqlite3 *db); 28 | int sqlite3_busy_timeout(sqlite3 *db, int ms); 29 | int sqlite3_prepare_v2(sqlite3 *db, const char *sql, int nbyte, sqlite3_stmt **stmt, const char **sqltail); 30 | int sqlite3_step(sqlite3_stmt *stmt); 31 | int sqlite3_reset(sqlite3_stmt *stmt); 32 | int sqlite3_finalize(sqlite3_stmt *stmt); 33 | 34 | int sqlite3_bind_int(sqlite3_stmt *stmt, int column, int value); 35 | int sqlite3_bind_double(sqlite3_stmt *stmt, int column, double value); 36 | int sqlite3_bind_text(sqlite3_stmt *stmt, int column, const char *value, int length, void(*)(void *)); 37 | int sqlite3_bind_null(sqlite3_stmt *stmt, int column); 38 | 39 | int sqlite3_data_count(sqlite3_stmt *stmt); 40 | const char *sqlite3_column_name(sqlite3_stmt *stmt, int column); 41 | int sqlite3_column_type(sqlite3_stmt *stmt, int column); 42 | int sqlite3_column_int(sqlite3_stmt *stmt, int column); 43 | double sqlite3_column_double(sqlite3_stmt *stmt, int column); 44 | const unsigned char *sqlite3_column_text(sqlite3_stmt *stmt, int column); 45 | const void *sqlite3_column_blob(sqlite3_stmt *stmt, int column); 46 | int sqlite3_column_bytes(sqlite3_stmt *stmt, int column); 47 | ]] 48 | 49 | sqlite3.READONLY = ffi.sqlite3.SQLITE_OPEN_READONLY 50 | sqlite3.READWRITE = ffi.sqlite3.SQLITE_OPEN_READWRITE 51 | sqlite3.CREATE = ffi.sqlite3.SQLITE_OPEN_CREATE 52 | sqlite3.OK = ffi.sqlite3.SQLITE_OK 53 | sqlite3.ROW = ffi.sqlite3.SQLITE_ROW 54 | sqlite3.DONE = ffi.sqlite3.SQLITE_DONE 55 | 56 | local new_db = ffi.typeof("sqlite3 *[1]") 57 | local new_stmt = ffi.typeof("sqlite3_stmt *[1]") 58 | 59 | local metatable_db = {} 60 | local metatable_stmt = {} 61 | metatable_db.__index = metatable_db 62 | metatable_stmt.__index = metatable_stmt 63 | 64 | local modes = { 65 | r = sqlite3.READONLY, 66 | w = sqlite3.READWRITE, 67 | c = sqlite3.READWRITE + sqlite3.CREATE, 68 | } 69 | 70 | -- 71 | -- GLOBAL FUNCTIONS 72 | -- 73 | 74 | function sqlite3.open(path, mode) 75 | assert(type(path) == "string", "incorrect datatype for parameter 'path'") 76 | assert(mode == nil or modes[mode], "invalid value for parameter 'mode'") 77 | 78 | local db = new_db() 79 | local err = ffi.sqlite3.sqlite3_open_v2(path, db, modes[mode or "c"], nil) 80 | db = db[0] 81 | 82 | if err ~= sqlite3.OK then 83 | return nil, ffi.string(ffi.sqlite3.sqlite3_errmsg(db)) 84 | end 85 | 86 | return db 87 | end 88 | 89 | -- 90 | -- DATABASE METHODS 91 | -- 92 | 93 | function metatable_db:close() 94 | return ffi.sqlite3.sqlite3_close_v2(self) 95 | end 96 | 97 | function metatable_db:busy_timeout(ms) 98 | return ffi.sqlite3.sqlite3_busy_timeout(self, ms) 99 | end 100 | 101 | function metatable_db:errmsg() 102 | return ffi.string(ffi.sqlite3.sqlite3_errmsg(self)) 103 | end 104 | 105 | function metatable_db:prepare(sql) 106 | assert(type(sql) == "string", "incorrect datatype for parameter 'sql'") 107 | 108 | local stmt = new_stmt() 109 | local err = ffi.sqlite3.sqlite3_prepare_v2(self, sql, -1, stmt, nil) 110 | 111 | if err ~= sqlite3.OK then 112 | return nil, self:errmsg() 113 | end 114 | 115 | return stmt[0] 116 | end 117 | 118 | -- The following six functions are quick convenience functions which accept 119 | -- variable arguments. These functions call error() upon failure. 120 | 121 | -- Return a table of rows. 122 | -- e.g. t[1].Name, t[1].Address, t[2].Name 123 | function metatable_db:q(sql, ...) 124 | local stmt, errmsg = self:prepare(sql) 125 | if not stmt then 126 | error(errmsg, 2) 127 | end 128 | 129 | local ret = stmt:bind_values(...) 130 | if ret ~= sqlite3.OK then 131 | error(self:errmsg(), 2) 132 | end 133 | 134 | local rows = {} 135 | while stmt:step() == sqlite3.ROW do 136 | rows[#rows + 1] = stmt:column_values() 137 | end 138 | 139 | stmt:finalize() 140 | return rows 141 | end 142 | 143 | -- Return a table of the first column of each row 144 | function metatable_db:q1(sql, ...) 145 | local stmt, errmsg = self:prepare(sql) 146 | if not stmt then 147 | error(errmsg, 2) 148 | end 149 | 150 | local ret = stmt:bind_values(...) 151 | if ret ~= sqlite3.OK then 152 | error(self:errmsg(), 2) 153 | end 154 | 155 | local values = {} 156 | while stmt:step() == sqlite3.ROW do 157 | values[#values + 1] = stmt:column(1) 158 | end 159 | 160 | stmt:finalize() 161 | return values 162 | end 163 | 164 | -- Return the first row, or nil if there are none. 165 | -- e.g. t.Name, t.Address 166 | function metatable_db:r(sql, ...) 167 | local stmt, errmsg = self:prepare(sql) 168 | if not stmt then 169 | error(errmsg, 2) 170 | end 171 | 172 | local ret = stmt:bind_values(...) 173 | if ret ~= sqlite3.OK then 174 | error(self:errmsg(), 2) 175 | end 176 | 177 | ret = stmt:step() 178 | local row 179 | if ret == sqlite3.ROW then 180 | row = stmt:column_values() 181 | elseif ret ~= sqlite3.DONE then 182 | error(self:errmsg(), 2) 183 | end 184 | 185 | stmt:finalize() 186 | return row 187 | end 188 | 189 | -- Return the first column of the first row, or nil if there are none. 190 | function metatable_db:r1(sql, ...) 191 | local stmt, errmsg = self:prepare(sql) 192 | if not stmt then 193 | error(errmsg, 2) 194 | end 195 | 196 | local ret = stmt:bind_values(...) 197 | if ret ~= sqlite3.OK then 198 | error(self:errmsg(), 2) 199 | end 200 | 201 | ret = stmt:step() 202 | local value 203 | if ret == sqlite3.ROW then 204 | value = stmt:column(1) 205 | elseif ret ~= sqlite3.DONE then 206 | error(self:errmsg(), 2) 207 | end 208 | 209 | stmt:finalize() 210 | return value 211 | end 212 | 213 | -- Return a boolean: true if result rows were produced by the SQL statement 214 | -- and false if not. 215 | -- e.g. db:b("SELECT TRUE FROM Customers WHERE Name = ?", "James") would 216 | -- return true if there is a customer named James, or false otherwise. 217 | function metatable_db:b(sql, ...) 218 | local stmt, errmsg = self:prepare(sql) 219 | if not stmt then 220 | error(errmsg, 2) 221 | end 222 | 223 | local ret = stmt:bind_values(...) 224 | if ret ~= sqlite3.OK then 225 | error(self:errmsg(), 2) 226 | end 227 | 228 | ret = stmt:step() 229 | stmt:finalize() 230 | 231 | if ret == sqlite3.ROW then 232 | return true 233 | elseif ret == sqlite3.DONE then 234 | return false 235 | end 236 | 237 | error(self:errmsg(), 2) 238 | end 239 | 240 | -- Return nothing 241 | function metatable_db:e(sql, ...) 242 | local stmt, errmsg = self:prepare(sql) 243 | if not stmt then 244 | error(errmsg, 2) 245 | end 246 | 247 | local ret = stmt:bind_values(...) 248 | if ret ~= sqlite3.OK then 249 | error(self:errmsg(), 2) 250 | end 251 | 252 | ret = stmt:step() 253 | if ret ~= sqlite3.ROW and ret ~= sqlite3.DONE then 254 | error(self:errmsg(), 2) 255 | end 256 | stmt:finalize() 257 | end 258 | 259 | -- 260 | -- STATEMENT METHODS 261 | -- 262 | 263 | function metatable_stmt:bind(column, value) 264 | assert(type(column) == "number", "incorrect datatype for parameter 'column'") 265 | local type = type(value) 266 | 267 | if type == "string" then 268 | return ffi.sqlite3.sqlite3_bind_text(self, column, value, #value, ffi.cast("void *", 0)) 269 | elseif type == "number" then 270 | return ffi.sqlite3.sqlite3_bind_double(self, column, value) 271 | elseif type == "boolean" then 272 | return ffi.sqlite3.sqlite3_bind_int(self, column, value) 273 | elseif value == nil then 274 | return ffi.sqlite3.sqlite3_bind_null(self, column) 275 | end 276 | 277 | error("incorrect datatype for parameter 'value'") 278 | end 279 | 280 | function metatable_stmt:bind_values(...) 281 | for i = 1, select("#", ...) do 282 | local ret = self:bind(i, select(i, ...)) 283 | if ret ~= sqlite3.OK then 284 | return ret 285 | end 286 | end 287 | return sqlite3.OK 288 | end 289 | 290 | function metatable_stmt:step() 291 | return ffi.sqlite3.sqlite3_step(self) 292 | end 293 | 294 | function metatable_stmt:data_count() 295 | return ffi.sqlite3.sqlite3_data_count(self) 296 | end 297 | 298 | function metatable_stmt:column_name(column) 299 | return ffi.string(ffi.sqlite3.sqlite3_column_name(self, column - 1)) 300 | end 301 | 302 | function metatable_stmt:column(column) 303 | column = column - 1 304 | local type = ffi.sqlite3.sqlite3_column_type(self, column) 305 | 306 | if type == ffi.sqlite3.SQLITE_INTEGER then 307 | return ffi.sqlite3.sqlite3_column_int(self, column) 308 | elseif type == ffi.sqlite3.SQLITE_FLOAT then 309 | return ffi.sqlite3.sqlite3_column_double(self, column) 310 | elseif type == ffi.sqlite3.SQLITE_TEXT then 311 | return ffi.string(ffi.sqlite3.sqlite3_column_text(self, column)) 312 | elseif type == ffi.sqlite3.SQLITE_BLOB then 313 | local blob = ffi.sqlite3.sqlite3_column_blob(self, column) 314 | if blob == nil then 315 | return nil 316 | end 317 | return ffi.string(blob, ffi.sqlite3.sqlite3_column_bytes(self, column)) 318 | elseif type == ffi.sqlite3.SQLITE_NULL then 319 | return nil 320 | end 321 | end 322 | 323 | function metatable_stmt:column_values() 324 | local ret = {} 325 | for i = 1, self:data_count() do 326 | ret[self:column_name(i)] = self:column(i) 327 | end 328 | return ret 329 | end 330 | 331 | function metatable_stmt:reset() 332 | return ffi.sqlite3.sqlite3_reset(self) 333 | end 334 | 335 | function metatable_stmt:finalize() 336 | return ffi.sqlite3.sqlite3_finalize(self) 337 | end 338 | 339 | ffi.metatype("sqlite3", metatable_db) 340 | ffi.metatype("sqlite3_stmt", metatable_stmt) 341 | 342 | return sqlite3 343 | -------------------------------------------------------------------------------- /lib/stringext.lua: -------------------------------------------------------------------------------- 1 | -- String extension functions 2 | 3 | function string:tokenize(delimiter, max) 4 | if delimiter == "" then 5 | return nil 6 | end 7 | 8 | delimiter = delimiter or " " 9 | 10 | local result = {} 11 | local pos = 1 12 | local first, last = self:find(delimiter, pos, true) 13 | while first and (not max or #result < max) do 14 | result[#result + 1] = self:sub(pos, first - 1) 15 | pos = last + 1 16 | first, last = self:find(delimiter, pos, true) 17 | end 18 | result[#result + 1] = self:sub(pos) 19 | 20 | return result 21 | end 22 | 23 | local bs = { [0] = 24 | "A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P", 25 | "Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f", 26 | "g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v", 27 | "w","x","y","z","0","1","2","3","4","5","6","7","8","9","+","/", 28 | } 29 | 30 | local AND = bit.band 31 | local OR = bit.bor 32 | local RSHIFT = bit.rshift 33 | local LSHIFT = bit.lshift 34 | 35 | function string:base64() 36 | local pad = 2 - ((#self - 1) % 3) 37 | self = (self .. ("\0"):rep(pad)):gsub("...", function(cs) 38 | local a, b, c = cs:byte(1, 3) 39 | return bs[RSHIFT(a, 2)] .. bs[OR(LSHIFT(AND(a, 3), 4), RSHIFT(b, 4))] .. 40 | bs[OR(LSHIFT(AND(b, 15), 2), RSHIFT(c, 6))] .. bs[AND(c, 63)] 41 | end) 42 | return self:sub(1, #self - pad) .. ("="):rep(pad) 43 | end 44 | -------------------------------------------------------------------------------- /pico.cgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/luajit 2 | -- Picochan CGI Frontend 3 | 4 | xpcall(require, function(err) 5 | if db then db:close() end 6 | io.write("Status: 500 Internal Server Error\r\n" .. 7 | "Content-Type: text/plain; charset=utf-8\r\n" .. 8 | "\r\n" .. 9 | (err and (tostring(err) .. "\n\n") or "") .. 10 | debug.traceback() .. "\n") 11 | end, "pico") 12 | -------------------------------------------------------------------------------- /pico.lua: -------------------------------------------------------------------------------- 1 | -- Picochan HTML Frontend 2 | -- HAPAS ARE MENTALLY ILL DEGENERATES 3 | 4 | local pico = require("picoengine") 5 | local cgi = require("lib.cgi") 6 | 7 | require("lib.stringext") 8 | require("lib.ioext") 9 | 10 | local html = {} 11 | html.table = {} 12 | html.list = {} 13 | html.container = {} 14 | html.form = {} 15 | local views = { 16 | THREAD = 0, 17 | INDEX = 1, 18 | RECENT = 2, 19 | MOD_ACTION = 3, 20 | } 21 | 22 | -- 23 | -- INITIALIZATION 24 | -- 25 | 26 | if jit.os == "BSD" then 27 | local openbsd = require("lib.openbsd") 28 | openbsd.unveil("./picochan.db", "rw") 29 | openbsd.unveil("./picochan.db-journal", "rwc") 30 | openbsd.unveil("./Media/", "rwxc") 31 | openbsd.unveil("./Static/", "rx") 32 | openbsd.unveil("/dev/urandom", "r") 33 | openbsd.unveil("/tmp/", "rwc") 34 | openbsd.unveil("/bin/sh", "x") 35 | openbsd.pledge("stdio rpath wpath cpath fattr flock proc exec prot_exec") 36 | end 37 | 38 | pico.initialize() 39 | local sitename = pico.global.get("sitename", "Picochan") 40 | local defaultpostname = pico.global.get("defaultpostname", "Anonymous") 41 | local defaultboardview = pico.global.get("defaultboardview", "catalog") 42 | local theme = pico.global.get("theme", "picochan") 43 | local threadpagesize = pico.global.get("threadpagesize", 50) 44 | 45 | cgi.initialize() 46 | pico.account.register_login(cgi.COOKIE.session_key) 47 | 48 | local function printf(...) 49 | cgi.outputbuf[#cgi.outputbuf + 1] = string.format(...) 50 | end 51 | 52 | local function thumbsize(w, h, mw, mh) 53 | return math.min(w, mw, math.floor(w / h * mh + 0.5)), math.min(h, mh, math.floor(h / w * mw + 0.5)) 54 | end 55 | 56 | local function permit(board) 57 | return pico.account.current and (not pico.account.current.Board or pico.account.current.Board == board) 58 | end 59 | 60 | -- 61 | -- HTML FUNCTIONS 62 | -- 63 | 64 | function html.begin(...) 65 | local title = string.format(...) 66 | title = title and (title .. " - ") or "" 67 | local theme = (cgi.COOKIE.theme and io.exists("./Static/" .. cgi.COOKIE.theme .. ".css")) 68 | and cgi.COOKIE.theme or theme 69 | 70 | printf("\r\n") 71 | printf("") 72 | printf( "") 73 | printf( "%s%s", title, sitename) 74 | printf( "") 75 | printf( "", theme) 76 | printf( "") 77 | printf( "") 78 | printf( "") 79 | printf( "") 80 | printf( "") 81 | printf( "") 103 | end 104 | 105 | function html.finish() 106 | printf("\r\n") 107 | printf("\r\n", os.clock() * 1000) 108 | end 109 | 110 | function html.error(title, ...) 111 | cgi.outputbuf = {} 112 | html.brc("error", title) 113 | printf(...) 114 | html.cfinish() 115 | pico.finalize() 116 | cgi.finalize() 117 | end 118 | 119 | function html.header(...) 120 | printf("

%s

", string.format(...)) 121 | end 122 | 123 | function html.subheader(...) 124 | printf("

%s

", string.format(...)) 125 | end 126 | 127 | function html.announcement() 128 | local announcement = pico.global.get("announcement") 129 | if announcement then 130 | printf("
%s
", announcement) 131 | end 132 | end 133 | 134 | function html.container.begin(width) 135 | printf("
", width or "narrow") 136 | end 137 | 138 | function html.container.finish() 139 | printf("
") 140 | end 141 | 142 | function html.container.barheader(...) 143 | printf("

%s

", string.format(...)) 144 | end 145 | 146 | function html.brc(title, header, width) 147 | html.begin("%s", title) 148 | html.header("%s", header) 149 | html.container.begin(width) 150 | end 151 | 152 | function html.cfinish() 153 | html.container.finish() 154 | html.finish() 155 | end 156 | 157 | function html.list.begin() 158 | printf("") 163 | end 164 | 165 | function html.list.entry(...) 166 | printf("
  • %s
  • ", string.format(...)) 167 | end 168 | 169 | function html.table.begin(...) 170 | printf("") 171 | for i = 1, select("#", ...) do 172 | printf("", select(i, ...)) 173 | end 174 | printf("") 175 | end 176 | 177 | function html.table.entry(...) 178 | printf("") 179 | for i = 1, select("#", ...) do 180 | printf("", select(i, ...)) 181 | end 182 | printf("") 183 | end 184 | 185 | function html.table.finish() 186 | printf("
    %s
    %s
    ") 187 | end 188 | 189 | function html.date(timestamp, reldisplay) 190 | local difftime = os.time() - timestamp 191 | local unit, multiple 192 | local decimal = false 193 | local reltime 194 | 195 | if difftime >= (60 * 60 * 24 * 365) then 196 | unit = "year" 197 | multiple = difftime / (60 * 60 * 24 * 365) 198 | decimal = true 199 | elseif difftime >= (60 * 60 * 24 * 30) then 200 | unit = "month" 201 | multiple = difftime / (60 * 60 * 24 * 30) 202 | decimal = true 203 | elseif difftime >= (60 * 60 * 24 * 7) then 204 | unit = "week" 205 | multiple = difftime / (60 * 60 * 24 * 7) 206 | elseif difftime >= (60 * 60 * 24) then 207 | unit = "day" 208 | multiple = difftime / (60 * 60 * 24) 209 | elseif difftime >= (60 * 60) then 210 | unit = "hour" 211 | multiple = difftime / (60 * 60) 212 | elseif difftime >= (60) then 213 | unit = "minute" 214 | multiple = difftime / (60) 215 | else 216 | unit = "second" 217 | multiple = difftime 218 | end 219 | 220 | if decimal then 221 | reltime = ("%.1f %s%s ago"):format(multiple, unit, multiple == 1 and "" or "s") 222 | else 223 | multiple = math.floor(multiple) 224 | reltime = ("%d %s%s ago"):format(multiple, unit, multiple == 1 and "" or "s") 225 | end 226 | 227 | return (""):format( 228 | os.date("!%F %T", timestamp), 229 | reldisplay and os.date("!%F %T %Z %z", timestamp) or reltime, 230 | reldisplay and reltime or os.date("!%F %T", timestamp)) 231 | end 232 | 233 | function html.striphtml(s) 234 | s = tostring(s) 235 | :gsub("&", "&") 236 | :gsub("<", "<") 237 | :gsub(">", ">") 238 | :gsub("'", "'") 239 | :gsub("\"", """) 240 | return s 241 | end 242 | 243 | function html.unstriphtml(s) 244 | s = tostring(s) 245 | :gsub(""", "\"") 246 | :gsub("'", "'") 247 | :gsub(">", ">") 248 | :gsub("<", "<") 249 | :gsub("&", "&") 250 | return s 251 | end 252 | 253 | function html.picofmt(post_tbl) 254 | local email = post_tbl.Email 255 | if email and (email == "nofo" or email:match("^nofo ") or email:match(" nofo$") or email:match(" nofo ")) then 256 | local s = html.striphtml(post_tbl.Comment) 257 | :gsub("[\1-\8\11-\31\127]", "") 258 | :gsub("^\n+", "") 259 | :gsub("%s+$", "") 260 | :gsub("\n", "
    ") 261 | return s 262 | end 263 | 264 | local function handle_refs(number, append) 265 | number = tonumber(number) 266 | local ref_post_tbl = pico.post.tbl(post_tbl.Board, number, true) 267 | 268 | if ref_post_tbl then 269 | return ("\2\2%d%s"):format( 270 | ref_post_tbl.Board, ref_post_tbl.Parent or number, number, number, append) 271 | else 272 | return ("\2\2%d%s"):format(number, append) 273 | end 274 | end 275 | 276 | local function handle_xbrefs(board, number, append) 277 | if number == "" then 278 | return ("\2\2\2/%s/%s"):format(board, board, append) 279 | else 280 | number = tonumber(number) 281 | end 282 | 283 | local ref_post_tbl = pico.post.tbl(board, number, true) 284 | 285 | if ref_post_tbl then 286 | return ("\2\2\2/%s/%d%s"):format( 287 | board, ref_post_tbl.Parent or number, number, board, number, append) 288 | else 289 | return ("\2\2\2/%s/%d%s"):format(board, number, append) 290 | end 291 | end 292 | 293 | local function handle_url(prev, url) 294 | local balance_tbl = { 295 | ["("] = ")", 296 | ["\1"] = "\2", 297 | ["{"] = "}", 298 | ["["] = "]", 299 | ["\3"] = "\3", 300 | ["\4"] = "\4", 301 | } 302 | local balance = balance_tbl[prev] 303 | local append = "" 304 | if balance then 305 | local first, second = (prev .. url):match("^(%b" .. prev .. balance .. ")(.-)$") 306 | if first then 307 | url = first:sub(2, -2) 308 | append = balance .. second 309 | end 310 | else 311 | local last = url:match("[!,%.:;%?]$") 312 | if last then 313 | url = url:sub(1, -2) 314 | append = last 315 | end 316 | end 317 | return ("%s%s%s"):format(prev, url, url, append) 318 | end 319 | 320 | local blocks = {} 321 | local iblocks = {} 322 | 323 | local function handle_code(b, c, t, e) 324 | return function(block) 325 | b[#b + 1] = t .. block .. e 326 | return c 327 | end 328 | end 329 | 330 | local function insert_escaped(t) 331 | return function() 332 | if #t > 0 then 333 | return table.remove(t, 1) 334 | end 335 | return "" 336 | end 337 | end 338 | 339 | local punct = "!\4#%$%%&\3%(%)%*%+,%-%./:;\1=\2%?@%[\\%]%^_`{|}~" 340 | 341 | local s = ("\n" .. post_tbl.Comment .. "\n") 342 | :gsub("[\1-\8\11-\31\127]", "") 343 | 344 | :gsub("&", "&") 345 | :gsub("<", "\1") 346 | :gsub(">", "\2") 347 | :gsub("'", "\3") 348 | :gsub("\"", "\4") 349 | 350 | :gsub("```\n*(.-)\n*```", handle_code(blocks, "\5", "", "")) 351 | :gsub("([^\n])\5", "%1\n\5") 352 | :gsub("\5([^\n])", "\5\n%1") 353 | :gsub("`([^\n]-)`", handle_code(iblocks, "\6", "", "")) 354 | 355 | :gsub("\2\2\2/([%l%d]+)/(%d-)([%s" .. punct .. "])", handle_xbrefs) 356 | :gsub("\2\2(%d+)([%s" .. punct .. "])", handle_refs) 357 | 358 | :gsub("\3\3\3([^\n]-)\3\3\3", "%1") 359 | :gsub("\3\3([^\n]-)\3\3", "%1") 360 | :gsub("~~([^\n]-)~~", "%1") 361 | :gsub("__([^\n]-)__", "%1") 362 | :gsub("==([^\n]-)==", "%1") 363 | :gsub("%*%*([^\n]-)%*%*", "%1") 364 | :gsub("%(%(%([^\n]-%)%)%)", "%1") 365 | :gsub("\n(\2[^\n]*)", "\n%1") 366 | :gsub("\n(\1[^\n]*)", "\n%1") 367 | 368 | :gsub("(.)(https?://[%w" .. punct .. "]+)", handle_url) 369 | 370 | :gsub("\6", insert_escaped(iblocks)) 371 | :gsub("\5", insert_escaped(blocks)) 372 | 373 | :gsub("\4", """) 374 | :gsub("\3", "'") 375 | :gsub("\2", ">") 376 | :gsub("\1", "<") 377 | 378 | :gsub("^\n+", "") 379 | :gsub("%s+$", "") 380 | :gsub("\n", "
    ") 381 | 382 | return s 383 | end 384 | 385 | function html.threadflags(post_tbl) 386 | printf("%s%s%s%s", 387 | post_tbl.Sticky == 1 and " 📌" or "", 388 | post_tbl.Lock == 1 and " 🔒" or "", 389 | post_tbl.Autosage == 1 and " " or "", 390 | post_tbl.Cycle == 1 and " 🔃" or "") 391 | end 392 | 393 | function html.renderpostfiles(post_tbl, unprivileged) 394 | local function formatfilesize(size) 395 | if size > (1024 * 1024) then 396 | return ("%.2f MiB"):format(size / 1024 / 1024) 397 | elseif size > 1024 then 398 | return ("%.2f KiB"):format(size / 1024) 399 | else 400 | return ("%d B"):format(size) 401 | end 402 | end 403 | 404 | local board = post_tbl.Board 405 | local number = post_tbl.Number 406 | local file_tbl = post_tbl.Files 407 | 408 | for i = 1, #file_tbl do 409 | local file = file_tbl[i] 410 | local filename = file.Name 411 | local downloadname = file.DownloadName 412 | local spoiler = file.Spoiler == 1 413 | local extension = pico.file.extension(filename) 414 | local class = pico.file.class(extension) 415 | 416 | printf("
    ", #file_tbl == 1 and "-single" or "") 417 | printf("
    ") 418 | printf("%s
    %s%s", 419 | filename, html.striphtml(downloadname), 420 | formatfilesize(file.Size), file.Width and (" " .. file.Width .. "x" .. file.Height) or "") 421 | printf(" (dl)", filename, html.striphtml(downloadname)) 422 | 423 | if not unprivileged and permit(board) then 424 | printf(" [U]", board, number, filename) 425 | printf("[S]", board, number, filename) 426 | 427 | if not pico.account.current.Board then 428 | printf("[D]", filename) 429 | end 430 | end 431 | 432 | printf("
    ") 433 | 434 | if class == "image" and extension ~= "svg" then 435 | printf("") 445 | elseif spoiler then 446 | printf("[SPL]", filename) 447 | elseif extension == "svg" then 448 | printf("[SVG]", filename, filename) 449 | elseif extension == "pdf" or extension == "ps" then 450 | local width, height = thumbsize(file.Width or 200, file.Height or 200, 200, 200) 451 | printf("[%s]", 452 | filename, filename, width, height, extension:upper()) 453 | elseif extension == "epub" then 454 | printf("[EPUB]", filename) 455 | elseif extension == "txt" then 456 | printf("[TXT]", filename) 457 | elseif class == "archive" then 458 | printf("[ARCH]", filename) 459 | elseif class == "video" or class == "audio" then 460 | if file.Width and file.Height then 461 | printf("", filename, filename) 462 | else 463 | printf("", filename) 464 | end 465 | end 466 | 467 | printf("
    ") 468 | end 469 | end 470 | 471 | function html.renderpost(post_tbl, overboard, view) 472 | local separate = view == views.RECENT or view == views.MOD_ACTION 473 | local board = post_tbl.Board 474 | local number = post_tbl.Number 475 | local parent = post_tbl.Parent 476 | 477 | printf("", overboard and "" or (" id='%d'"):format(number)) 478 | printf("
    ", (separate or parent) and "" or " thread") 479 | printf("
    ") 480 | 481 | if separate or (overboard and not parent) then 482 | printf(" ") 483 | if parent then 484 | printf("/%s/%d", board, parent, board, parent) 485 | else 486 | printf("/%s/", board, board) 487 | end 488 | printf(" ->") 489 | end 490 | 491 | if post_tbl.Subject and post_tbl.Subject ~= "" then 492 | printf(" %s", html.striphtml(post_tbl.Subject)) 493 | end 494 | 495 | printf(" ") 496 | if post_tbl.Email and post_tbl.Email ~= "" then 497 | printf("%s", 498 | html.striphtml(post_tbl.Email), html.striphtml(post_tbl.Name or defaultpostname)) 499 | else 500 | printf("%s", html.striphtml(post_tbl.Name or defaultpostname)) 501 | end 502 | printf("") 503 | 504 | if post_tbl.Capcode then 505 | local capcode 506 | 507 | if post_tbl.Capcode == "admin" then 508 | capcode = "Administrator" 509 | elseif post_tbl.Capcode == "bo" then 510 | capcode = "Board Owner (" .. post_tbl.CapcodeBoard .. ")" 511 | elseif post_tbl.Capcode == "gvol" then 512 | capcode = "Global Volunteer" 513 | elseif post_tbl.Capcode == "lvol" then 514 | capcode = "Board Volunteer (" .. post_tbl.CapcodeBoard .. ")" 515 | end 516 | 517 | printf(" ## %s", capcode) 518 | end 519 | 520 | printf(" ", html.date(post_tbl.Date)) 521 | printf(" No.%d", 522 | board, parent or number, number, board, parent or number, number) 523 | 524 | html.threadflags(post_tbl) 525 | if view ~= views.MOD_ACTION and permit(board) then 526 | if parent then 527 | printf(" [D]", board, number) 528 | else 529 | printf(" [D]", board, number) 530 | printf("[M]", board, number) 531 | printf("[R]", board, number) 532 | printf("[S]", board, number) 533 | printf("[L]", board, number) 534 | printf("[A]", board, number) 535 | printf("[C]", board, number) 536 | end 537 | end 538 | if view == views.INDEX and not parent then 539 | printf(" [Open]", board, number) 540 | printf(" [Last]", 541 | board, number, post_tbl.PageCount, threadpagesize) 542 | end 543 | 544 | local reflist = pico.post.refs(board, number) 545 | if #reflist > 0 then 546 | printf("") 547 | for i = 1, #reflist do 548 | local ref = reflist[i] 549 | printf(" >>%d", board, parent or number, ref, ref) 550 | end 551 | printf("") 552 | end 553 | 554 | printf("
    ") 555 | html.renderpostfiles(post_tbl, view == views.MOD_ACTION) 556 | printf("
    %s
    ", html.picofmt(post_tbl)) 557 | printf("
    ") 558 | end 559 | 560 | function html.rendercatalog(catalog_tbl) 561 | printf("
    ") 562 | 563 | for i = 1, #catalog_tbl do 564 | local post_tbl = catalog_tbl[i] 565 | local board = post_tbl.Board 566 | local number = post_tbl.Number 567 | 568 | printf("") 612 | 613 | printf("") 614 | end 615 | 616 | printf("
    ") 617 | end 618 | 619 | function html.renderindex(index_tbl, overboard) 620 | for i = 1, #index_tbl do 621 | local thread_tbl = index_tbl[i] 622 | local op_tbl = thread_tbl[1] 623 | 624 | html.renderpost(op_tbl, overboard, views.INDEX) 625 | printf("") 626 | 627 | printf("") 628 | if op_tbl.RepliesOmitted > 0 then 629 | printf("%d %s omitted. ", op_tbl.RepliesOmitted, op_tbl.RepliesOmitted == 1 and "reply" or "replies") 630 | end 631 | printf("View full thread", op_tbl.Board, op_tbl.Number) 632 | printf("") 633 | 634 | for j = 2, #thread_tbl do 635 | printf("") 636 | html.renderpost(thread_tbl[j], overboard, views.INDEX) 637 | end 638 | 639 | if i ~= #index_tbl then 640 | printf("
    ") 641 | end 642 | end 643 | end 644 | 645 | function html.renderrecent(recent_tbl, overboard) 646 | for i = 1, #recent_tbl do 647 | if i ~= 1 then 648 | printf("") 649 | end 650 | html.renderpost(recent_tbl[i], overboard, views.RECENT) 651 | end 652 | end 653 | 654 | function html.renderpages(prefix, page, pagecount) 655 | -- Always show the first, last, and five nearest pages. Only show an ellipses 656 | -- if there would be a discontinuity of two or greater pages on either side of 657 | -- the five page window. 658 | local start, stop 659 | if pagecount <= 7 then 660 | start = 1 661 | stop = pagecount 662 | else 663 | start = math.max(1, page - 2) 664 | stop = math.min(pagecount, page + 2) 665 | if start <= 3 then 666 | start = 1 667 | end 668 | if stop + 2 >= pagecount then 669 | stop = pagecount 670 | end 671 | if stop - start <= 3 then 672 | if start == 1 then 673 | stop = 5 674 | else -- stop == pagecount 675 | start = pagecount - 4 676 | end 677 | end 678 | end 679 | 680 | printf("
    ") 681 | if page > 1 then 682 | printf("<< ", prefix, page - 1) 683 | end 684 | if start > 1 then 685 | printf("[1] ... ", prefix) 686 | end 687 | for i = start, stop do 688 | if i ~= start then 689 | printf(" ") 690 | end 691 | if i == page then 692 | printf("[%d]", i) 693 | else 694 | printf("[%d]", prefix, i, i) 695 | end 696 | end 697 | if stop < pagecount then 698 | printf(" ... [%d] ", prefix, pagecount, pagecount) 699 | end 700 | if page < pagecount then 701 | printf(" >>", prefix, page + 1) 702 | end 703 | printf("
    ") 704 | end 705 | 706 | function html.form.postform(board_tbl, parent) 707 | printf("
    ") 708 | printf( "", board_tbl.Name) 709 | 710 | if parent then 711 | printf("", parent) 712 | end 713 | 714 | printf( "[X]") 715 | printf( "") 716 | printf( "
    ", defaultpostname) 717 | printf( "
    ") 718 | printf( "") 719 | printf( "
    ") 720 | printf( "
    ", board_tbl.PostMaxLength) 721 | 722 | for i = 1, board_tbl.PostMaxFiles do 723 | printf("" .. 724 | "%s", 725 | i, i, i, i, i, i, i, i ~= board_tbl.PostMaxFiles and "
    " or "") 726 | end 727 | 728 | if (not parent and board_tbl.ThreadCaptcha == 1 729 | or parent and board_tbl.PostCaptcha == 1) 730 | and not permit(board_tbl.Name) then 731 | local captchaid, captcha = pico.captcha.create() 732 | printf("", captchaid) 733 | printf("

    ") 734 | printf("", captcha:base64()) 735 | end 736 | 737 | printf("
    ") 738 | end 739 | 740 | function html.form.board_selection(default) 741 | local boards = pico.board.list() 742 | for i = 1, #boards do 743 | local board = boards[i] 744 | printf("", board.Name, board.Name == default and " selected" or "", board.Name, board.Title) 745 | end 746 | end 747 | 748 | function html.form.account_selection(default) 749 | local accounts = pico.account.list() 750 | for i = 1, #accounts do 751 | local account = accounts[i] 752 | printf("", account, account == default and " selected" or "", account) 753 | end 754 | end 755 | 756 | function html.form.theme_selection(default) 757 | local themes = io.popen("ls ./Static/*.css | awk -F/ '!/^\\.\\/Static\\/style\\.css/{sub(/\\.css$/, \"\"); print $3}'") 758 | for theme in themes:lines() do 759 | printf("", theme, theme == default and " selected" or "", theme) 760 | end 761 | end 762 | 763 | function html.form.board_config_select() 764 | printf("
    ") 765 | printf( "") 766 | printf( "
    ") 769 | printf( "") 770 | printf("
    ") 771 | end 772 | 773 | function html.form.board_config(board) 774 | local board_tbl = pico.board.tbl(board) 775 | 776 | printf("
    ") 777 | printf( "", board) 778 | printf( "
    ", html.striphtml(board_tbl.Title)) 779 | printf( "
    ", html.striphtml(board_tbl.Subtitle or "")) 780 | printf( "
    ", board_tbl.Lock == 1 and "checked " or "") 781 | printf( "
    ", board_tbl.DisplayOverboard == 1 and "checked " or "") 782 | printf( "
    ", board_tbl.PostMaxFiles) 783 | printf( "
    ", board_tbl.ThreadMinLength) 784 | printf( "
    ", board_tbl.PostMaxLength) 785 | printf( "
    ", board_tbl.PostMaxNewlines) 786 | printf( "
    ", board_tbl.PostMaxDblNewlines) 787 | printf( "
    ", board_tbl.TPHLimit or "") 788 | printf( "
    ", board_tbl.PPHLimit or "") 789 | printf( "
    ", board_tbl.ThreadCaptcha == 1 and "checked " or "") 790 | printf( "
    ", board_tbl.PostCaptcha == 1 and "checked " or "") 791 | printf( "
    ", board_tbl.CaptchaTriggerTPH or "") 792 | printf( "
    ", board_tbl.CaptchaTriggerPPH or "") 793 | printf( "
    ", board_tbl.BumpLimit or "") 794 | printf( "
    ", board_tbl.PostLimit or "") 795 | printf( "
    ", board_tbl.ThreadLimit or "") 796 | printf( "") 797 | printf("
    ") 798 | end 799 | 800 | function html.form.banner_delete_select() 801 | printf("
    ") 802 | printf( "") 803 | printf( "
    ") 806 | printf( "") 807 | printf("
    ") 808 | end 809 | 810 | function html.form.banner_delete(board, banners) 811 | printf("
    ") 812 | printf( "", board) 813 | printf( "
    ") 814 | for i = 1, #banners do 815 | local banner = banners[i] 816 | printf("", banner, banner, i == 1 and "checked " or "") 817 | printf("
    ", banner, banner, banner) 818 | end 819 | printf( "
    ") 820 | printf( "") 821 | printf("
    ") 822 | end 823 | 824 | -- 825 | -- PAGE DEFINITIONS 826 | -- 827 | 828 | cgi.headers["Content-Type"] = "text/html; charset=utf-8" 829 | cgi.headers["Cache-Control"] = "no-cache" 830 | cgi.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self';" 831 | cgi.headers["Referrer-Policy"] = "no-referrer" 832 | cgi.headers["X-Content-Type-Options"] = "nosniff" 833 | cgi.headers["X-DNS-Prefetch-Control"] = "off" 834 | 835 | local handlers = {} 836 | 837 | local function account_check() 838 | if not pico.account.current then 839 | cgi.headers.Status = "303 See Other" 840 | cgi.headers.Location = "/Mod/login" 841 | pico.finalize() 842 | cgi.finalize() 843 | end 844 | end 845 | 846 | local function tbl_validate(tbl, ...) 847 | for i = 1, select("#", ...) do 848 | local v = tbl[select(i, ...)] 849 | if v == nil or v == "" then 850 | return false 851 | end 852 | end 853 | return true 854 | end 855 | 856 | handlers["/"] = function() 857 | html.begin("welcome") 858 | html.header("Welcome to %s", sitename) 859 | html.container.begin() 860 | printf("%s", pico.global.get("frontpage", "")) 861 | html.cfinish() 862 | end 863 | 864 | handlers["/Mod"] = function() 865 | account_check() 866 | html.brc("dashboard", "Moderation Dashboard") 867 | printf("You are logged in as %s. Your account type is %s.", 868 | pico.account.current.Name, pico.account.current.Type) 869 | html.container.barheader("Global") 870 | html.list.begin() 871 | html.list.entry("Change global announcement") 872 | html.list.entry("Change site name") 873 | html.list.entry("Change front-page content") 874 | html.list.entry("Change default site theme") 875 | html.list.entry("Change default post name") 876 | html.list.entry("Change default board view") 877 | html.list.entry("Change thread page size") 878 | html.list.entry("Change catalog page size") 879 | html.list.entry("Change overboard catalog page size") 880 | html.list.entry("Change index page size") 881 | html.list.entry("Change index window size") 882 | html.list.entry("Change recent posts page size") 883 | html.list.entry("Change mod log page size") 884 | html.list.entry("Change the maximum file size") 885 | html.list.finish() 886 | html.container.barheader("Moderator Tools") 887 | html.list.begin() 888 | html.list.entry("Multi-delete by range") 889 | html.list.entry("Pattern delete") 890 | html.list.finish() 891 | html.container.barheader("Accounts") 892 | html.list.begin() 893 | html.list.entry("Create an account") 894 | html.list.entry("Delete an account") 895 | html.list.entry("Configure an account") 896 | html.list.finish() 897 | html.container.barheader("Boards") 898 | html.list.begin() 899 | html.list.entry("Create a board") 900 | html.list.entry("Delete a board") 901 | html.list.entry("Configure a board") 902 | html.list.entry("Add a banner to a board") 903 | html.list.entry("Delete a banner from a board") 904 | html.list.finish() 905 | html.cfinish() 906 | end 907 | 908 | handlers["/Mod/login"] = function() 909 | if pico.account.current then 910 | cgi.headers.Status = "303 See Other" 911 | cgi.headers.Location = "/Mod" 912 | pico.finalize() 913 | cgi.finalize() 914 | end 915 | 916 | html.brc("login", "Moderator Login") 917 | 918 | if os.getenv("REQUEST_METHOD") == "POST" then 919 | if not tbl_validate(cgi.POST, "username", "password") then 920 | cgi.headers.Status = "400 Bad Request" 921 | html.error("Action failed", "Invalid request") 922 | end 923 | 924 | local session_key, errmsg = pico.account.login(cgi.POST.username, cgi.POST.password) 925 | if session_key then 926 | cgi.headers["Set-Cookie"] = "session_key=" .. session_key .. "; HttpOnly; Path=/; SameSite=Strict" 927 | cgi.headers.Status = "303 See Other" 928 | cgi.headers.Location = "/Mod" 929 | pico.finalize() 930 | cgi.finalize() 931 | else 932 | printf("Cannot log in: %s", errmsg) 933 | end 934 | end 935 | 936 | printf("
    ") 937 | printf( "
    ") 938 | printf( "
    ") 939 | printf( "") 940 | printf("
    ") 941 | 942 | html.cfinish() 943 | end 944 | 945 | handlers["/Mod/logout"] = function() 946 | account_check() 947 | pico.account.logout(cgi.COOKIE.session_key) 948 | cgi.headers["Set-Cookie"] = "session_key=; HttpOnly; Path=/; Expires=Thursday, 1 Jan 1970 00:00:00 GMT; SameSite=Strict" 949 | cgi.headers.Status = "303 See Other" 950 | cgi.headers.Location = "/Overboard" 951 | pico.finalize() 952 | cgi.finalize() 953 | end 954 | 955 | handlers["/Mod/global/([%l%d]+)"] = function(varname) 956 | account_check() 957 | html.brc("change global configuration", "Change global configuration") 958 | 959 | if os.getenv("REQUEST_METHOD") == "POST" then 960 | if not tbl_validate(cgi.POST, "name") then 961 | cgi.headers.Status = "400 Bad Request" 962 | html.error("Action failed", "Invalid request") 963 | end 964 | local result, msg = pico.global.set(cgi.POST.name, cgi.POST.value ~= "" and cgi.POST.value or nil) 965 | printf("%s: %s", result and "Variable set" or "Cannot set variable", msg) 966 | end 967 | 968 | printf("
    ") 969 | printf("", varname) 970 | printf("", varname) 971 | 972 | if varname == "frontpage" or varname == "announcement" then 973 | printf("", 974 | html.striphtml(pico.global.get(varname, "")) or "") 975 | elseif varname == "theme" then 976 | printf("") 979 | elseif varname == "defaultboardview" then 980 | printf("") 985 | else 986 | printf("", 987 | html.striphtml(pico.global.get(varname, "")) or "") 988 | end 989 | printf("
    ") 990 | printf("
    ") 991 | 992 | html.cfinish() 993 | end 994 | 995 | handlers["/Mod/tools/multidelete"] = function() 996 | account_check() 997 | html.brc("multidelete", "Multidelete") 998 | 999 | if os.getenv("REQUEST_METHOD") == "POST" then 1000 | if not tbl_validate(cgi.POST, "board", "ispec", "reason") then 1001 | cgi.headers.Status = "400 Bad Request" 1002 | html.error("Action failed", "Invalid request") 1003 | end 1004 | printf("%s", select(2, pico.post.multidelete(cgi.POST.board, cgi.POST.ispec, cgi.POST.espec ~= "" and cgi.POST.espec or nil, cgi.POST.reason))) 1005 | end 1006 | 1007 | printf("
    ") 1008 | printf( "
    ") 1009 | printf( "
    ") 1010 | printf( "
    ") 1011 | printf( "
    ") 1012 | printf( "") 1013 | printf("
    ") 1014 | 1015 | html.cfinish() 1016 | end 1017 | 1018 | handlers["/Mod/tools/pattdelete"] = function() 1019 | account_check() 1020 | html.brc("pattern delete", "Pattern delete") 1021 | 1022 | if os.getenv("REQUEST_METHOD") == "POST" then 1023 | if not tbl_validate(cgi.POST, "pattern", "reason") then 1024 | cgi.headers.Status = "400 Bad Request" 1025 | html.error("Action failed", "Invalid request") 1026 | end 1027 | printf("%s", select(2, pico.post.pattdelete(cgi.POST.pattern, cgi.POST.reason))) 1028 | end 1029 | 1030 | printf("
    ") 1031 | printf( "
    ") 1032 | printf( "
    ") 1033 | printf( "") 1034 | printf("
    ") 1035 | 1036 | html.cfinish() 1037 | end 1038 | 1039 | handlers["/Mod/account/create"] = function() 1040 | account_check() 1041 | html.brc("create account", "Create account") 1042 | 1043 | if os.getenv("REQUEST_METHOD") == "POST" then 1044 | if not tbl_validate(cgi.POST, "name", "password", "type") then 1045 | cgi.headers.Status = "400 Bad Request" 1046 | html.error("Action failed", "Invalid request") 1047 | end 1048 | printf("%s", select(2, pico.account.create(cgi.POST.name, cgi.POST.password, cgi.POST.type, cgi.POST.board ~= "" and cgi.POST.board or nil))) 1049 | end 1050 | 1051 | printf("
    ") 1052 | printf( "
    ") 1053 | printf( "
    ") 1054 | printf( "") 1055 | printf( "
    ") 1061 | printf( "
    ") 1062 | printf( "") 1063 | printf("
    ") 1064 | 1065 | html.cfinish() 1066 | end 1067 | 1068 | handlers["/Mod/account/delete"] = function() 1069 | account_check() 1070 | html.brc("delete account", "Delete account") 1071 | 1072 | if os.getenv("REQUEST_METHOD") == "POST" then 1073 | if not tbl_validate(cgi.POST, "name", "reason") then 1074 | cgi.headers.Status = "400 Bad Request" 1075 | html.error("Action failed", "Invalid request") 1076 | end 1077 | local result, msg = pico.account.delete(cgi.POST.name, cgi.POST.reason) 1078 | printf("%s%s", result and "" or "Cannot delete account: ", msg) 1079 | end 1080 | 1081 | printf("
    ") 1082 | printf( "") 1083 | printf( "
    ") 1086 | printf( "
    ") 1087 | printf( "") 1088 | printf("
    ") 1089 | 1090 | html.cfinish() 1091 | end 1092 | 1093 | handlers["/Mod/account/config"] = function() 1094 | account_check() 1095 | html.brc("configure account", "Configure account") 1096 | 1097 | if os.getenv("REQUEST_METHOD") == "POST" then 1098 | if not tbl_validate(cgi.POST, "name", "password") then 1099 | cgi.headers.Status = "400 Bad Request" 1100 | html.error("Action failed", "Invalid request") 1101 | end 1102 | printf("%s", select(2, pico.account.changepass(cgi.POST.name, cgi.POST.password))) 1103 | end 1104 | 1105 | printf("
    ") 1106 | printf( "") 1107 | printf( "
    ") 1110 | printf( "
    ") 1111 | printf( "") 1112 | printf("
    ") 1113 | 1114 | html.cfinish() 1115 | end 1116 | 1117 | handlers["/Mod/board/create"] = function() 1118 | account_check() 1119 | html.brc("create board", "Create board") 1120 | 1121 | if os.getenv("REQUEST_METHOD") == "POST" then 1122 | if not tbl_validate(cgi.POST, "name", "title") then 1123 | cgi.headers.Status = "400 Bad Request" 1124 | html.error("Action failed", "Invalid request") 1125 | end 1126 | local result, msg = pico.board.create(cgi.POST.name, cgi.POST.title, cgi.POST.subtitle ~= "" and cgi.POST.subtitle or nil) 1127 | printf("%s%s", result and "" or "Cannot create board: ", msg) 1128 | end 1129 | 1130 | printf("
    ") 1131 | printf( "
    ") 1132 | printf( "
    ") 1133 | printf( "
    ") 1134 | printf( "") 1135 | printf("
    ") 1136 | 1137 | html.cfinish() 1138 | end 1139 | 1140 | handlers["/Mod/board/delete"] = function() 1141 | account_check() 1142 | html.brc("delete board", "Delete board") 1143 | 1144 | if os.getenv("REQUEST_METHOD") == "POST" then 1145 | if not tbl_validate(cgi.POST, "name", "reason") then 1146 | cgi.headers.Status = "400 Bad Request" 1147 | html.error("Action failed", "Invalid request") 1148 | end 1149 | local result, msg = pico.board.delete(cgi.POST.name, cgi.POST.reason) 1150 | printf("%s%s", result and "" or "Cannot delete board: ", msg) 1151 | end 1152 | 1153 | printf("
    ") 1154 | printf( "") 1155 | printf( "
    ") 1158 | printf( "
    ") 1159 | printf( "") 1160 | printf("
    ") 1161 | 1162 | html.cfinish() 1163 | end 1164 | 1165 | handlers["/Mod/board/config"] = function() 1166 | account_check() 1167 | html.brc("configure board", "Configure board") 1168 | 1169 | if os.getenv("REQUEST_METHOD") == "POST" then 1170 | if not tbl_validate(cgi.POST, "Name") then 1171 | cgi.headers.Status = "400 Bad Request" 1172 | html.error("Action failed", "Invalid request") 1173 | end 1174 | if pico.board.exists(cgi.POST.Name) then 1175 | if tbl_validate(cgi.POST, "Title") then 1176 | cgi.POST.Subtitle = cgi.POST.Subtitle ~= "" and cgi.POST.Subtitle or nil 1177 | cgi.POST.TPHLimit = cgi.POST.TPHLimit ~= "" and cgi.POST.TPHLimit or nil 1178 | cgi.POST.PPHLimit = cgi.POST.PPHLimit ~= "" and cgi.POST.PPHLimit or nil 1179 | cgi.POST.CaptchaTriggerTPH = cgi.POST.CaptchaTriggerTPH ~= "" and cgi.POST.CaptchaTriggerTPH or nil 1180 | cgi.POST.CaptchaTriggerPPH = cgi.POST.CaptchaTriggerPPH ~= "" and cgi.POST.CaptchaTriggerPPH or nil 1181 | cgi.POST.BumpLimit = cgi.POST.BumpLimit ~= "" and cgi.POST.BumpLimit or nil 1182 | cgi.POST.PostLimit = cgi.POST.PostLimit ~= "" and cgi.POST.PostLimit or nil 1183 | cgi.POST.ThreadLimit = cgi.POST.ThreadLimit ~= "" and cgi.POST.ThreadLimit or nil 1184 | local result, msg = pico.board.configure(cgi.POST) 1185 | printf("%s%s", result and "" or "Cannot configure board: ", msg) 1186 | end 1187 | html.form.board_config(cgi.POST.Name) 1188 | else 1189 | printf("Cannot configure board: Board does not exist") 1190 | html.form.board_config_select() 1191 | end 1192 | else 1193 | html.form.board_config_select() 1194 | end 1195 | 1196 | html.cfinish() 1197 | end 1198 | 1199 | handlers["/Mod/banner/add"] = function() 1200 | account_check() 1201 | html.brc("add a banner", "Add a banner") 1202 | 1203 | if os.getenv("REQUEST_METHOD") == "POST" then 1204 | if not tbl_validate(cgi.POST, "board", "file") then 1205 | cgi.headers.Status = "400 Bad Request" 1206 | html.error("Action failed", "Invalid request") 1207 | end 1208 | local result, msg = pico.board.banner.add(cgi.POST.board, cgi.POST.file) 1209 | printf("%s%s", result and "" or "Cannot add banner: ", msg) 1210 | end 1211 | 1212 | printf("
    ") 1213 | printf( "") 1214 | printf( "
    ") 1217 | printf( "
    ") 1218 | printf( "") 1219 | printf("
    ") 1220 | 1221 | html.cfinish() 1222 | end 1223 | 1224 | handlers["/Mod/banner/delete"] = function() 1225 | account_check() 1226 | html.brc("delete a banner", "Delete a banner") 1227 | 1228 | if os.getenv("REQUEST_METHOD") == "POST" then 1229 | if not tbl_validate(cgi.POST, "board") then 1230 | cgi.headers.Status = "400 Bad Request" 1231 | html.error("Action failed", "Invalid request") 1232 | end 1233 | if pico.board.exists(cgi.POST.board) then 1234 | local banners = pico.board.banner.list(cgi.POST.board) 1235 | if #banners > 0 then 1236 | if tbl_validate(cgi.POST, "file", "reason") then 1237 | local result, msg = pico.board.banner.delete(cgi.POST.board, cgi.POST.file, cgi.POST.reason) 1238 | printf("%s%s", result and "" or "Cannot delete banner: ", msg) 1239 | end 1240 | html.form.banner_delete(cgi.POST.board, banners) 1241 | else 1242 | printf("Cannot delete banners: Board contains no banners") 1243 | html.form.banner_delete_select() 1244 | end 1245 | else 1246 | printf("Cannot delete banners: Board does not exist") 1247 | html.form.banner_delete_select() 1248 | end 1249 | else 1250 | html.form.banner_delete_select() 1251 | end 1252 | 1253 | html.cfinish() 1254 | end 1255 | 1256 | handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] = function(operation, board, number, file) 1257 | account_check() 1258 | html.begin("%s post", operation) 1259 | html.header("Modify or Delete a Post") 1260 | html.container.begin() 1261 | 1262 | local post_tbl = pico.post.tbl(board, number) 1263 | if not post_tbl then 1264 | html.error("Action failed", "Cannot find post %d on board %s", number, board) 1265 | end 1266 | 1267 | if os.getenv("REQUEST_METHOD") == "POST" then 1268 | if not tbl_validate(cgi.POST, "reason") then 1269 | cgi.headers.Status = "400 Bad Request" 1270 | html.error("Action failed", "Invalid request") 1271 | end 1272 | 1273 | local result, msg 1274 | if operation == "delete" then 1275 | result, msg = pico.post.delete(board, number, cgi.POST.reason) 1276 | elseif operation == "unlink" then 1277 | result, msg = pico.post.unlink(board, number, file, cgi.POST.reason) 1278 | elseif operation == "spoiler" then 1279 | result, msg = pico.post.spoiler(board, number, file, cgi.POST.reason) 1280 | elseif operation == "move" then 1281 | if not tbl_validate(cgi.POST, "destination") then 1282 | cgi.headers.Status = "400 Bad Request" 1283 | html.error("Action failed", "Invalid request") 1284 | end 1285 | result, msg = pico.thread.move(board, number, cgi.POST.destination, cgi.POST.reason) 1286 | elseif operation == "merge" then 1287 | if not (tbl_validate(cgi.POST, "destination") and tonumber(cgi.POST.destination)) then 1288 | cgi.headers.Status = "400 Bad Request" 1289 | html.error("Action failed", "Invalid request") 1290 | end 1291 | result, msg = pico.thread.merge(board, number, tonumber(cgi.POST.destination), cgi.POST.reason) 1292 | else 1293 | result, msg = pico.thread.toggle(operation, board, number, cgi.POST.reason) 1294 | end 1295 | 1296 | if not result then 1297 | html.error("Action failed", "Backend returned error: %s", msg) 1298 | end 1299 | 1300 | cgi.headers.Status = "303 See Other" 1301 | 1302 | if operation == "move" then 1303 | cgi.headers.Location = "/" .. cgi.POST.destination 1304 | elseif operation == "merge" then 1305 | cgi.headers.Location = "/" .. board .. "/" .. cgi.POST.destination 1306 | elseif operation == "delete" then 1307 | cgi.headers.Location = 1308 | post_tbl.Parent and ("/" .. board .. "/" .. post_tbl.Parent) 1309 | or ("/" .. board) 1310 | else 1311 | cgi.headers.Location = 1312 | post_tbl.Parent and ("/" .. board .. "/" .. post_tbl.Parent) 1313 | or ("/" .. board .. "/" .. post_tbl.Number) 1314 | end 1315 | 1316 | pico.finalize() 1317 | cgi.finalize() 1318 | end 1319 | 1320 | local thread = operation == "sticky" or 1321 | operation == "lock" or 1322 | operation == "autosage" or 1323 | operation == "cycle" 1324 | local toggle = thread or operation == "spoiler" 1325 | thread = (thread or operation == "move" or operation == "merge" or operation == "delete") and 1326 | not post_tbl.Parent 1327 | 1328 | printf("You are about to %s%s the following %s:", 1329 | toggle and ("toggle the " .. operation .. " attribute for") 1330 | or ("" .. operation .. ""), 1331 | file and (" " .. file .. " from") or "", 1332 | thread and "thread" or "post") 1333 | html.renderpost(post_tbl, true, views.MOD_ACTION) 1334 | 1335 | printf("
    ") 1336 | if operation == "move" then 1337 | printf("
    ") 1338 | printf("
    ") 1339 | elseif operation == "merge" then 1340 | printf("
    ") 1341 | printf("
    ") 1342 | else 1343 | printf("
    ") 1344 | end 1345 | printf("") 1346 | printf("
    ") 1347 | 1348 | html.cfinish() 1349 | end 1350 | 1351 | handlers["/Mod/post/(unlink)/([%l%d]+)/(%d+)/([%l%d.]+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1352 | handlers["/Mod/post/(spoiler)/([%l%d]+)/(%d+)/([%l%d.]+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1353 | handlers["/Mod/post/(move)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1354 | handlers["/Mod/post/(merge)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1355 | handlers["/Mod/post/(sticky)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1356 | handlers["/Mod/post/(lock)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1357 | handlers["/Mod/post/(autosage)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1358 | handlers["/Mod/post/(cycle)/([%l%d]+)/(%d+)"] = handlers["/Mod/post/(delete)/([%l%d]+)/(%d+)"] 1359 | 1360 | handlers["/Mod/file/delete/([%l%d.]+)"] = function(file) 1361 | account_check() 1362 | html.brc("delete file", "Delete file") 1363 | 1364 | if os.getenv("REQUEST_METHOD") == "POST" then 1365 | if not tbl_validate(cgi.POST, "reason") then 1366 | cgi.headers.Status = "400 Bad Request" 1367 | html.error("Action failed", "Invalid request") 1368 | end 1369 | 1370 | local result, msg = pico.file.delete(file, cgi.POST.reason) 1371 | if not result then 1372 | html.error("Action failed", "Backend returned error: %s", msg) 1373 | end 1374 | 1375 | cgi.headers.Status = "303 See Other" 1376 | cgi.headers.Location = "/Overboard" 1377 | pico.finalize() 1378 | cgi.finalize() 1379 | end 1380 | 1381 | printf("You are about to delete the file %s from all boards.", file) 1382 | printf("
    ") 1383 | printf( "
    ") 1384 | printf( "") 1385 | printf("
    ") 1386 | 1387 | html.cfinish() 1388 | end 1389 | 1390 | handlers["/Log"] = function(page) 1391 | html.brc("logs", "Moderation Logs", "wide") 1392 | 1393 | page = tonumber(page) or 1 1394 | if page <= 0 then 1395 | cgi.headers.Status = "404 Not Found" 1396 | html.error("Page not found", "Page number too low: %s", page) 1397 | end 1398 | 1399 | local log_tbl, pagecount = pico.log.retrieve(page) 1400 | if page > pagecount then 1401 | cgi.headers.Status = "404 Not Found" 1402 | html.error("Page not found", "Page number too high: %s", page) 1403 | end 1404 | 1405 | if pagecount > 1 then 1406 | html.renderpages("/Log", page, pagecount) 1407 | printf("
    ") 1408 | end 1409 | 1410 | html.table.begin("Account", "Board", "Date", "Description") 1411 | for i = 1, #log_tbl do 1412 | local entry = log_tbl[i] 1413 | html.table.entry(entry.Account or "SYSTEM", 1414 | not entry.Board and "GLOBAL" or ("/%s/"):format(entry.Board, entry.Board), 1415 | html.date(entry.Date), 1416 | html.striphtml(entry.Description)) 1417 | end 1418 | html.table.finish() 1419 | 1420 | if pagecount > 1 then 1421 | printf("
    ") 1422 | html.renderpages("/Log", page, pagecount) 1423 | end 1424 | html.cfinish() 1425 | end 1426 | 1427 | handlers["/Log/(%d+)"] = handlers["/Log"] 1428 | 1429 | handlers["/Boards"] = function() 1430 | html.brc("boards", "Board List", "wide") 1431 | html.table.begin("Board", "Title", "Subtitle", "TPW (7d)", "TPD (1d)", "PPD (7d)", "PPD (1d)", "PPH (1h)", "Total Posts", "Last Activity") 1432 | 1433 | local g_tpw7d = 0 1434 | local g_tpd1d = 0 1435 | local g_ppd7d = 0 1436 | local g_ppd1d = 0 1437 | local g_pph1h = 0 1438 | local g_total = 0 1439 | local g_last = nil 1440 | local board_list_tbl = pico.board.list() 1441 | for i = 1, #board_list_tbl do 1442 | local board_tbl = board_list_tbl[i] 1443 | local board = board_tbl.Name 1444 | local title = html.striphtml(board_tbl.Title) 1445 | local subtitle = html.striphtml(board_tbl.Subtitle or "") 1446 | local tpw7d = pico.board.stats.threadrate(board, 24 * 7, 1) 1447 | local tpd1d = pico.board.stats.threadrate(board, 24, 1) 1448 | local ppd7d = pico.board.stats.postrate(board, 24, 7) 1449 | local ppd1d = pico.board.stats.postrate(board, 24, 1) 1450 | local pph1h = pico.board.stats.postrate(board, 1, 1) 1451 | local total = pico.board.stats.totalposts(board) 1452 | local last = pico.board.stats.lastbumpdate(board) 1453 | 1454 | g_tpw7d = g_tpw7d + tpw7d 1455 | g_tpd1d = g_tpd1d + tpd1d 1456 | g_ppd7d = g_ppd7d + ppd7d 1457 | g_ppd1d = g_ppd1d + ppd1d 1458 | g_pph1h = g_pph1h + pph1h 1459 | g_total = g_total + total 1460 | if not g_last then 1461 | g_last = last 1462 | elseif last then 1463 | g_last = math.max(g_last, last) 1464 | end 1465 | 1466 | html.table.entry(("/%s/"):format(board, title, board), 1467 | title, subtitle, tpw7d, tpd1d, ppd7d, ppd1d, pph1h, total, last and html.date(last, true) or "") 1468 | end 1469 | 1470 | html.table.entry("GLOBAL", "", "", g_tpw7d, g_tpd1d, g_ppd7d, g_ppd1d, g_pph1h, g_total, g_last and html.date(g_last, true) or "") 1471 | html.table.finish() 1472 | html.cfinish() 1473 | end 1474 | 1475 | local function overboard_header() 1476 | html.begin("overboard") 1477 | html.header("%s Overboard", sitename) 1478 | html.announcement() 1479 | printf("[Catalog] ") 1480 | printf("[Index] ") 1481 | printf("[Recent] ") 1482 | printf("[Update]
    ") 1483 | end 1484 | 1485 | local function board_header(board_tbl) 1486 | if not board_tbl then 1487 | cgi.headers.Status = "404 Not Found" 1488 | html.error("Board Not Found", "The board you specified does not exist.") 1489 | end 1490 | 1491 | local board = board_tbl.Name 1492 | 1493 | html.begin("/%s/", board) 1494 | local banner = pico.board.banner.get(board) 1495 | if banner then 1496 | printf("", banner) 1497 | end 1498 | html.header("/%s/ - %s", 1499 | board, board, html.striphtml(board_tbl.Title)) 1500 | html.subheader("%s", html.striphtml(board_tbl.Subtitle or "")) 1501 | html.announcement() 1502 | if board_tbl.Lock ~= 1 or permit(board) then 1503 | printf("[Start a New Thread]") 1504 | html.form.postform(board_tbl) 1505 | end 1506 | printf("[Catalog] ", board) 1507 | printf("[Index] ", board) 1508 | printf("[Recent] ", board) 1509 | printf("[Update]
    ") 1510 | end 1511 | 1512 | local function board_view(board_func, render_func, view) 1513 | return function(board, page) 1514 | local overboard = board == "Overboard" 1515 | local boardval = not overboard and board or nil 1516 | 1517 | page = tonumber(page) or 1 1518 | if page <= 0 then 1519 | cgi.headers.Status = "404 Not Found" 1520 | html.error("Page not found", "Page number too low: %s", page) 1521 | end 1522 | 1523 | local tbl, pagecount, msg = board_func(boardval, page) 1524 | if not tbl then 1525 | cgi.headers.Status = "404 Not Found" 1526 | html.error("Page not found", "Cannot display %s: %s", view, msg) 1527 | elseif page > pagecount then 1528 | cgi.headers.Status = "404 Not Found" 1529 | html.error("Page not found", "Page number too high: %s", page) 1530 | end 1531 | 1532 | if overboard then 1533 | overboard_header() 1534 | else 1535 | board_header(pico.board.tbl(board)) 1536 | end 1537 | render_func(tbl, overboard) 1538 | if pagecount > 1 then 1539 | printf("
    ") 1540 | html.renderpages(("/%s/%s"):format(board, view), page, pagecount) 1541 | end 1542 | html.finish() 1543 | end 1544 | end 1545 | 1546 | handlers["/(Overboard)/catalog"] = board_view(pico.board.catalog, html.rendercatalog, "catalog") 1547 | handlers["/(Overboard)/catalog/(%d)"] = handlers["/(Overboard)/catalog"] 1548 | handlers["/([%l%d]+)/catalog"] = handlers["/(Overboard)/catalog"] 1549 | handlers["/([%l%d]+)/catalog/(%d)"] = handlers["/(Overboard)/catalog"] 1550 | 1551 | handlers["/(Overboard)/index"] = board_view(pico.board.index, html.renderindex, "index") 1552 | handlers["/(Overboard)/index/(%d+)"] = handlers["/(Overboard)/index"] 1553 | handlers["/([%l%d]+)/index"] = handlers["/(Overboard)/index"] 1554 | handlers["/([%l%d]+)/index/(%d+)"] = handlers["/(Overboard)/index"] 1555 | 1556 | handlers["/(Overboard)/recent"] = board_view(pico.board.recent, html.renderrecent, "recent") 1557 | handlers["/(Overboard)/recent/(%d+)"] = handlers["/(Overboard)/recent"] 1558 | handlers["/([%l%d]+)/recent"] = handlers["/(Overboard)/recent"] 1559 | handlers["/([%l%d]+)/recent/(%d+)"] = handlers["/(Overboard)/recent"] 1560 | 1561 | if defaultboardview == "index" then 1562 | handlers["/(Overboard)"] = handlers["/(Overboard)/index"] 1563 | handlers["/(Overboard)/(%d+)"] = handlers["/(Overboard)/index"] 1564 | handlers["/([%l%d]+)/?"] = handlers["/([%l%d]+)/index"] 1565 | elseif defaultboardview == "recent" then 1566 | handlers["/(Overboard)"] = handlers["/(Overboard)/recent"] 1567 | handlers["/(Overboard)/(%d+)"] = handlers["/(Overboard)/recent"] 1568 | handlers["/([%l%d]+)/?"] = handlers["/([%l%d]+)/recent"] 1569 | else 1570 | handlers["/(Overboard)"] = handlers["/(Overboard)/catalog"] 1571 | handlers["/(Overboard)/(%d+)"] = handlers["/(Overboard)/catalog"] 1572 | handlers["/([%l%d]+)/?"] = handlers["/([%l%d]+)/catalog"] 1573 | end 1574 | 1575 | handlers["/([%l%d]+)/(%d+)"] = function(board, number, page) 1576 | local board_tbl = pico.board.tbl(board) 1577 | 1578 | if not board_tbl then 1579 | cgi.headers.Status = "404 Not Found" 1580 | html.error("Board Not Found", "The board you specified does not exist.") 1581 | end 1582 | 1583 | if page then 1584 | page = tonumber(page) 1585 | if page <= 0 then 1586 | cgi.headers.Status = "404 Not Found" 1587 | html.error("Page not found", "Page number too low: %s", page) 1588 | end 1589 | end 1590 | 1591 | local thread_tbl, pagecount, msg = pico.thread.tbl(board, number, page) 1592 | 1593 | if not thread_tbl then 1594 | local post_tbl = pico.post.tbl(board, number) 1595 | 1596 | if not post_tbl then 1597 | cgi.headers.Status = "404 Not Found" 1598 | html.error("Thread Not Found", "Cannot display thread: %s", msg) 1599 | end 1600 | 1601 | cgi.headers.Status = "301 Moved Permanently" 1602 | cgi.headers.Location = ("/%s/%d#%d"):format(board, post_tbl.Parent, post_tbl.Number) 1603 | pico.finalize() 1604 | cgi.finalize() 1605 | end 1606 | 1607 | if page then 1608 | if page > pagecount then 1609 | cgi.headers.Status = "404 Not Found" 1610 | html.error("Page not found", "Page number too high: %s", page) 1611 | end 1612 | end 1613 | 1614 | local op_tbl = thread_tbl[1] 1615 | html.begin("/%s/ - %s", board, (op_tbl.Subject and op_tbl.Subject ~= "") 1616 | and html.striphtml(op_tbl.Subject) 1617 | or html.striphtml(op_tbl.Comment:sub(1, 64))) 1618 | local banner = pico.board.banner.get(board) 1619 | if banner then 1620 | printf("", banner) 1621 | end 1622 | html.header("/%s/ - %s", 1623 | board, board, html.striphtml(board_tbl.Title)) 1624 | html.subheader("%s", html.striphtml(board_tbl.Subtitle or "")) 1625 | html.announcement() 1626 | local replyable = (board_tbl.Lock ~= 1 and op_tbl.Lock ~= 1) or permit(board_tbl.Name) 1627 | if replyable then 1628 | printf("[Make a Post]") 1629 | html.form.postform(board_tbl, number) 1630 | end 1631 | printf("
    ") 1632 | 1633 | for i = 1, #thread_tbl do 1634 | if i ~= 1 then 1635 | printf("") 1636 | end 1637 | html.renderpost(thread_tbl[i], false, views.THREAD) 1638 | end 1639 | 1640 | printf("
    ") 1641 | if page then 1642 | html.renderpages(("/%s/%d"):format(board, number), page, pagecount) 1643 | end 1644 | printf("[Catalog] ", board) 1645 | printf("[Index] ", board) 1646 | printf("[Recent] ", board) 1647 | 1648 | printf("") 1649 | printf("[Update] ") 1650 | if replyable then 1651 | printf("[Reply] ") 1652 | end 1653 | local reply_count = op_tbl.ReplyCount 1654 | printf("%d %s", reply_count, reply_count == 1 and "reply" or "replies") 1655 | printf("") 1656 | 1657 | html.finish() 1658 | end 1659 | 1660 | handlers["/([%l%d]+)/(%d+)/(%d+)"] = handlers["/([%l%d]+)/(%d+)"] 1661 | 1662 | handlers["/Theme"] = function() 1663 | html.brc("change theme configuration", "Change theme configuration") 1664 | 1665 | if os.getenv("REQUEST_METHOD") == "POST" then 1666 | if not tbl_validate(cgi.POST, "theme") then 1667 | cgi.headers.Status = "400 Bad Request" 1668 | html.error("Action failed", "Invalid request") 1669 | end 1670 | 1671 | if not io.exists("./Static/" .. cgi.POST.theme .. ".css") then 1672 | cgi.headers.Status = "400 Bad Request" 1673 | html.error("Theme not found", "Cannot find theme file: %s", cgi.POST.theme) 1674 | end 1675 | 1676 | cgi.headers["Set-Cookie"] = "theme=" .. cgi.POST.theme .. "; HttpOnly; Path=/; SameSite=Strict" 1677 | cgi.headers.Status = "303 See Other" 1678 | cgi.headers.Location = "/" 1679 | pico.finalize() 1680 | cgi.finalize() 1681 | end 1682 | 1683 | printf("
    ") 1684 | printf( "") 1685 | printf( "") 1688 | printf( "
    ") 1689 | printf("
    ") 1690 | 1691 | html.cfinish() 1692 | end 1693 | 1694 | handlers["/Post"] = function() 1695 | if os.getenv("REQUEST_METHOD") ~= "POST" then 1696 | cgi.headers.Status = "400 Bad Request" 1697 | html.error("Action failed", "Invalid request") 1698 | end 1699 | 1700 | local board_tbl = pico.board.tbl(cgi.POST.board) 1701 | if not board_tbl then 1702 | cgi.headers.Status = "400 Bad Request" 1703 | html.error("Board Not Found", "The board you specified does not exist.") 1704 | end 1705 | 1706 | local files = {} 1707 | for i = 1, board_tbl.PostMaxFiles do 1708 | local file = cgi.FILE["file" .. i] 1709 | if file then 1710 | local hash, msg = pico.file.add(file.file) 1711 | if not hash then 1712 | cgi.headers.Status = "400 Bad Request" 1713 | html.error("File Upload Error", "Cannot add file #%d: %s", i, msg) 1714 | end 1715 | files[#files + 1] = { Name = file.filename, Hash = hash, Spoiler = cgi.POST["spoiler" .. i] and 1 or 0 } 1716 | end 1717 | end 1718 | 1719 | local number, msg = pico.post.create( 1720 | cgi.POST.board, tonumber(cgi.POST.parent), 1721 | cgi.POST.name ~= "" and cgi.POST.name or nil, 1722 | cgi.POST.email ~= "" and cgi.POST.email or nil, 1723 | cgi.POST.subject ~= "" and cgi.POST.subject or nil, 1724 | cgi.POST.comment, files, 1725 | cgi.POST.captchaid, cgi.POST.captcha 1726 | ) 1727 | 1728 | if not number then 1729 | cgi.headers.Status = "400 Bad Request" 1730 | html.error("Posting Error", "Cannot make post: %s", msg) 1731 | end 1732 | 1733 | cgi.headers.Status = "303 See Other" 1734 | 1735 | if cgi.POST.parent then 1736 | cgi.headers.Location = "/" .. cgi.POST.board .. "/" .. cgi.POST.parent .. "#" .. number 1737 | else 1738 | cgi.headers.Location = "/" .. cgi.POST.board .. "/" .. number 1739 | end 1740 | end 1741 | 1742 | local path_info = os.getenv("PATH_INFO") 1743 | if not path_info then 1744 | cgi.headers.Status = "500 Internal Server Error" 1745 | html.error("Internal Server Error", "Request path was not provided") 1746 | end 1747 | 1748 | for patt, func in pairs(handlers) do 1749 | patt = "^" .. patt .. "$" 1750 | 1751 | if path_info:match(patt) then 1752 | path_info:gsub(patt, func) 1753 | pico.finalize() 1754 | cgi.finalize() 1755 | end 1756 | end 1757 | 1758 | cgi.headers.Status = "404 Not Found" 1759 | html.error("Page Not Found", "The specified page does not exist.") 1760 | -------------------------------------------------------------------------------- /picochan.sql: -------------------------------------------------------------------------------- 1 | PRAGMA application_id = 37564; 2 | PRAGMA user_version = 2; 3 | 4 | CREATE TABLE Boards ( 5 | Name TEXT NOT NULL UNIQUE PRIMARY KEY CHECK(LENGTH(Name) BETWEEN 1 AND 8), 6 | Title TEXT NOT NULL UNIQUE CHECK(LENGTH(Title) BETWEEN 1 AND 32), 7 | Subtitle TEXT CHECK(Subtitle IS NULL OR LENGTH(Subtitle) BETWEEN 1 AND 64), 8 | MaxPostNumber INTEGER NOT NULL DEFAULT 0 CHECK(MaxPostNumber >= 0), 9 | Lock BOOLEAN NOT NULL DEFAULT FALSE, 10 | DisplayOverboard BOOLEAN NOT NULL DEFAULT TRUE, 11 | PostMaxFiles INTEGER NOT NULL DEFAULT 5 CHECK(PostMaxFiles >= 0), 12 | ThreadMinLength INTEGER NOT NULL DEFAULT 1 CHECK(ThreadMinLength >= 0), 13 | PostMaxLength INTEGER NOT NULL DEFAULT 8192 CHECK(PostMaxLength >= 0), 14 | PostMaxNewlines INTEGER NOT NULL DEFAULT 64 CHECK(PostMaxNewlines >= 0), 15 | PostMaxDblNewlines INTEGER NOT NULL DEFAULT 16 CHECK(PostMaxDblNewlines >= 0), 16 | TPHLimit INTEGER DEFAULT NULL CHECK(TPHLimit IS NULL OR TPHLimit > 0), 17 | PPHLimit INTEGER DEFAULT NULL CHECK(PPHLimit IS NULL OR PPHLimit > 0), 18 | ThreadCaptcha BOOLEAN NOT NULL DEFAULT FALSE, 19 | PostCaptcha BOOLEAN NOT NULL DEFAULT FALSE, 20 | CaptchaTriggerTPH INTEGER DEFAULT NULL CHECK(CaptchaTriggerTPH IS NULL OR CaptchaTriggerTPH > 0), 21 | CaptchaTriggerPPH INTEGER DEFAULT NULL CHECK(CaptchaTriggerPPH IS NULL OR CaptchaTriggerPPH > 0), 22 | BumpLimit INTEGER DEFAULT 200 CHECK(BumpLimit IS NULL OR BumpLimit >= 0), 23 | PostLimit INTEGER DEFAULT 250 CHECK(PostLimit IS NULL OR PostLimit >= 0), 24 | ThreadLimit INTEGER DEFAULT 500 CHECK(ThreadLimit IS NULL OR ThreadLimit > 0) 25 | ) WITHOUT ROWID; 26 | 27 | CREATE TABLE Posts ( 28 | Board TEXT NOT NULL, 29 | Number INTEGER NOT NULL DEFAULT 0 CHECK(Number >= 0), 30 | Parent INTEGER DEFAULT NULL, 31 | Date DATETIME NOT NULL DEFAULT 0, 32 | Name TEXT DEFAULT NULL CHECK(Name IS NULL OR LENGTH(Name) <= 64), 33 | Email TEXT DEFAULT NULL CHECK(Email IS NULL OR LENGTH(Email) <= 64), 34 | Subject TEXT DEFAULT NULL CHECK(Subject IS NULL OR LENGTH(Subject) <= 128), 35 | Capcode TEXT DEFAULT NULL, 36 | CapcodeBoard TEXT DEFAULT NULL, 37 | Comment TEXT NOT NULL DEFAULT '' CHECK(LENGTH(Comment) <= 32768), 38 | 39 | PRIMARY KEY (Board, Number), 40 | FOREIGN KEY (Board) REFERENCES Boards (Name) ON UPDATE CASCADE ON DELETE CASCADE, 41 | FOREIGN KEY (Board, Parent) REFERENCES Posts (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE, 42 | FOREIGN KEY (Board, Parent) REFERENCES Threads (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE, 43 | CHECK((Capcode IN ('admin', 'gvol') AND CapcodeBoard IS NULL) OR (Capcode IN ('bo', 'lvol') AND CapcodeBoard IS NOT NULL)) 44 | ); 45 | 46 | CREATE TABLE Threads ( 47 | Board TEXT NOT NULL, 48 | Number INTEGER NOT NULL, 49 | LastBumpDate DATETIME NOT NULL DEFAULT 0, 50 | Sticky BOOLEAN NOT NULL DEFAULT FALSE, 51 | Lock BOOLEAN NOT NULL DEFAULT FALSE, 52 | Autosage BOOLEAN NOT NULL DEFAULT FALSE, 53 | Cycle BOOLEAN NOT NULL DEFAULT FALSE, 54 | ReplyCount INTEGER NOT NULL DEFAULT 0 CHECK(ReplyCount >= 0), 55 | 56 | PRIMARY KEY (Board, Number), 57 | FOREIGN KEY (Board) REFERENCES Boards (Name) ON UPDATE CASCADE ON DELETE CASCADE, 58 | FOREIGN KEY (Board, Number) REFERENCES Posts (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE 59 | ) WITHOUT ROWID; 60 | 61 | CREATE TABLE Refs ( 62 | Board TEXT NOT NULL, 63 | Referee INTEGER NOT NULL, 64 | Referrer INTEGER NOT NULL, 65 | 66 | PRIMARY KEY (Board, Referee, Referrer), 67 | FOREIGN KEY (Board, Referee) REFERENCES Posts (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE, 68 | FOREIGN KEY (Board, Referrer) REFERENCES Posts (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE, 69 | CHECK(Referee != Referrer) 70 | ) WITHOUT ROWID; 71 | 72 | CREATE TABLE FileRefs ( 73 | Board TEXT NOT NULL, 74 | Number INTEGER NOT NULL, 75 | File TEXT NOT NULL, 76 | Name TEXT NOT NULL, 77 | Spoiler BOOLEAN NOT NULL, 78 | Sequence INTEGER NOT NULL, 79 | 80 | PRIMARY KEY (Board, Number, Sequence), 81 | FOREIGN KEY (Board, Number) REFERENCES Posts (Board, Number) ON UPDATE CASCADE ON DELETE CASCADE, 82 | FOREIGN KEY (File) REFERENCES Files (Name) ON UPDATE CASCADE ON DELETE CASCADE 83 | ) WITHOUT ROWID; 84 | 85 | CREATE TABLE Files ( 86 | Name TEXT NOT NULL UNIQUE PRIMARY KEY, 87 | Size INTEGER NOT NULL CHECK(Size > 0), 88 | Width INTEGER DEFAULT NULL, 89 | Height INTEGER DEFAULT NULL, 90 | 91 | CHECK((Width IS NOT NULL AND Height IS NOT NULL) OR (Width IS NULL AND Height IS NULL)) 92 | ) WITHOUT ROWID; 93 | 94 | CREATE TABLE Banners ( 95 | Board TEXT NOT NULL, 96 | File TEXT NOT NULL, 97 | 98 | PRIMARY KEY (Board, File), 99 | FOREIGN KEY (Board) REFERENCES Boards (Name) ON UPDATE CASCADE ON DELETE CASCADE, 100 | FOREIGN KEY (File) REFERENCES Files (Name) ON UPDATE CASCADE ON DELETE CASCADE 101 | ) WITHOUT ROWID; 102 | 103 | CREATE TABLE GlobalConfig ( 104 | Name TEXT NOT NULL UNIQUE PRIMARY KEY, 105 | Value NUMERIC NOT NULL 106 | ) WITHOUT ROWID; 107 | 108 | CREATE TABLE Accounts ( 109 | Name TEXT NOT NULL UNIQUE PRIMARY KEY CHECK(LENGTH(Name) BETWEEN 1 AND 16), 110 | Type TEXT NOT NULL, 111 | Board TEXT, 112 | PwHash TEXT NOT NULL, 113 | 114 | FOREIGN KEY (Board) REFERENCES Boards (Name) ON UPDATE CASCADE ON DELETE CASCADE, 115 | CHECK((Type IN ('admin', 'gvol') AND Board IS NULL) OR (Type IN ('bo', 'lvol') AND Board IS NOT NULL)) 116 | ) WITHOUT ROWID; 117 | 118 | CREATE TABLE Sessions ( 119 | Key TEXT NOT NULL UNIQUE PRIMARY KEY CHECK(LENGTH(Key) = 16), 120 | Account TEXT NOT NULL UNIQUE, 121 | ExpireDate DATETIME NOT NULL DEFAULT 0, 122 | 123 | FOREIGN KEY (Account) REFERENCES Accounts (Name) ON UPDATE CASCADE ON DELETE CASCADE 124 | ) WITHOUT ROWID; 125 | 126 | CREATE TABLE Logs ( 127 | Account TEXT DEFAULT NULL, 128 | Board TEXT DEFAULT NULL, 129 | Date DATETIME NOT NULL DEFAULT 0, 130 | Description TEXT NOT NULL CHECK(LENGTH(Description) > 0) 131 | ); 132 | 133 | CREATE TABLE Captchas ( 134 | Id TEXT NOT NULL UNIQUE PRIMARY KEY CHECK(LENGTH(Id) = 16), 135 | Text TEXT NOT NULL CHECK(LENGTH(Text) = 6), 136 | ExpireDate DATETIME NOT NULL DEFAULT 0 137 | ) WITHOUT ROWID; 138 | 139 | CREATE TRIGGER create_thread AFTER INSERT ON Posts 140 | WHEN NEW.Parent IS NULL 141 | BEGIN 142 | UPDATE Boards SET MaxPostNumber = MaxPostNumber + 1 WHERE Name = NEW.Board; 143 | UPDATE Posts SET Number = (SELECT MaxPostNumber FROM Boards WHERE Name = NEW.Board) WHERE ROWID = NEW.ROWID; 144 | UPDATE Posts SET Date = STRFTIME('%s', 'now') WHERE NEW.Date = 0 AND ROWID = NEW.ROWID; 145 | INSERT INTO Threads (Board, Number, LastBumpDate, Autosage) VALUES ( 146 | NEW.Board, 147 | (SELECT Number FROM Posts WHERE ROWID = NEW.ROWID), 148 | (SELECT Date FROM Posts WHERE ROWID = NEW.ROWID), 149 | NEW.Email IS NOT NULL AND (NEW.Email = 'sage' OR NEW.Email LIKE 'sage %' OR NEW.Email LIKE '% sage' OR NEW.Email LIKE '% sage %')); 150 | END; 151 | 152 | CREATE TRIGGER bump_thread AFTER INSERT ON Posts 153 | WHEN NEW.Parent IS NOT NULL 154 | BEGIN 155 | UPDATE Boards SET MaxPostNumber = MaxPostNumber + 1 WHERE Name = NEW.Board; 156 | UPDATE Posts SET Number = (SELECT MaxPostNumber FROM Boards WHERE Name = NEW.Board) WHERE ROWID = NEW.ROWID; 157 | UPDATE Posts SET Date = STRFTIME('%s', 'now') WHERE NEW.Date = 0 AND ROWID = NEW.ROWID; 158 | UPDATE Threads SET ReplyCount = ReplyCount + 1 WHERE Board = NEW.Board AND Number = NEW.Parent; 159 | UPDATE Threads SET LastBumpDate = (SELECT Date FROM Posts WHERE ROWID = NEW.ROWID) 160 | WHERE Board = NEW.Board AND Number = NEW.Parent AND NOT Autosage 161 | AND (NEW.Email IS NULL OR NOT (NEW.Email = 'sage' OR NEW.Email LIKE 'sage %' OR NEW.Email LIKE '% sage' OR NEW.Email LIKE '% sage %')) 162 | AND ((SELECT BumpLimit FROM Boards WHERE Name = NEW.Board) IS NULL 163 | OR ReplyCount <= (SELECT BumpLimit FROM Boards WHERE Name = NEW.Board)); 164 | END; 165 | 166 | CREATE TRIGGER auto_enable_captcha_per_thread AFTER INSERT ON Posts 167 | WHEN NEW.Parent IS NULL 168 | AND NOT (SELECT ThreadCaptcha FROM Boards WHERE Name = NEW.Board) 169 | AND (SELECT CaptchaTriggerTPH FROM Boards WHERE Name = NEW.Board) IS NOT NULL 170 | AND (SELECT COUNT(*) FROM Posts WHERE Board = NEW.Board AND Parent IS NULL AND Date > (STRFTIME('%s', 'now') - 3600)) 171 | > (SELECT CaptchaTriggerTPH FROM Boards WHERE Name = NEW.Board) 172 | BEGIN 173 | UPDATE Boards SET ThreadCaptcha = TRUE WHERE Name = NEW.Board; 174 | INSERT INTO Logs (Board, Date, Description) VALUES (NEW.Board, STRFTIME('%s', 'now'), 175 | 'Automatically enabled per-thread captcha due to excessive TPH'); 176 | END; 177 | 178 | CREATE TRIGGER auto_enable_captcha_per_post AFTER INSERT ON Posts 179 | WHEN NOT (SELECT PostCaptcha FROM Boards WHERE Name = NEW.Board) 180 | AND (SELECT CaptchaTriggerPPH FROM Boards WHERE Name = NEW.Board) IS NOT NULL 181 | AND (SELECT COUNT(*) FROM Posts WHERE Board = NEW.Board AND Date > (STRFTIME('%s', 'now') - 3600)) 182 | > (SELECT CaptchaTriggerPPH FROM Boards WHERE Name = NEW.Board) 183 | BEGIN 184 | UPDATE Boards SET PostCaptcha = TRUE WHERE Name = NEW.Board; 185 | INSERT INTO Logs (Board, Date, Description) VALUES (New.Board, STRFTIME('%s', 'now'), 186 | 'Automatically enabled per-post captcha due to excessive PPH'); 187 | END; 188 | 189 | CREATE TRIGGER delete_cyclical BEFORE INSERT ON Posts 190 | WHEN (SELECT Cycle FROM Threads WHERE Board = NEW.Board AND Number = NEW.Parent) 191 | AND (SELECT PostLimit FROM Boards WHERE Name = NEW.Board) IS NOT NULL 192 | AND (SELECT ReplyCount FROM Threads WHERE Board = NEW.Board AND Number = NEW.Parent) 193 | >= (SELECT PostLimit FROM Boards WHERE Name = NEW.Board) 194 | BEGIN 195 | DELETE FROM Posts WHERE Board = NEW.Board AND Number = (SELECT MIN(Number) FROM Posts WHERE Parent = NEW.Parent); 196 | END; 197 | 198 | CREATE TRIGGER slide_thread BEFORE INSERT ON Threads 199 | WHEN (SELECT ThreadLimit FROM Boards WHERE Name = NEW.Board) IS NOT NULL 200 | AND (SELECT COUNT(*) FROM Threads WHERE Board = NEW.Board) 201 | >= (SELECT ThreadLimit FROM Boards WHERE Name = NEW.Board) 202 | BEGIN 203 | DELETE FROM Posts 204 | WHERE Board = NEW.Board AND Number = 205 | (SELECT MIN(Number) FROM Threads 206 | WHERE LastBumpDate = (SELECT MIN(LastBumpDate) FROM Threads WHERE Board = NEW.Board AND NOT Sticky) 207 | AND NOT Sticky); 208 | END; 209 | 210 | CREATE TRIGGER unbump_thread AFTER DELETE ON Posts 211 | WHEN OLD.Parent IS NOT NULL 212 | BEGIN 213 | UPDATE Threads SET ReplyCount = ReplyCount - 1 WHERE Board = OLD.Board AND Number = OLD.Parent; 214 | UPDATE Threads SET LastBumpDate = 215 | (SELECT MAX(Date) FROM Posts WHERE Board = OLD.Board AND (Number = OLD.Parent OR Parent = OLD.Parent) 216 | AND (Email IS NULL OR NOT (Email = 'sage' OR Email LIKE 'sage %' OR Email LIKE '% sage' OR Email LIKE '% sage %'))) 217 | WHERE Board = OLD.Board AND Number = OLD.Parent AND NOT Autosage 218 | AND ((SELECT BumpLimit FROM Boards WHERE Name = OLD.Board) IS NULL 219 | OR ReplyCount <= (SELECT BumpLimit FROM Boards WHERE Name = OLD.Board)); 220 | END; 221 | 222 | CREATE TRIGGER delete_empty_posts AFTER DELETE ON FileRefs 223 | WHEN (SELECT COUNT(*) FROM FileRefs WHERE Board = OLD.Board AND Number = OLD.Number) = 0 224 | AND (SELECT Comment FROM Posts WHERE Board = OLD.Board AND Number = OLD.Number) = '' 225 | BEGIN 226 | DELETE FROM Posts WHERE Board = OLD.Board AND Number = OLD.Number; 227 | END; 228 | 229 | CREATE TRIGGER delete_old_sessions BEFORE INSERT ON Sessions 230 | BEGIN 231 | DELETE FROM Sessions WHERE Account = NEW.Account; 232 | END; 233 | 234 | CREATE TRIGGER expire_session AFTER INSERT ON Sessions 235 | BEGIN 236 | UPDATE Sessions SET ExpireDate = STRFTIME('%s', 'now') + 86400 WHERE Key = NEW.Key; 237 | DELETE FROM Sessions WHERE ExpireDate <= STRFTIME('%s', 'now'); 238 | END; 239 | 240 | CREATE TRIGGER set_log_date AFTER INSERT ON Logs 241 | BEGIN 242 | UPDATE Logs SET Date = STRFTIME('%s', 'now') WHERE ROWID = NEW.ROWID; 243 | END; 244 | 245 | CREATE TRIGGER expire_captcha AFTER INSERT ON Captchas 246 | BEGIN 247 | UPDATE Captchas SET ExpireDate = STRFTIME('%s', 'now') + 1800 WHERE Id = NEW.Id; 248 | DELETE FROM Captchas WHERE ExpireDate <= STRFTIME('%s', 'now'); 249 | END; 250 | 251 | CREATE INDEX posts_parent_number ON Posts (Parent, Number); 252 | CREATE INDEX posts_date ON Posts (Date DESC); 253 | CREATE INDEX captchas_expiredate ON Captchas (ExpireDate); 254 | CREATE INDEX boards_displayoverboard ON Boards (DisplayOverboard); 255 | 256 | -- This is a default account. You should use this only for setup purposes. 257 | -- The setup account should be DELETED after you make your main admin account. 258 | -- The initial username is 'setup' and the password is 'password'. 259 | INSERT INTO Accounts (Name, Type, PwHash) VALUES ('setup', 'admin', '$argon2id$v=19$m=65536,t=16,p=4$dnFMZDFSRkhMWXFKdGV4TA$B3+O7QbPE/e42Js3sr4ldhtPP4ibRpas1KZquqidMDysu4NdvdX3EA2/X9rdb2LjzB/UDj8dwfKWQxLbcgVZFg'); 260 | INSERT INTO GlobalConfig VALUES ('sitename', 'Picochan'); 261 | INSERT INTO GlobalConfig VALUES ('defaultpostname', 'Anonymous'); 262 | INSERT INTO GlobalConfig VALUES ('defaultboardview', 'catalog'); 263 | INSERT INTO GlobalConfig VALUES ('frontpage', 'Welcome to Picochan.'); 264 | INSERT INTO GlobalConfig VALUES ('theme', 'picochan'); 265 | INSERT INTO GlobalConfig VALUES ('threadpagesize', 50); 266 | INSERT INTO GlobalConfig VALUES ('catalogpagesize', 1000); 267 | INSERT INTO GlobalConfig VALUES ('overboardpagesize', 100); 268 | INSERT INTO GlobalConfig VALUES ('indexpagesize', 10); 269 | INSERT INTO GlobalConfig VALUES ('indexwindowsize', 5); 270 | INSERT INTO GlobalConfig VALUES ('recentpagesize', 50); 271 | INSERT INTO GlobalConfig VALUES ('logpagesize', 50); 272 | INSERT INTO GlobalConfig VALUES ('maxfilesize', 16777216); 273 | -------------------------------------------------------------------------------- /picoengine.lua: -------------------------------------------------------------------------------- 1 | -- Picochan Backend. 2 | -- HAPAS ARE MENTALLY ILL DEGENERATES 3 | 4 | local sqlite3 = require("lib.sqlite3") 5 | local sha = require("lib.sha") 6 | local argon2 = require("lib.argon2") 7 | local random = require("lib.random") 8 | 9 | require("lib.stringext") 10 | 11 | local pico = {} 12 | pico.global = {} 13 | pico.account = {} 14 | pico.board = {} 15 | pico.board.stats = {} 16 | pico.board.banner = {} 17 | pico.file = {} 18 | pico.post = {} 19 | pico.thread = {} 20 | pico.log = {} 21 | pico.captcha = {} 22 | pico.webring = {} 23 | pico.webring.endpoint = {} 24 | 25 | -- 26 | -- INITIALIZATION 27 | -- 28 | 29 | function pico.initialize() 30 | db = assert(sqlite3.open("picochan.db", "w")) 31 | db:e("PRAGMA busy_timeout = 10000") 32 | db:e("PRAGMA foreign_keys = ON") 33 | db:e("PRAGMA recursive_triggers = ON") 34 | db:e("PRAGMA secure_delete = ON") 35 | db:e("PRAGMA case_sensitive_like = ON") 36 | end 37 | 38 | function pico.finalize() 39 | db:close() 40 | end 41 | 42 | -- 43 | -- ACCOUNT MANAGEMENT FUNCTIONS 44 | -- 45 | 46 | pico.account.current = nil 47 | 48 | local function valid_account_name(name) 49 | return type(name) == "string" and #name <= 16 and #name >= 1 and not name:match("[^%w]") 50 | end 51 | 52 | local function valid_account_type(type) 53 | return type == "admin" or type == "gvol" or type == "bo" or type == "lvol" 54 | end 55 | 56 | local function valid_account_password(password) 57 | return type(password) == "string" and #password >= 6 and #password <= 128 58 | end 59 | 60 | -- permclass is a space-separated list of one or more of the following: 61 | -- admin gvol bo lvol 62 | -- targettype may be one of the following: 63 | -- acct board post 64 | local function permit(permclass, targettype, targarg) 65 | -- STEP 1. Check account type 66 | if not pico.account.current then 67 | return false, "Action not permitted (not logged in)" 68 | elseif not permclass:match(pico.account.current.Type) then 69 | return false, "Action not permitted (account type not authorized)" 70 | end 71 | 72 | -- STEP 2. Check targets 73 | -- If no target, stop here 74 | if not targettype then 75 | return true 76 | end 77 | 78 | -- Special case: Admin can modify any target 79 | if pico.account.current.Type == "admin" then 80 | return true 81 | end 82 | 83 | if targettype == "acct" then 84 | -- Special case: Anyone can modify their own account (password change) 85 | if pico.account.current.Name == targarg then 86 | return true 87 | end 88 | 89 | if pico.account.current.Type == "gvol" or pico.account.current.Type == "lvol" then 90 | return false, "Action not permitted (account type not authorized)" 91 | elseif pico.account.current.Type == "bo" then 92 | local board = db:r1("SELECT Board FROM Accounts WHERE Name = ?", targarg) 93 | if board == pico.account.current.Board then 94 | return true 95 | else 96 | return false, "Action not permitted (attempt to modify account outside assigned board)" 97 | end 98 | end 99 | elseif targettype == "board" then 100 | if pico.account.current.Type == "gvol" or pico.account.current.Type == "lvol" then 101 | return false, "Action not permitted (account type not authorized)" 102 | elseif pico.account.current.Type == "bo" then 103 | if targarg == pico.account.current.Board then 104 | return true 105 | else 106 | return false, "Action not permitted (attempt to modify non-assigned board)" 107 | end 108 | end 109 | elseif targettype == "post" then 110 | if pico.account.current.Type == "gvol" then 111 | return true 112 | elseif (pico.account.current.Type == "bo") 113 | or (pico.account.current.Type == "lvol") then 114 | if targarg == pico.account.current.Board then 115 | return true 116 | else 117 | return false, "Action not permitted (attempt to modify post outside assigned board)" 118 | end 119 | end 120 | end 121 | 122 | return false, "Action not permitted (unclassified denial: THIS IS A BUG, REPORT TO ADMINISTRATOR)" 123 | end 124 | 125 | function pico.account.create(name, password, type, board) 126 | local auth, msg = permit("admin bo", "board", board) 127 | if not auth then return auth, msg end 128 | 129 | if not valid_account_name(name) then 130 | return false, "Account name is invalid" 131 | elseif not valid_account_type(type) then 132 | return false, "Account type is invalid" 133 | elseif not valid_account_password(password) then 134 | return false, "Account password does not meet requirements" 135 | elseif pico.account.exists(name) then 136 | return false, "Account already exists" 137 | elseif (type == "bo" or type == "lvol") then 138 | if not board then 139 | return false, "Board was not specified, but the account type requires it" 140 | elseif not pico.board.exists(board) then 141 | return false, "Account's specified board does not exist" 142 | end 143 | end 144 | 145 | if type == "admin" or type == "gvol" then 146 | board = nil 147 | end 148 | 149 | db:e("INSERT INTO Accounts (Name, Type, Board, PwHash) VALUES (?, ?, ?, ?)", 150 | name, type, board, argon2.digest(password)) 151 | pico.log.insert(board, "Created new %s account '%s'", type, name) 152 | return true, "Account created successfully" 153 | end 154 | 155 | function pico.account.delete(name, reason) 156 | local auth, msg = permit("admin bo", "acct", name) 157 | if not auth then return auth, msg end 158 | 159 | local account_tbl = db:r("SELECT Type, Board FROM Accounts WHERE Name = ?", name) 160 | if not account_tbl then 161 | return false, "Account does not exist" 162 | end 163 | 164 | db:e("DELETE FROM Accounts WHERE Name = ?", name) 165 | pico.log.insert(account_tbl.Board, "Deleted a %s account '%s' for reason: %s", 166 | account_tbl.Type, name, reason) 167 | return true, "Account deleted successfully" 168 | end 169 | 170 | function pico.account.changepass(name, password) 171 | local auth, msg = permit("admin gvol bo lvol", "acct", name) 172 | if not auth then return auth, msg end 173 | 174 | local account_tbl = db:r("SELECT Board FROM Accounts WHERE Name = ?", name) 175 | 176 | if not account_tbl then 177 | return false, "Account does not exist" 178 | elseif not valid_account_password(password) then 179 | return false, "Account password does not meet requirements" 180 | end 181 | 182 | db:e("UPDATE Accounts SET PwHash = ? WHERE Name = ?", 183 | argon2.digest(password), name) 184 | pico.log.insert(account_tbl.Board, "Changed password of account '%s'", name) 185 | return true, "Account password changed successfully" 186 | end 187 | 188 | -- log in an account. returns an authentication key which you can use to perform 189 | -- mod-only actions. 190 | function pico.account.login(name, password) 191 | if not pico.account.exists(name) 192 | or not argon2.verify(password, db:r1("SELECT PwHash FROM Accounts WHERE Name = ?", name)) then 193 | return nil, "Invalid username or password" 194 | end 195 | 196 | local key = random.string(16) 197 | db:e("INSERT INTO Sessions (Key, Account) VALUES (?, ?)", key, name) 198 | 199 | pico.account.register_login(key) 200 | return key 201 | end 202 | 203 | -- populate the account table using an authentication key (perhaps provided by a 204 | -- session cookie, or by pico.account.login() above) 205 | function pico.account.register_login(key) 206 | if pico.account.current then 207 | pico.account.logout(key) 208 | end 209 | 210 | pico.account.current = db:r("SELECT * FROM Accounts WHERE Name = (SELECT Account FROM Sessions " .. 211 | "WHERE Key = ? AND ExpireDate > STRFTIME('%s', 'now'))", key) 212 | db:e("UPDATE Sessions SET ExpireDate = STRFTIME('%s', 'now') + 86400 WHERE Key = ?", key) 213 | end 214 | 215 | function pico.account.logout(key) 216 | if not pico.account.current then 217 | return false, "No account logged in" 218 | end 219 | 220 | db:e("DELETE FROM Sessions WHERE Key = ?", key) 221 | return true, "Account logged out successfully" 222 | end 223 | 224 | function pico.account.list() 225 | return db:q1("SELECT Name FROM Accounts") 226 | end 227 | 228 | function pico.account.exists(name) 229 | return db:b("SELECT TRUE FROM Accounts WHERE Name = ?", name) 230 | end 231 | 232 | -- 233 | -- GLOBAL CONFIGURATION FUNCTIONS 234 | -- 235 | 236 | -- retrieve value of globalconfig variable or the default value if it doesn't exist 237 | function pico.global.get(name, default) 238 | local value = db:r1("SELECT Value FROM GlobalConfig WHERE Name = ?", name) 239 | if value ~= nil then 240 | return value 241 | end 242 | return default 243 | end 244 | 245 | -- setting a globalconfig variable to nil removes it. 246 | function pico.global.set(name, value) 247 | local auth, msg = permit("admin") 248 | if not auth then return auth, msg end 249 | 250 | db:e("DELETE FROM GlobalConfig WHERE Name = ?", name) 251 | 252 | if value ~= nil then 253 | db:e("INSERT INTO GlobalConfig VALUES (?, ?)", name, value) 254 | end 255 | 256 | pico.log.insert(nil, "Edited global configuration variable '%s'", name) 257 | return true, "Global configuration modified" 258 | end 259 | 260 | -- 261 | -- BOARD MANAGEMENT FUNCTIONS 262 | -- 263 | 264 | local function valid_board_name(name) 265 | return type(name) == "string" and #name >= 1 and #name <= 8 and not name:match("[^%l%d]") 266 | end 267 | 268 | local function valid_board_title(title) 269 | return type(title) == "string" and #title >= 1 and #title <= 32 270 | end 271 | 272 | local function valid_board_subtitle(subtitle) 273 | return type(subtitle) == "string" and #subtitle >= 1 and #subtitle <= 64 274 | end 275 | 276 | function pico.board.create(name, title, subtitle) 277 | local auth, msg = permit("admin") 278 | if not auth then return auth, msg end 279 | 280 | if pico.board.exists(name) then 281 | return false, "Board already exists" 282 | elseif not valid_board_name(name) then 283 | return false, "Invalid board name" 284 | elseif not valid_board_title(name) then 285 | return false, "Invalid board title" 286 | elseif subtitle and not valid_board_subtitle(subtitle) then 287 | return false, "Invalid board subtitle" 288 | end 289 | 290 | db:e("INSERT INTO Boards (Name, Title, Subtitle) VALUES (?, ?, ?)", 291 | name, title, subtitle) 292 | pico.log.insert(nil, "Created a new board: /%s/ - %s", name, title) 293 | return true, "Board created successfully" 294 | end 295 | 296 | function pico.board.delete(name, reason) 297 | local auth, msg = permit("admin") 298 | if not auth then return auth, msg end 299 | 300 | if not pico.board.exists(name) then 301 | return false, "Board does not exist" 302 | end 303 | 304 | db:e("DELETE FROM Boards WHERE Name = ?", name) 305 | pico.log.insert(nil, "Deleted board /%s/ for reason: %s", name, reason) 306 | pico.file.clean() 307 | return true, "Board deleted successfully" 308 | end 309 | 310 | function pico.board.list() 311 | return db:q("SELECT Name, Title, Subtitle FROM Boards ORDER BY DisplayOverboard DESC, MaxPostNumber DESC") 312 | end 313 | 314 | function pico.board.exists(name) 315 | return db:b("SELECT TRUE FROM Boards WHERE Name = ?", name) 316 | end 317 | 318 | function pico.board.tbl(name) 319 | return db:r("SELECT * FROM Boards WHERE Name = ?", name) 320 | end 321 | 322 | function pico.board.configure(board_tbl) 323 | local auth, msg = permit("admin bo", "board", board_tbl.Name) 324 | if not auth then return auth, msg end 325 | 326 | if not board_tbl then 327 | return false, "Board configuration not supplied" 328 | elseif not pico.board.exists(board_tbl.Name) then 329 | return false, "Board does not exist" 330 | end 331 | 332 | db:e("UPDATE Boards SET Title = ?, Subtitle = ?, Lock = ?, DisplayOverboard = ?, " .. 333 | "PostMaxFiles = ?, ThreadMinLength = ?, PostMaxLength = ?, PostMaxNewlines = ?, " .. 334 | "PostMaxDblNewlines = ?, TPHLimit = ?, PPHLimit = ?, ThreadCaptcha = ?, " .. 335 | "PostCaptcha = ?, CaptchaTriggerTPH = ?, CaptchaTriggerPPH = ?, " .. 336 | "BumpLimit = ?, PostLimit = ?, ThreadLimit = ? WHERE Name = ?", 337 | board_tbl.Title, board_tbl.Subtitle, 338 | board_tbl.Lock or 0, board_tbl.DisplayOverboard or 0, 339 | board_tbl.PostMaxFiles, board_tbl.ThreadMinLength, 340 | board_tbl.PostMaxLength, board_tbl.PostMaxNewlines, 341 | board_tbl.PostMaxDblNewlines, board_tbl.TPHLimit, 342 | board_tbl.PPHLimit, board_tbl.ThreadCaptcha or 0, 343 | board_tbl.PostCaptcha or 0, board_tbl.CaptchaTriggerTPH, 344 | board_tbl.CaptchaTriggerPPH, board_tbl.BumpLimit, 345 | board_tbl.PostLimit, board_tbl.ThreadLimit, 346 | board_tbl.Name) 347 | 348 | pico.log.insert(board_tbl.Name, "Modified board configuration") 349 | return true, "Board configured successfully" 350 | end 351 | 352 | function pico.board.catalog(name, page) 353 | if name and not pico.board.exists(name) then 354 | return nil, nil, "Board does not exist" 355 | end 356 | 357 | page = tonumber(page) or 1 358 | local where = name and "Threads.Board = ? " 359 | or "Threads.Board IN (SELECT Name FROM Boards WHERE DisplayOverboard) " 360 | local sql = "SELECT Posts.*, LastBumpDate, Sticky, Lock, Autosage, Cycle, ReplyCount, File, Spoiler, Width AS FileWidth, Height AS FileHeight " .. 361 | "FROM Threads JOIN Posts USING(Board, Number) LEFT JOIN FileRefs USING(Board, Number) LEFT JOIN Files ON FileRefs.File = Files.Name " .. 362 | "WHERE (Sequence = 1 OR Sequence IS NULL) AND " .. 363 | where .. 364 | "ORDER BY " .. 365 | (name and "Sticky DESC, LastBumpDate DESC, Threads.Number DESC " 366 | or "LastBumpDate DESC ") .. 367 | "LIMIT ? OFFSET ?" 368 | local pagecount_sql = "SELECT ((COUNT(*) - 1) / CAST(? AS INTEGER)) + 1 FROM Threads WHERE " .. where 369 | 370 | local catalog_tbl, pagecount 371 | if name then 372 | local pagesize = pico.global.get("catalogpagesize", 1000) 373 | catalog_tbl = db:q(sql, name, pagesize, (page - 1) * pagesize) 374 | pagecount = db:r1(pagecount_sql, pagesize, name) 375 | else 376 | local pagesize = pico.global.get("overboardpagesize", 100) 377 | catalog_tbl = db:q(sql, pagesize, (page - 1) * pagesize) 378 | pagecount = db:r1(pagecount_sql, pagesize) 379 | end 380 | 381 | return catalog_tbl, pagecount 382 | end 383 | 384 | function pico.board.index(name, page) 385 | if name and not pico.board.exists(name) then 386 | return nil, nil, "Board does not exist" 387 | end 388 | 389 | page = tonumber(page) or 1 390 | local pagesize = pico.global.get("indexpagesize", 10) 391 | local threadpagesize = pico.global.get("threadpagesize", 50) 392 | local windowsize = pico.global.get("indexwindowsize", 5) 393 | 394 | local where = name and "WHERE Board = ? " or "" 395 | local sql = "SELECT Board, Number FROM Threads " .. 396 | where .. 397 | "ORDER BY " .. 398 | (name and "Sticky DESC, LastBumpDate DESC, Threads.Number DESC " 399 | or "LastBumpDate DESC ") .. 400 | "LIMIT ? OFFSET ?" 401 | local pagecount_sql = "SELECT ((COUNT(*) - 1) / CAST(? AS INTEGER)) + 1" 402 | 403 | local thread_ops, pagecount 404 | if name then 405 | thread_ops = db:q(sql, name, pagesize, (page - 1) * pagesize) 406 | pagecount = db:r1(pagecount_sql .. " FROM Threads " .. where, pagesize, name) 407 | else 408 | thread_ops = db:q(sql, pagesize, (page - 1) * pagesize) 409 | pagecount = db:r1(pagecount_sql .. " FROM Threads " .. where, pagesize) 410 | end 411 | 412 | local index_tbl = {} 413 | for i = 1, #thread_ops do 414 | local op_tbl = thread_ops[i] 415 | local thread_tbl = db:q("SELECT Posts.*, LastBumpDate, Sticky, Lock, Autosage, Cycle, ReplyCount, " .. 416 | "IIF(ReplyCount > ?, ReplyCount - ?, 0) AS RepliesOmitted, (" .. 417 | pagecount_sql .. " FROM Posts WHERE Board = Threads.Board AND Parent = Threads.Number) AS PageCount " .. 418 | "FROM Threads JOIN Posts USING(Board, Number) " .. 419 | "WHERE Board = ? AND Number = ? " .. 420 | "UNION ALL " .. 421 | "SELECT * FROM " .. 422 | "(SELECT *, " .. 423 | "NULL AS LastBumpDate, NULL AS Sticky, NULL AS Lock, " .. 424 | "NULL AS Autosage, NULL AS Cycle, NULL AS ReplyCount, " .. 425 | "NULL AS RepliesOmitted, NULL AS PageCount " .. 426 | "FROM Posts " .. 427 | "WHERE Board = ? AND Parent = ? ORDER BY Number DESC LIMIT ?) " .. 428 | "ORDER BY Number ASC", 429 | windowsize, windowsize, threadpagesize, 430 | op_tbl.Board, op_tbl.Number, 431 | op_tbl.Board, op_tbl.Number, windowsize) 432 | for j = 1, #thread_tbl do 433 | thread_tbl[j].Files = pico.file.list(thread_tbl[j].Board, thread_tbl[j].Number) 434 | end 435 | index_tbl[i] = thread_tbl 436 | end 437 | 438 | return index_tbl, pagecount 439 | end 440 | 441 | function pico.board.recent(name, page) 442 | if name and not pico.board.exists(name) then 443 | return nil, nil, "Board does not exist" 444 | end 445 | 446 | page = tonumber(page) or 1 447 | local pagesize = pico.global.get("recentpagesize", 50) 448 | 449 | local where = name and "WHERE Board = ? " or "" 450 | local sql = "SELECT * FROM Posts " .. where .. "ORDER BY Date DESC LIMIT ? OFFSET ?" 451 | local pagecount_sql = "SELECT ((COUNT(*) - 1) / CAST(? AS INTEGER)) + 1 FROM Posts " .. where 452 | 453 | local recent_tbl, pagecount 454 | if name then 455 | recent_tbl = db:q(sql, name, pagesize, (page - 1) * pagesize) 456 | pagecount = db:r1(pagecount_sql, pagesize, name) 457 | else 458 | recent_tbl = db:q(sql, pagesize, (page - 1) * pagesize) 459 | pagecount = db:r1(pagecount_sql, pagesize) 460 | end 461 | 462 | for i = 1, #recent_tbl do 463 | local post_tbl = recent_tbl[i] 464 | post_tbl.Files = pico.file.list(post_tbl.Board, post_tbl.Number) 465 | end 466 | 467 | return recent_tbl, pagecount 468 | end 469 | 470 | function pico.board.banner.get(board) 471 | if not pico.board.exists(board) then 472 | return nil, "Board does not exist" 473 | end 474 | 475 | local file = db:r1("SELECT File FROM Banners WHERE Board = ? ORDER BY RANDOM() LIMIT 1", board) 476 | if not file then 477 | return nil, "Banner does not exist" 478 | end 479 | return file 480 | end 481 | 482 | function pico.board.banner.list(board) 483 | if not pico.board.exists(board) then 484 | return nil, "Board does not exist" 485 | end 486 | 487 | return db:q1("SELECT File FROM Banners WHERE Board = ?", board) 488 | end 489 | 490 | function pico.board.banner.exists(board, file) 491 | return db:b("SELECT TRUE FROM Banners WHERE Board = ? AND File = ?", board, file) 492 | end 493 | 494 | function pico.board.banner.add(board, file) 495 | local auth, msg = permit("admin bo", "board", board) 496 | if not auth then return auth, msg end 497 | 498 | if not pico.board.exists(board) then 499 | return false, "Board does not exist" 500 | elseif not pico.file.exists(file) then 501 | return false, "File does not exist" 502 | elseif pico.board.banner.exists(board, file) then 503 | return false, "Banner already exists" 504 | end 505 | 506 | db:e("INSERT INTO Banners (Board, File) VALUES (?, ?)", board, file) 507 | pico.log.insert(board, "Added banner %s", file) 508 | return true, "Banner added successfully" 509 | end 510 | 511 | function pico.board.banner.delete(board, file, reason) 512 | local auth, msg = permit("admin bo", "board", board) 513 | if not auth then return auth, msg end 514 | 515 | if not pico.board.exists(board) then 516 | return false, "Board does not exist" 517 | elseif not pico.file.exists(file) then 518 | return false, "File does not exist" 519 | elseif not pico.board.banner.exists(board, file) then 520 | return false, "Banner does not exist" 521 | end 522 | 523 | db:e("DELETE FROM Banners WHERE Board = ? AND File = ?", board, file) 524 | pico.log.insert(board, "Deleted banner %s for reason: %s", file, reason) 525 | pico.file.clean() 526 | return true, "Banner deleted successfully" 527 | end 528 | 529 | -- To get number of posts per hour over the last 12 hours: 530 | -- * interval = 1 (hour) 531 | -- * intervals = 12 (12 hours) 532 | -- To get number of posts per day over 1 week: 533 | -- * interval = 24 (hours) 534 | -- * intervals = 7 (7 * 24 hours = 1 week) 535 | function pico.board.stats.threadrate(board, interval, intervals) 536 | return math.ceil(db:r1("SELECT (COUNT(*) / ?) FROM Posts WHERE Board = ? AND Parent IS NULL AND Date > (STRFTIME('%s', 'now') - (? * 3600))", 537 | intervals, board, interval * intervals)) 538 | end 539 | 540 | function pico.board.stats.postrate(board, interval, intervals) 541 | return math.ceil(db:r1("SELECT (COUNT(*) / ?) FROM Posts WHERE Board = ? AND Date > (STRFTIME('%s', 'now') - (? * 3600))", 542 | intervals, board, interval * intervals)) 543 | end 544 | 545 | function pico.board.stats.totalposts(board) 546 | return db:r1("SELECT MaxPostNumber FROM Boards WHERE Name = ?", board) 547 | end 548 | 549 | function pico.board.stats.lastbumpdate(board) 550 | return db:r1("SELECT MAX(LastBumpDate) FROM Threads WHERE Board = ?", board) 551 | end 552 | 553 | -- 554 | -- FILE MANAGEMENT FUNCTIONS 555 | -- 556 | 557 | -- return a file's extension based on its contents 558 | local function identify_file(data) 559 | if not data or #data == 0 then 560 | return nil 561 | end 562 | 563 | if data:sub(1,8) == "\x89PNG\x0D\x0A\x1A\x0A" then 564 | return "png" 565 | elseif data:sub(1,3) == "\xFF\xD8\xFF" then 566 | return "jpg" 567 | elseif data:sub(1,6) == "GIF87a" 568 | or data:sub(1,6) == "GIF89a" then 569 | return "gif" 570 | elseif data:sub(1,4) == "RIFF" 571 | and data:sub(9,12) == "WEBP" then 572 | return "webp" 573 | elseif data:sub(1,4) == "\x1A\x45\xDF\xA3" then 574 | return "webm" 575 | elseif data:sub(5,12) == "ftypmp42" 576 | or data:sub(5,12) == "ftypisom" then 577 | return "mp4" 578 | elseif data:sub(1,2) == "\xFF\xFB" 579 | or data:sub(1,3) == "ID3" then 580 | return "mp3" 581 | elseif data:sub(1,4) == "OggS" then 582 | return "ogg" 583 | elseif data:sub(1,4) == "fLaC" then 584 | return "flac" 585 | elseif data:sub(1,4) == "%PDF" then 586 | return "pdf" 587 | elseif data:sub(1,4) == "\x25\x21\x50\x53" then 588 | return "ps" 589 | elseif data:sub(1,4) == "PK\x03\x04" 590 | and data:sub(31,58) == "mimetypeapplication/epub+zip" then 591 | return "epub" 592 | elseif data:sub(1,3) == "\x1F\x8B\x08" then 593 | return "gz" 594 | elseif data:sub(1,3) == "BZh" then 595 | return "bz2" 596 | elseif data:sub(1,5) == "\xFD7zXZ" then 597 | return "xz" 598 | elseif data:sub(1,4) == "\x04\x22\x4D\x18" then 599 | return "lz4" 600 | elseif data:sub(1,4) == "\x28\xB5\x2F\xFD" then 601 | return "zst" 602 | elseif data:sub(258,262) == "ustar" then 603 | return "tar" 604 | elseif data:sub(1,4) == "PK\x03\x04" then 605 | return "zip" 606 | elseif data:sub(1,6) == "7z\xBC\xAF\x27\x1C" then 607 | return "7z" 608 | elseif data:sub(1,6) == "Rar!\x1A\x07" then 609 | return "rar" 610 | elseif data:find("DOCTYPE svg", 1, true) 611 | or data:find(" pico.global.get("maxfilesize", 16777216) then 662 | f:close() 663 | return nil, "File too large" 664 | end 665 | 666 | assert(f:seek("set")) 667 | local data = assert(f:read("*a")) 668 | f:close() 669 | 670 | local extension = identify_file(data) 671 | if not extension then 672 | return nil, "Could not identify file type" 673 | end 674 | 675 | local class = pico.file.class(extension) 676 | local hash = sha.hash("sha256", data) 677 | local filename = hash .. "." .. extension 678 | 679 | if pico.file.exists(filename) then 680 | return filename, "File already existed and was not changed" 681 | end 682 | 683 | local newf = assert(io.open("Media/" .. filename, "w")) 684 | assert(newf:write(data)) 685 | newf:close() 686 | 687 | local p, width, height 688 | if class == "video" or (class == "audio" and os.execute("exec ffmpeg -v quiet -i Media/" .. filename .. " -map 0:v:0 -f image2 - >/dev/null")) then 689 | local ffmpeg = "ffmpeg -v quiet -i Media/" .. filename .. 690 | (class == "video" and " -ss 00:00:00.500 -frames:v 1 -f image2 -" 691 | or " -map 0:v:0 -f image2 -") 692 | os.execute(ffmpeg .. " | magick - -filter Catrom -strip -thumbnail 200x200 jpg:Media/thumb/" .. filename) 693 | os.execute(ffmpeg .. " | magick - -filter Catrom -quality 60 -strip -thumbnail 100x70 jpg:Media/icon/" .. filename) 694 | p = io.popen("exec ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 " .. 695 | "Media/" .. filename, "r") 696 | elseif class == "image" or extension == "pdf" or extension == "ps" then 697 | local prefix = (extension == "pdf" or extension == "ps" or extension == "svg") and "png:" or "" 698 | local frame = (extension == "pdf" or extension == "ps") and "[0]" or "" 699 | os.execute("exec magick Media/" .. filename .. frame .. 700 | " -filter Catrom -strip -coalesce -thumbnail 200x200 " .. prefix .. "Media/thumb/" .. filename) 701 | os.execute("exec magick Media/" .. filename .. 702 | "[0] -filter Catrom -quality 60 -strip -coalesce -thumbnail 100x70 " .. prefix .. "Media/icon/" .. filename) 703 | p = io.popen("exec magick identify -format '%wx%h' Media/" .. filename .. "[0]", "r") 704 | end 705 | 706 | if p then 707 | local dimensions = p:read("*a"):tokenize("x") 708 | p:close() 709 | width, height = tonumber(dimensions[1]), tonumber(dimensions[2]) 710 | end 711 | 712 | if (not width) or (not height) then 713 | width, height = nil, nil 714 | end 715 | 716 | db:e("INSERT INTO Files VALUES (?, ?, ?, ?)", filename, size, width, height) 717 | return filename, "File added successfully" 718 | end 719 | 720 | -- Delete a file from the media directory and remove its corresponding entries 721 | -- in the database. 722 | function pico.file.delete(filename, reason) 723 | local auth, msg = permit("admin gvol") 724 | if not auth then return auth, msg end 725 | 726 | if not pico.file.exists(filename) then 727 | return false, "File does not exist" 728 | end 729 | 730 | db:e("DELETE FROM Files WHERE Name = ?", filename) 731 | os.remove("Media/" .. filename) 732 | os.remove("Media/icon/" .. filename) 733 | os.remove("Media/thumb/" .. filename) 734 | 735 | pico.log.insert(nil, "Deleted file %s from all boards for reason: %s", filename, reason) 736 | return true, "File deleted successfully" 737 | end 738 | 739 | function pico.file.clean() 740 | local files = db:q1("SELECT Name FROM Files EXCEPT SELECT File FROM FileRefs EXCEPT SELECT File FROM Banners") 741 | for i = 1, #files do 742 | local file = files[i] 743 | db:e("DELETE FROM Files WHERE Name = ?", file) 744 | os.remove("Media/" .. file) 745 | os.remove("Media/icon/" .. file) 746 | os.remove("Media/thumb/" .. file) 747 | end 748 | end 749 | 750 | function pico.file.list(board, number) 751 | return db:q("SELECT Files.*, FileRefs.Name AS DownloadName, Spoiler " .. 752 | "FROM FileRefs JOIN Files ON FileRefs.File = Files.Name " .. 753 | "WHERE Board = ? AND Number = ? ORDER BY Sequence ASC", 754 | board, number) 755 | end 756 | 757 | function pico.file.exists(name) 758 | return db:b("SELECT TRUE FROM Files WHERE Name = ?", name) 759 | end 760 | 761 | function pico.file.create_refs(board, number, files) 762 | if files then 763 | for i = 1, #files do 764 | local file = files[i] 765 | if file.Hash and file.Hash ~= "" then 766 | db:e("INSERT INTO FileRefs VALUES (?, ?, ?, ?, ?, ?)", board, number, file.Hash, file.Name, file.Spoiler, i) 767 | end 768 | end 769 | end 770 | end 771 | 772 | -- 773 | -- POST ACCESS, CREATION AND DELETION FUNCTIONS 774 | -- 775 | 776 | function pico.post.tbl(board, number, omit_files) 777 | local post_tbl = db:r("SELECT * FROM Posts LEFT JOIN Threads USING(Board, Number) WHERE Board = ? AND Number = ?", board, number) 778 | if post_tbl and not omit_files then 779 | post_tbl.Files = pico.file.list(board, number) 780 | end 781 | return post_tbl 782 | end 783 | 784 | -- Return list of posts which >>reply to the specified post. 785 | function pico.post.refs(board, number) 786 | return db:q1("SELECT Referrer FROM Refs WHERE Board = ? AND Referee = ?", board, number) 787 | end 788 | 789 | -- Create a post and return its number 790 | -- 'files' is an array with a collection of file hashes to attach to the post 791 | function pico.post.create(board, parent, name, email, subject, comment, files, captcha_id, captcha_text) 792 | local board_tbl = pico.board.tbl(board) 793 | local is_thread = not parent 794 | 795 | local capcode, capcode_board 796 | if name == "##" and pico.account.current then 797 | name = pico.account.current.Name 798 | capcode = pico.account.current.Type 799 | capcode_board = pico.account.current.Board 800 | end 801 | 802 | comment = comment and comment:gsub("[\1-\8\11-\31\127]", ""):gsub("^\n+", ""):gsub("%s+$", "") or "" 803 | 804 | if not board_tbl then 805 | return nil, "Board does not exist" 806 | elseif board_tbl.Lock == 1 and not permit("admin gvol bo lvol", "board", board) then 807 | return nil, "Board is locked" 808 | elseif board_tbl.PPHLimit and pico.board.stats.postrate(board, 1, 1) > board_tbl.PPHLimit then 809 | return nil, "Maximum post creation rate exceeded" 810 | elseif #comment > board_tbl.PostMaxLength then 811 | return nil, "Post text too long" 812 | elseif select(2, comment:gsub("\r?\n", "")) > board_tbl.PostMaxNewlines then 813 | return nil, "Post contained too many newlines" 814 | elseif select(2, comment:gsub("\r?\n\r?\n", "")) > board_tbl.PostMaxDblNewlines then 815 | return nil, "Post contained too many double newlines" 816 | elseif name and #name > 64 then 817 | return nil, "Name too long" 818 | elseif email and #email > 64 then 819 | return nil, "Email too long" 820 | elseif subject and #subject > 64 then 821 | return nil, "Subject too long" 822 | elseif (not files or #files == 0) and comment == "" then 823 | return nil, "Post is blank" 824 | elseif ((is_thread and board_tbl.ThreadCaptcha == 1) or (not is_thread and board_tbl.PostCaptcha == 1)) 825 | and not permit("admin gvol bo lvol", "post", board) 826 | and not pico.captcha.check(captcha_id, captcha_text) then 827 | return nil, "Captcha is required but no valid captcha supplied" 828 | elseif is_thread then 829 | if board_tbl.TPHLimit and pico.board.stats.threadrate(board, 1, 1) > board_tbl.TPHLimit then 830 | return nil, "Maximum thread creation rate exceeded" 831 | elseif #comment < board_tbl.ThreadMinLength then 832 | return nil, "Thread text too short" 833 | end 834 | else 835 | local parent_tbl = pico.post.tbl(board, parent) 836 | if not parent_tbl then 837 | return nil, "Parent thread does not exist" 838 | elseif parent_tbl.Parent then 839 | return nil, "Parent post is not a thread" 840 | elseif parent_tbl.Lock == 1 and not permit("admin gvol bo lvol", "post", board) then 841 | return nil, "Parent thread is locked" 842 | elseif parent_tbl.Cycle ~= 1 and board_tbl.PostLimit 843 | and parent_tbl.ReplyCount >= board_tbl.PostLimit then 844 | return nil, "Thread full" 845 | end 846 | end 847 | 848 | db:e("BEGIN TRANSACTION") 849 | db:e("INSERT INTO Posts (Board, Parent, Name, Email, Subject, Capcode, CapcodeBoard, Comment) " .. 850 | "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", board, parent, name, email, subject, capcode, capcode_board, comment) 851 | local number = db:r1("SELECT MaxPostNumber FROM Boards WHERE Name = ?", board) 852 | 853 | pico.file.create_refs(board, number, files) 854 | pico.post.create_refs(board, number, parent, email, comment) 855 | 856 | db:e("END TRANSACTION") 857 | return number 858 | end 859 | 860 | function pico.post.set(board, parent, date, name, email, subject, capcode, capcode_board, comment, files) 861 | local auth, msg = permit("admin gvol") 862 | if not auth then return auth, msg end 863 | 864 | db:e("BEGIN TRANSACTION") 865 | db:e("INSERT INTO Posts (Board, Parent, Date, Name, Email, Subject, Capcode, CapcodeBoard, Comment) " .. 866 | "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", board, parent, date, name, email, subject, capcode, capcode_board, comment) 867 | local number = db:r1("SELECT MaxPostNumber FROM Boards WHERE Name = ?", board) 868 | 869 | pico.file.create_refs(board, number, files) 870 | pico.post.create_refs(board, number, parent, email, comment) 871 | 872 | db:e("END TRANSACTION") 873 | return number 874 | end 875 | 876 | function pico.post.create_refs(board, number, parent, email, comment) 877 | if not email or not (email == "nofo" or email:match("^nofo ") or email:match(" nofo$") or email:match(" nofo ")) then 878 | for ref in comment:gmatch(">>(%d+)") do 879 | ref = tonumber(ref) 880 | 881 | -- 1. Ensure that the reference doesn't already exist. 882 | -- 2. Ensure that the post being referred to does exist. 883 | -- 3. Ensure that the post being referred to is in the same thread as the referrer. 884 | -- 4. Ensure that the post being referred to is not the same as the referrer. 885 | if ref ~= number then 886 | db:e("INSERT INTO Refs SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM Refs WHERE Board = ? AND Referee = ? AND Referrer = ?) = 0 " .. 887 | "AND (SELECT TRUE FROM Posts WHERE Board = ? AND Number = ?) " .. 888 | "AND ((SELECT Parent FROM Posts WHERE Board = ? AND Number = ?) = ? OR (? = ?))", 889 | board, ref, number, board, ref, number, board, ref, board, ref, parent, ref, parent) 890 | end 891 | end 892 | end 893 | end 894 | 895 | function pico.post.delete(board, number, reason) 896 | local auth, msg = permit("admin gvol bo lvol", "post", board) 897 | if not auth then return auth, msg end 898 | 899 | if not db:b("SELECT TRUE FROM Posts WHERE Board = ? AND Number = ?", board, number) then 900 | return false, "Post does not exist" 901 | end 902 | 903 | db:e("DELETE FROM Posts WHERE Board = ? AND Number = ?", board, number) 904 | pico.log.insert(board, "Deleted post /%s/%d for reason: %s", board, number, reason) 905 | pico.file.clean() 906 | return true, "Post deleted successfully" 907 | end 908 | 909 | -- example: pico.post.multidelete("b", "31-57 459-1000", "33 35 48 466", "spam") 910 | function pico.post.multidelete(board, include, exclude, reason) 911 | local auth, msg = permit("admin bo", "board", board) 912 | if not auth then return auth, msg end 913 | if not include then return false, "Invalid include parameter" end 914 | 915 | if not pico.board.exists(board) then 916 | return false, "Board does not exist" 917 | end 918 | 919 | local sql = { "DELETE FROM Posts WHERE Board = ? AND (FALSE" } 920 | local sqlp = { board } 921 | local inclist = include:tokenize() 922 | 923 | local function genspec(spec, sql, sqlp) 924 | if spec:match("-") then 925 | local spec_tbl = spec:tokenize("-") 926 | if #spec_tbl ~= 2 then 927 | return false, "Invalid range specification" 928 | end 929 | 930 | local start, finish = unpack(spec_tbl) 931 | start, finish = tonumber(start), tonumber(finish) 932 | if not start or not finish then 933 | return false, "Invalid range specification" 934 | end 935 | 936 | sql[#sql + 1] = "OR Number BETWEEN ? AND ?" 937 | sqlp[#sqlp + 1] = start 938 | sqlp[#sqlp + 1] = finish 939 | else 940 | local number = tonumber(spec) 941 | if not number then 942 | return false, "Invalid single specification" 943 | end 944 | 945 | sql[#sql + 1] = "OR Number = ?" 946 | sqlp[#sqlp + 1] = number 947 | end 948 | 949 | return true 950 | end 951 | 952 | for i = 1, #inclist do 953 | local result, msg = genspec(inclist[i], sql, sqlp) 954 | if not result then return result, msg end 955 | end 956 | sql[#sql + 1] = ") AND NOT (FALSE" 957 | if exclude then 958 | local exclist = exclude:tokenize() 959 | for i = 1, #exclist do 960 | local result, msg = genspec(exclist[i], sql, sqlp) 961 | if not result then return result, msg end 962 | end 963 | end 964 | sql[#sql + 1] = ")" 965 | 966 | db:e(table.concat(sql, " "), unpack(sqlp)) 967 | pico.log.insert(board, "Deleted posts {%s}%s for reason: %s", 968 | include, exclude and (" excluding {" .. exclude .. "}") or "", reason) 969 | pico.file.clean() 970 | return true, "Posts deleted successfully" 971 | end 972 | 973 | function pico.post.pattdelete(pattern, reason) 974 | local auth, msg = permit("admin") 975 | if not auth then return auth, msg end 976 | if not pattern or #pattern < 6 then return false, "Invalid or too short include pattern" end 977 | 978 | db:e("DELETE FROM Posts WHERE Comment LIKE ? ESCAPE '$'", pattern) 979 | pico.log.insert(nil, "Deleted posts matching pattern '%s' for reason: %s", pattern, reason) 980 | pico.file.clean() 981 | return true, "Posts deleted successfully" 982 | end 983 | 984 | -- remove a file from a post without deleting it 985 | function pico.post.unlink(board, number, file, reason) 986 | local auth, msg = permit("admin gvol bo lvol", "post", board) 987 | if not auth then return auth, msg end 988 | 989 | if not db:b("SELECT TRUE FROM FileRefs WHERE Board = ? AND Number = ? AND File = ?", 990 | board, number, file) then 991 | return false, "No such file in that particular post" 992 | end 993 | 994 | db:e("DELETE FROM FileRefs WHERE Board = ? AND Number = ? AND File = ?", board, number, file) 995 | pico.log.insert(board, "Unlinked file %s from /%s/%d for reason: %s", 996 | file, board, number, reason) 997 | pico.file.clean() 998 | return true, "File unlinked successfully" 999 | end 1000 | 1001 | function pico.post.spoiler(board, number, file, reason) 1002 | local auth, msg = permit("admin gvol bo lvol", "post", board) 1003 | if not auth then return auth, msg end 1004 | 1005 | if not db:b("SELECT TRUE FROM FileRefs WHERE Board = ? AND Number = ? AND File = ?", 1006 | board, number, file) then 1007 | return false, "No such file in the given post" 1008 | end 1009 | 1010 | db:e("UPDATE FileRefs SET Spoiler = NOT Spoiler WHERE Board = ? AND Number = ? AND File = ?", 1011 | board, number, file) 1012 | pico.log.insert(board, "Toggled spoiler on file %s from /%s/%d for reason: %s", 1013 | file, board, number, reason) 1014 | return true, "Spoiler toggled on file sucessfully" 1015 | end 1016 | 1017 | -- 1018 | -- THREAD ACCESS AND MODIFICATION FUNCTIONS 1019 | -- 1020 | 1021 | -- Return entire thread (parent + all replies + all file info) as a table 1022 | function pico.thread.tbl(board, number, page) 1023 | if not pico.thread.exists(board, number) then 1024 | return nil, nil, "Post is not a thread or does not exist" 1025 | end 1026 | 1027 | db:e("BEGIN TRANSACTION") 1028 | local thread_tbl, pagecount 1029 | if page then 1030 | local pagesize = pico.global.get("threadpagesize", 50) 1031 | thread_tbl = db:q("SELECT Posts.*, LastBumpDate, Sticky, Lock, Autosage, Cycle, ReplyCount " .. 1032 | "FROM Threads JOIN Posts USING(Board, Number) " .. 1033 | "WHERE Board = ? AND Number = ? " .. 1034 | "UNION ALL " .. 1035 | "SELECT * FROM " .. 1036 | "(SELECT *, " .. 1037 | "NULL AS LastBumpDate, NULL AS Sticky, NULL AS Lock, " .. 1038 | "NULL AS Autosage, NULL AS Cycle, NULL AS ReplyCount " .. 1039 | "FROM Posts " .. 1040 | "WHERE Board = ? AND Parent = ? ORDER BY Number ASC " .. 1041 | "LIMIT ? OFFSET ?)", 1042 | board, number, 1043 | board, number, pagesize, (page - 1) * pagesize) 1044 | pagecount = db:r1("SELECT ((COUNT(*) - 1) / CAST(? AS INTEGER)) + 1 FROM Posts WHERE Board = ? AND Parent = ?", 1045 | pagesize, board, number) 1046 | 1047 | else 1048 | thread_tbl = db:q("SELECT * FROM Posts LEFT JOIN Threads USING(Board, Number) " .. 1049 | "WHERE Board = ? AND (Number = ? OR Parent = ?) ORDER BY Number ASC", 1050 | board, number, number) 1051 | end 1052 | for i = 1, #thread_tbl do 1053 | local post_tbl = thread_tbl[i] 1054 | post_tbl.Files = pico.file.list(post_tbl.Board, post_tbl.Number) 1055 | end 1056 | db:e("END TRANSACTION") 1057 | 1058 | return thread_tbl, pagecount 1059 | end 1060 | 1061 | -- toggle sticky, lock, autosage, or cycle 1062 | function pico.thread.toggle(attribute, board, number, reason) 1063 | local auth, msg = permit("admin gvol bo lvol", "post", board) 1064 | if not auth then return auth, msg end 1065 | 1066 | if not pico.thread.exists(board, number) then 1067 | return false, "Thread does not exist" 1068 | end 1069 | 1070 | if attribute == "sticky" then 1071 | db:e("UPDATE Threads SET Sticky = NOT Sticky WHERE Board = ? AND Number = ?", board, number) 1072 | elseif attribute == "lock" then 1073 | db:e("UPDATE Threads SET Lock = NOT Lock WHERE Board = ? AND Number = ?", board, number) 1074 | elseif attribute == "autosage" then 1075 | db:e("UPDATE Threads SET Autosage = NOT Autosage WHERE Board = ? AND Number = ?", board, number) 1076 | elseif attribute == "cycle" then 1077 | db:e("UPDATE Threads SET Cycle = NOT Cycle WHERE Board = ? AND Number = ?", board, number) 1078 | else 1079 | return false, "Invalid attribute" 1080 | end 1081 | 1082 | pico.log.insert(board, "Toggled attribute '%s' on /%s/%d for reason: %s", 1083 | attribute, board, number, reason) 1084 | return true, "Attribute toggled successfully" 1085 | end 1086 | 1087 | -- 1. Fetch all contents of the thread. 1088 | -- 2. For each post of the thread, including the OP: 1089 | -- 1. Rewrite references in the post's comment using the old->new lookup table. 1090 | -- 2. Repost the post to the new board. 1091 | -- 3. Keep a lookup table of the old post number and the new post number. 1092 | -- 3. Delete the old thread. 1093 | function pico.thread.move(board, number, newboard, reason) 1094 | local auth, msg = permit("admin gvol", "post", board) 1095 | if not auth then return auth, msg end 1096 | 1097 | if not pico.thread.exists(board, number) then 1098 | return false, "Post does not exist or is not a thread" 1099 | elseif not pico.board.exists(newboard) then 1100 | return false, "Destination board does not exist" 1101 | end 1102 | 1103 | local thread_tbl = pico.thread.tbl(board, number) 1104 | local number_lut = {} 1105 | local newthread 1106 | 1107 | for i = 1, #thread_tbl do 1108 | local post_tbl = thread_tbl[i] 1109 | post_tbl.Comment = post_tbl.Comment:gsub(">>(%d+)", number_lut) 1110 | post_tbl.Parent = post_tbl.Parent and newthread 1111 | 1112 | for j = 1, #post_tbl.Files do 1113 | post_tbl.Files[j] = { Name = post_tbl.Files[j].DownloadName, 1114 | Hash = post_tbl.Files[j].Name, 1115 | Spoiler = post_tbl.Files[j].Spoiler } 1116 | end 1117 | 1118 | local newnumber = pico.post.set(newboard, post_tbl.Parent, post_tbl.Date, 1119 | post_tbl.Name, post_tbl.Email, post_tbl.Subject, 1120 | post_tbl.Capcode, post_tbl.CapcodeBoard, 1121 | post_tbl.Comment, post_tbl.Files) 1122 | number_lut[tostring(post_tbl.Number)] = ">>" .. tostring(newnumber) 1123 | 1124 | if i == 1 then 1125 | newthread = newnumber 1126 | end 1127 | end 1128 | 1129 | db:e("DELETE FROM Posts WHERE Board = ? AND Number = ?", board, number) 1130 | pico.log.insert(nil, "Moved thread /%s/%d to /%s/%d for reason: %s", board, number, newboard, newthread, reason) 1131 | return true, "Thread moved successfully" 1132 | end 1133 | 1134 | function pico.thread.merge(board, number, newthread, reason) 1135 | local auth, msg = permit("admin gvol bo lvol", "post", board) 1136 | if not auth then return auth, msg end 1137 | 1138 | if not pico.thread.exists(board, number) then 1139 | return false, "Source thread does not exist" 1140 | elseif not pico.thread.exists(board, newthread) then 1141 | return false, "Destination thread does not exist" 1142 | end 1143 | 1144 | db:e("BEGIN TRANSACTION") 1145 | db:e("UPDATE Posts SET Parent = ? WHERE Board = ? AND (Number = ? OR Parent = ?)", newthread, board, number, number) 1146 | db:e("DELETE FROM Threads WHERE Board = ? AND Number = ?", board, number) 1147 | db:e("END TRANSACTION") 1148 | pico.log.insert(board, "Merged thread /%s/%d into /%s/%d for reason: %s", board, number, board, newthread, reason) 1149 | return true, "Thread merged successfully" 1150 | end 1151 | 1152 | function pico.thread.exists(board, number) 1153 | return db:b("SELECT TRUE FROM Threads WHERE Board = ? AND Number = ?", board, number) 1154 | end 1155 | 1156 | -- 1157 | -- LOG FUNCTIONS 1158 | -- 1159 | 1160 | -- Use nil for the board parameter if the action applies to all boards. 1161 | function pico.log.insert(board, ...) 1162 | local account = pico.account.current and pico.account.current.Name 1163 | db:e("INSERT INTO Logs (Account, Board, Description) VALUES (?, ?, ?)", 1164 | account, board, string.format(...)) 1165 | end 1166 | 1167 | function pico.log.retrieve(page) 1168 | page = tonumber(page) or 1 1169 | local pagesize = pico.global.get("logpagesize", 50) 1170 | return db:q("SELECT * FROM Logs ORDER BY ROWID DESC LIMIT ? OFFSET ?", pagesize, (page - 1) * pagesize), 1171 | db:r1("SELECT ((COUNT(*) - 1) / CAST(? AS INTEGER)) + 1 FROM Logs", pagesize) 1172 | end 1173 | 1174 | -- 1175 | -- CAPTCHA FUNCTIONS 1176 | -- 1177 | 1178 | -- return a captcha image (jpg) and its associated id 1179 | function pico.captcha.create() 1180 | local width = 300 1181 | local height = 100 1182 | 1183 | local min_line_size = 10 1184 | local max_line_size = 20 1185 | 1186 | local min_circle_count = 3 1187 | local max_circle_count = 5 1188 | local min_circle_size = 15 1189 | local max_circle_size = 30 1190 | 1191 | local min_distortion_count = 3 1192 | local max_distortion_count = 5 1193 | local distortion_limit = 30 1194 | 1195 | local font = "DejaVu-Sans" 1196 | local font_size = 70 1197 | 1198 | local min_text_translation_x = -5 1199 | local max_text_translation_x = 5 1200 | local min_text_translation_y = -15 1201 | local max_text_translation_y = 15 1202 | local min_text_rotation = -30 1203 | local max_text_rotation = 30 1204 | local min_text_skew = -30 1205 | local max_text_skew = 30 1206 | 1207 | local command = ("exec magick -size %dx%d -compose Exclusion "):format(width, height) 1208 | 1209 | -- Text 1210 | local text = random.string(6, "%l") 1211 | command = command .. ("'(' xc: -font %s -pointsize %d -gravity center "):format(font, font_size) 1212 | for i = 1, 6 do 1213 | command = command .. ("-draw \"translate %d,%d rotate %d skewX %d text 0,0 '%s'\" "):format( 1214 | (48 * i - 168) + random.int(min_text_translation_x, max_text_translation_x), 1215 | random.int(min_text_translation_y, max_text_translation_y), 1216 | random.int(min_text_rotation, max_text_rotation), 1217 | random.int(min_text_skew, max_text_skew), 1218 | text:sub(i, i)) 1219 | end 1220 | command = command .. "')' " 1221 | 1222 | -- Text: alternative 1223 | -- command = command .. ("'(' xc: -font %s -pointsize %d -gravity center -draw \"text 0,0 '%s'\" ')' "):format( 1224 | -- font, font_size, text) 1225 | 1226 | -- Line mask 1227 | command = command .. "'(' xc: " 1228 | local line_top = random.int(min_line_size - max_line_size, 0) 1229 | while line_top < height do 1230 | local line_bottom = line_top + random.int(min_line_size, max_line_size) 1231 | command = command .. ("-draw 'rectangle 0,%d %d,%d' "):format(line_top, width, line_bottom) 1232 | line_top = line_bottom + random.int(min_line_size, max_line_size) 1233 | end 1234 | command = command .. "')' -composite " 1235 | 1236 | -- Circle mask 1237 | command = command .. "'(' xc: " 1238 | for i = 1, random.int(min_circle_count, max_circle_count) do 1239 | local x = random.int(width * 0.1, width * 0.9) 1240 | local y = random.int(height * 0.1, height * 0.9) 1241 | local size = random.int(min_circle_size, max_circle_size) 1242 | command = command .. ("-draw 'circle %d,%d %d,%d' "):format(x, y, x + size, y) 1243 | end 1244 | command = command .. "')' -composite " 1245 | 1246 | -- Distortion 1247 | command = command .. ("-distort Shepards '0,0 0,0 0,%d 0,%d %d,0 %d,0 %d,%d %d,%d"):format( 1248 | height, height, width, width, width, height, width, height) 1249 | local distortion_count = random.int(min_distortion_count, max_distortion_count) 1250 | local distortion_size = width / distortion_count 1251 | for i = 0, distortion_count - 1 do 1252 | local origin_x = random.int(distortion_size * i, distortion_size * (1 + i)) 1253 | local origin_y = random.int(0, height) 1254 | 1255 | local min_width_destination = origin_x - distortion_limit 1256 | local min_height_destination = origin_y - distortion_limit 1257 | local distortion_limit_x = origin_x + distortion_limit 1258 | local distortion_limit_y = origin_y + distortion_limit 1259 | 1260 | local destination_x = random.int(min_width_destination, distortion_limit_x) 1261 | local destination_y = random.int(min_height_destination, distortion_limit_y) 1262 | 1263 | command = command .. (" %d,%d %d,%d"):format(origin_x, origin_y, destination_x, destination_y) 1264 | end 1265 | command = command .. "' " 1266 | 1267 | -- Blur 1268 | command = command .. "-blur 1x1 jpg:-" 1269 | 1270 | local p = assert(io.popen(command, "r")) 1271 | local captcha_data = p:read("*a") 1272 | p:close() 1273 | 1274 | local captcha_id = random.string(16) 1275 | db:e("INSERT INTO Captchas VALUES (?, ?, STRFTIME('%s', 'now') + 1200)", captcha_id, text) 1276 | 1277 | return captcha_id, captcha_data 1278 | end 1279 | 1280 | function pico.captcha.check(id, text) 1281 | if db:b("SELECT TRUE FROM Captchas WHERE Id = ? AND Text = LOWER(?) AND ExpireDate > STRFTIME('%s', 'now')", id, text) then 1282 | db:e("DELETE FROM Captchas WHERE Id = ?", id) 1283 | return true 1284 | end 1285 | return false 1286 | end 1287 | 1288 | return pico 1289 | --------------------------------------------------------------------------------