a mini social media app for small communities
at main 21 kB view raw
1module webapp 2 3import veb 4import auth 5import entity { Like, Post, User } 6import database { PostSearchResult } 7import net.http 8import json 9 10// search_hard_limit is the maximum limit for a search query, used to prevent 11// people from requesting searches with huge limits and straining the SQL server 12pub const search_hard_limit = 50 13pub const not_logged_in_msg = 'you are not logged in!' 14 15////// user ////// 16 17struct HcaptchaResponse { 18pub: 19 success bool 20 error_codes []string @[json: 'error-codes'] 21} 22 23@['/api/user/register'; post] 24fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { 25 // before doing *anything*, check the captchas 26 if app.config.hcaptcha.enabled { 27 token := ctx.form['h-captcha-response'] 28 response := http.post_form('https://api.hcaptcha.com/siteverify', { 29 'secret': app.config.hcaptcha.secret 30 'remoteip': ctx.ip() 31 'response': token 32 }) or { 33 return ctx.server_error('failed to post hcaptcha response: ${err}') 34 } 35 data := json.decode(HcaptchaResponse, response.body) or { 36 return ctx.server_error('failed to decode hcaptcha response: ${err}') 37 } 38 if !data.success { 39 return ctx.server_error('failed to verify hcaptcha: ${data}') 40 } 41 } 42 43 if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { 44 return ctx.server_error('invalid invite code') 45 } 46 47 if app.get_user_by_name(username) != none { 48 return ctx.server_error('username taken') 49 } 50 51 // validate username 52 if !app.validators.username.validate(username) { 53 return ctx.server_error('invalid username') 54 } 55 56 // validate password 57 if !app.validators.password.validate(password) { 58 return ctx.server_error('invalid password') 59 } 60 61 if password != ctx.form['confirm-password'] { 62 return ctx.server_error('passwords do not match') 63 } 64 65 salt := auth.generate_salt() 66 mut user := User{ 67 username: username 68 password: auth.hash_password_with_salt(password, salt) 69 password_salt: salt 70 } 71 72 if app.config.instance.default_theme != '' { 73 user.theme = app.config.instance.default_theme 74 } 75 76 if x := app.new_user(user) { 77 app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 78 app.config.welcome.body.replace('%s', x.get_name())) 79 token := app.auth.add_token(x.id) or { 80 eprintln('api_user_register: could not create token for user with id ${x.id}: ${err}') 81 return ctx.server_error('could not create token for user') 82 } 83 ctx.set_cookie( 84 name: 'token' 85 value: token 86 same_site: .same_site_none_mode 87 secure: true 88 path: '/' 89 ) 90 } else { 91 eprintln('api_user_register: could not log into newly-created user: ${user}') 92 return ctx.server_error('could not log into newly-created user.') 93 } 94 95 return ctx.ok('user registered') 96} 97 98@['/api/user/set_username'; post] 99fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 100 user := app.whoami(mut ctx) or { 101 return ctx.unauthorized(not_logged_in_msg) 102 } 103 104 if app.get_user_by_name(new_username) != none { 105 return ctx.server_error('username taken') 106 } 107 108 // validate username 109 if !app.validators.username.validate(new_username) { 110 return ctx.server_error('invalid username') 111 } 112 113 if !app.set_username(user.id, new_username) { 114 return ctx.server_error('failed to update username') 115 } 116 117 return ctx.ok('username updated') 118} 119 120@['/api/user/set_password'; post] 121fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 122 user := app.whoami(mut ctx) or { 123 return ctx.unauthorized(not_logged_in_msg) 124 } 125 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 127 return ctx.server_error('current_password is incorrect') 128 } 129 130 // validate password 131 if !app.validators.password.validate(new_password) { 132 return ctx.server_error('invalid password') 133 } 134 135 if new_password != ctx.form['confirm_password'] { 136 return ctx.server_error('passwords do not match') 137 } 138 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 140 if !app.set_password(user.id, hashed_new_password) { 141 return ctx.server_error('failed to update password') 142 } 143 144 // invalidate tokens and log out 145 app.auth.delete_tokens_for_user(user.id) or { 146 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 147 return ctx.server_error('failed to delete tokens during password deletion') 148 } 149 ctx.set_cookie( 150 name: 'token' 151 value: '' 152 same_site: .same_site_none_mode 153 secure: true 154 path: '/' 155 ) 156 157 return ctx.ok('password updated') 158} 159 160@['/api/user/login'; post] 161fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 162 user := app.get_user_by_name(username) or { 163 return ctx.server_error('invalid credentials') 164 } 165 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 167 return ctx.server_error('invalid credentials') 168 } 169 170 token := app.auth.add_token(user.id) or { 171 eprintln('failed to add token on log in: ${err}') 172 return ctx.server_error('could not create token for user with id ${user.id}') 173 } 174 175 ctx.set_cookie( 176 name: 'token' 177 value: token 178 same_site: .same_site_none_mode 179 secure: true 180 path: '/' 181 ) 182 183 return ctx.ok('logged in') 184} 185 186@['/api/user/logout'; post] 187fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 188 if token := ctx.get_cookie('token') { 189 if user := app.get_user_by_token(token) { 190 // app.auth.delete_tokens_for_ip(ctx.ip()) or { 191 // eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 192 // return ctx.redirect('/login') 193 // } 194 app.auth.delete_tokens_for_value(token) or { 195 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 196 } 197 } else { 198 eprintln('failed to get user for token for logout') 199 } 200 } else { 201 eprintln('failed to get token cookie for logout') 202 } 203 204 ctx.set_cookie( 205 name: 'token' 206 value: '' 207 same_site: .same_site_none_mode 208 secure: true 209 path: '/' 210 ) 211 212 return ctx.ok('logged out') 213} 214 215@['/api/user/full_logout'; post] 216fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 217 if token := ctx.get_cookie('token') { 218 if user := app.get_user_by_token(token) { 219 app.auth.delete_tokens_for_user(user.id) or { 220 eprintln('failed to yeet tokens for ${user.id}') 221 } 222 } else { 223 eprintln('failed to get user for token for full_logout') 224 } 225 } else { 226 eprintln('failed to get token cookie for full_logout') 227 } 228 229 ctx.set_cookie( 230 name: 'token' 231 value: '' 232 same_site: .same_site_none_mode 233 secure: true 234 path: '/' 235 ) 236 237 return ctx.ok('logged out') 238} 239 240@['/api/user/set_nickname'; post] 241fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 242 user := app.whoami(mut ctx) or { 243 return ctx.unauthorized(not_logged_in_msg) 244 } 245 246 mut clean_nickname := ?string(nickname.trim_space()) 247 if clean_nickname or { '' } == '' { 248 clean_nickname = none 249 } 250 251 // validate 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 253 return ctx.server_error('invalid nickname') 254 } 255 256 if !app.set_nickname(user.id, clean_nickname) { 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 258 return ctx.server_error('failed to update nickname') 259 } 260 261 return ctx.ok('updated nickname') 262} 263 264@['/api/user/set_muted'; post] 265fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 266 user := app.whoami(mut ctx) or { 267 return ctx.unauthorized(not_logged_in_msg) 268 } 269 270 to_mute := app.get_user_by_id(id) or { 271 return ctx.server_error('no such user') 272 } 273 274 if user.admin { 275 if !app.set_muted(to_mute.id, muted) { 276 return ctx.server_error('failed to change mute status') 277 } 278 return ctx.ok('muted user') 279 } else { 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 281 return ctx.unauthorized('insufficient permissions') 282 } 283} 284 285@['/api/user/set_automated'; post] 286fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 287 user := app.whoami(mut ctx) or { 288 return ctx.unauthorized(not_logged_in_msg) 289 } 290 291 if !app.set_automated(user.id, is_automated) { 292 return ctx.server_error('failed to set automated status.') 293 } 294 295 if is_automated { 296 return ctx.ok('you\'re now a bot! :D') 297 } else { 298 return ctx.ok('you\'re no longer a bot :(') 299 } 300} 301 302@['/api/user/set_theme'; post] 303fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 304 if !app.config.instance.allow_changing_theme { 305 return ctx.server_error('this instance disallows changing themes :(') 306 } 307 308 user := app.whoami(mut ctx) or { 309 return ctx.unauthorized(not_logged_in_msg) 310 } 311 312 mut theme := ?string(none) 313 if url.trim_space() == '' { 314 theme = app.config.instance.default_theme 315 } else { 316 theme = url.trim_space() 317 } 318 319 if !app.set_theme(user.id, theme) { 320 return ctx.server_error('failed to change theme') 321 } 322 323 return ctx.ok('theme updated') 324} 325 326@['/api/user/set_css'; post] 327fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { 328 if !app.config.instance.allow_changing_theme { 329 return ctx.server_error('this instance disallows changing themes :(') 330 } 331 332 user := app.whoami(mut ctx) or { 333 return ctx.unauthorized(not_logged_in_msg) 334 } 335 336 c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() } 337 338 if !app.set_css(user.id, c) { 339 return ctx.server_error('failed to change css') 340 } 341 342 return ctx.ok('css updated') 343} 344 345@['/api/user/set_pronouns'; post] 346fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 347 user := app.whoami(mut ctx) or { 348 return ctx.unauthorized(not_logged_in_msg) 349 } 350 351 clean_pronouns := pronouns.trim_space() 352 if !app.validators.pronouns.validate(clean_pronouns) { 353 return ctx.server_error('invalid pronouns') 354 } 355 356 if !app.set_pronouns(user.id, clean_pronouns) { 357 return ctx.server_error('failed to change pronouns') 358 } 359 360 return ctx.ok('pronouns updated') 361} 362 363@['/api/user/set_bio'; post] 364fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 365 user := app.whoami(mut ctx) or { 366 return ctx.unauthorized(not_logged_in_msg) 367 } 368 369 clean_bio := bio.trim_space() 370 if !app.validators.user_bio.validate(clean_bio) { 371 return ctx.server_error('invalid bio') 372 } 373 374 if !app.set_bio(user.id, clean_bio) { 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 376 return ctx.server_error('failed to update bio') 377 } 378 379 return ctx.ok('bio updated') 380} 381 382@['/api/user/get_name'; get] 383fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 384 if !app.config.instance.public_data { 385 _ := app.whoami(mut ctx) or { 386 return ctx.unauthorized('no such user') 387 } 388 } 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 390 return ctx.text(user.get_name()) 391} 392 393@['/api/user/delete'; post] 394fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 395 user := app.whoami(mut ctx) or { 396 return ctx.unauthorized(not_logged_in_msg) 397 } 398 399 if user.admin || user.id == id { 400 println('attempting to delete ${id} as ${user.id}') 401 402 // yeet 403 if !app.delete_user(user.id) { 404 return ctx.server_error('failed to delete user: ${id}') 405 } 406 407 app.auth.delete_tokens_for_user(id) or { 408 eprintln('failed to delete tokens for user during deletion: ${id}') 409 } 410 // log out 411 if user.id == id { 412 ctx.set_cookie( 413 name: 'token' 414 value: '' 415 same_site: .same_site_none_mode 416 secure: true 417 path: '/' 418 ) 419 } 420 println('deleted user ${id}') 421 return ctx.ok('user deleted') 422 } else { 423 return ctx.unauthorized('be nice. deleting other users is off-limits.') 424 } 425} 426 427@['/api/user/search'; get] 428fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 429 _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 430 if limit >= search_hard_limit { 431 return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') 432 } 433 users := app.search_for_users(query, limit, offset) 434 return ctx.json[[]User](users) 435} 436 437@['/api/user/whoami'; get] 438fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 439 user := app.whoami(mut ctx) or { 440 return ctx.unauthorized(not_logged_in_msg) 441 } 442 return ctx.text(user.username) 443} 444 445/// user/notification /// 446 447@['/api/user/notification/clear'; post] 448fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 449 user := app.whoami(mut ctx) or { 450 return ctx.unauthorized(not_logged_in_msg) 451 } 452 453 if notification := app.get_notification_by_id(id) { 454 if notification.user_id != user.id { 455 return ctx.server_error('no such notification for user') 456 } else if !app.delete_notification(id) { 457 return ctx.server_error('failed to delete notification') 458 } 459 } else { 460 return ctx.server_error('no such notification for user') 461 } 462 463 return ctx.ok('cleared notification') 464} 465 466@['/api/user/notification/clear_all'; post] 467fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 468 user := app.whoami(mut ctx) or { 469 return ctx.unauthorized(not_logged_in_msg) 470 } 471 if !app.delete_notifications_for_user(user.id) { 472 return ctx.server_error('failed to delete notifications') 473 } 474 return ctx.ok('cleared notifications') 475} 476 477////// post ////// 478 479@['/api/post/new_post'; post] 480fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 481 user := app.whoami(mut ctx) or { 482 return ctx.unauthorized(not_logged_in_msg) 483 } 484 485 if user.muted { 486 return ctx.server_error('you are muted!') 487 } 488 489 // validate title 490 if !app.validators.post_title.validate(title) { 491 return ctx.server_error('invalid title') 492 } 493 494 // validate body 495 if !app.validators.post_body.validate(body) { 496 return ctx.server_error('invalid body') 497 } 498 499 nsfw := 'nsfw' in ctx.form 500 if nsfw && !app.config.post.allow_nsfw { 501 return ctx.server_error('nsfw posts are not allowed on this instance') 502 } 503 504 mut post := Post{ 505 author_id: user.id 506 title: title 507 body: body 508 nsfw: nsfw 509 } 510 511 if replying_to != 0 { 512 // check if replying post exists 513 app.get_post_by_id(replying_to) or { 514 return ctx.server_error('the post you are trying to reply to does not exist') 515 } 516 post.replying_to = replying_to 517 } 518 519 if !app.add_post(post) { 520 println('failed to post: ${post} from user ${user.id}') 521 return ctx.server_error('failed to post') 522 } 523 524 //TODO: Can I not just get the ID directly?? This method feels dicey at best. 525 // find the post's id to process mentions with 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 527 app.process_post_mentions(x) 528 return ctx.ok('posted. id=${x.id}') 529 } else { 530 eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}') 531 return ctx.server_error('failed to get post ID, this error should never happen') 532 } 533} 534 535@['/api/post/delete'; post] 536fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 537 user := app.whoami(mut ctx) or { 538 return ctx.unauthorized(not_logged_in_msg) 539 } 540 541 post := app.get_post_by_id(id) or { 542 return ctx.server_error('post does not exist') 543 } 544 545 if user.admin || user.id == post.author_id { 546 if !app.delete_post(post.id) { 547 eprintln('api_post_delete: failed to delete post: ${id}') 548 return ctx.server_error('failed to delete post') 549 } 550 println('deleted post: ${id}') 551 return ctx.ok('post deleted') 552 } else { 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 554 return ctx.unauthorized('insufficient permissions') 555 } 556} 557 558@['/api/post/like'; post] 559fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 560 user := app.whoami(mut ctx) or { 561 return ctx.unauthorized(not_logged_in_msg) 562 } 563 564 post := app.get_post_by_id(id) or { 565 return ctx.server_error('post does not exist') 566 } 567 568 if app.does_user_like_post(user.id, post.id) { 569 if !app.unlike_post(post.id, user.id) { 570 eprintln('user ${user.id} failed to unlike post ${id}') 571 return ctx.server_error('failed to unlike post') 572 } 573 return ctx.ok('unliked post') 574 } else { 575 // remove the old dislike, if it exists 576 if app.does_user_dislike_post(user.id, post.id) { 577 if !app.unlike_post(post.id, user.id) { 578 eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 579 } 580 } 581 582 like := Like{ 583 user_id: user.id 584 post_id: post.id 585 is_like: true 586 } 587 if !app.add_like(like) { 588 eprintln('user ${user.id} failed to like post ${id}') 589 return ctx.server_error('failed to like post') 590 } 591 return ctx.ok('liked post') 592 } 593} 594 595@['/api/post/dislike'; post] 596fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 597 user := app.whoami(mut ctx) or { 598 return ctx.unauthorized(not_logged_in_msg) 599 } 600 601 post := app.get_post_by_id(id) or { 602 return ctx.server_error('post does not exist') 603 } 604 605 if app.does_user_dislike_post(user.id, post.id) { 606 if !app.unlike_post(post.id, user.id) { 607 eprintln('user ${user.id} failed to undislike post ${id}') 608 return ctx.server_error('failed to undislike post') 609 } 610 return ctx.ok('undisliked post') 611 } else { 612 // remove the old like, if it exists 613 if app.does_user_like_post(user.id, post.id) { 614 if !app.unlike_post(post.id, user.id) { 615 eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 616 } 617 } 618 619 like := Like{ 620 user_id: user.id 621 post_id: post.id 622 is_like: false 623 } 624 if !app.add_like(like) { 625 eprintln('user ${user.id} failed to dislike post ${id}') 626 return ctx.server_error('failed to dislike post') 627 } 628 return ctx.ok('disliked post') 629 } 630} 631 632@['/api/post/save'; post] 633fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 634 user := app.whoami(mut ctx) or { 635 return ctx.unauthorized(not_logged_in_msg) 636 } 637 638 if app.get_post_by_id(id) != none { 639 if app.toggle_save_post(user.id, id) { 640 return ctx.text('toggled save') 641 } else { 642 return ctx.server_error('failed to save post') 643 } 644 } else { 645 return ctx.server_error('post does not exist') 646 } 647} 648 649@['/api/post/save_for_later'; post] 650fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 651 user := app.whoami(mut ctx) or { 652 return ctx.unauthorized(not_logged_in_msg) 653 } 654 655 if app.get_post_by_id(id) != none { 656 if app.toggle_save_for_later_post(user.id, id) { 657 return ctx.text('toggled save') 658 } else { 659 return ctx.server_error('failed to save post') 660 } 661 } else { 662 return ctx.server_error('post does not exist') 663 } 664} 665 666@['/api/post/get_title'; get] 667fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 668 if !app.config.instance.public_data { 669 _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 670 } 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 672 return ctx.text(post.title) 673} 674 675@['/api/post/edit'; post] 676fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 677 user := app.whoami(mut ctx) or { 678 return ctx.unauthorized(not_logged_in_msg) 679 } 680 post := app.get_post_by_id(id) or { 681 return ctx.server_error('no such post') 682 } 683 if post.author_id != user.id { 684 return ctx.unauthorized('insufficient permissions') 685 } 686 687 nsfw := if 'nsfw' in ctx.form { 688 app.config.post.allow_nsfw 689 } else { 690 post.nsfw 691 } 692 693 if !app.update_post(id, title, body, nsfw) { 694 eprintln('failed to update post') 695 return ctx.server_error('failed to update post') 696 } 697 698 return ctx.ok('posted edited') 699} 700 701@['/api/post/pin'; post] 702fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 703 user := app.whoami(mut ctx) or { 704 return ctx.unauthorized(not_logged_in_msg) 705 } 706 707 if user.admin { 708 if !app.pin_post(id) { 709 eprintln('failed to pin post: ${id}') 710 return ctx.server_error('failed to pin post') 711 } 712 return ctx.ok('post pinned') 713 } else { 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 715 return ctx.unauthorized('insufficient permissions') 716 } 717} 718 719@['/api/post/get/<id>'; get] 720fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 721 if !app.config.instance.public_data { 722 _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 723 } 724 post := app.get_post_by_id(id) or { return ctx.text('no such post') } 725 return ctx.json[Post](post) 726} 727 728@['/api/post/search'; get] 729fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 730 _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 731 if limit >= search_hard_limit { 732 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 733 } 734 posts := app.search_for_posts(query, limit, offset) 735 return ctx.json[[]PostSearchResult](posts) 736} 737 738////// site ////// 739 740@['/api/site/set_motd'; post] 741fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 742 user := app.whoami(mut ctx) or { 743 return ctx.unauthorized(not_logged_in_msg) 744 } 745 746 if user.admin { 747 if !app.set_motd(motd) { 748 eprintln('failed to set motd: ${motd}') 749 return ctx.server_error('failed to set motd') 750 } 751 println('set motd to: ${motd}') 752 return ctx.ok('motd updated') 753 } else { 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 755 return ctx.unauthorized('insufficient permissions') 756 } 757}