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