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