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/// user/notification /// 401 402@['/api/user/notification/clear'] 403fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 404 user := app.whoami(mut ctx) or { 405 ctx.error('you are not logged in!') 406 return ctx.redirect('/login') 407 } 408 409 if notification := app.get_notification_by_id(id) { 410 if notification.user_id != user.id { 411 ctx.error('no such notification for user') 412 return ctx.redirect('/inbox') 413 } else { 414 if !app.delete_notification(id) { 415 ctx.error('failed to delete notification') 416 return ctx.redirect('/inbox') 417 } 418 } 419 } else { 420 ctx.error('no such notification for user') 421 } 422 423 return ctx.redirect('/inbox') 424} 425 426@['/api/user/notification/clear_all'] 427fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 428 user := app.whoami(mut ctx) or { 429 ctx.error('you are not logged in!') 430 return ctx.redirect('/login') 431 } 432 if !app.delete_notifications_for_user(user.id) { 433 ctx.error('failed to delete notifications') 434 return ctx.redirect('/inbox') 435 } 436 return ctx.redirect('/inbox') 437} 438 439////// post ////// 440 441@['/api/post/new_post'; post] 442fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 443 user := app.whoami(mut ctx) or { 444 ctx.error('not logged in!') 445 return ctx.redirect('/login') 446 } 447 448 if user.muted { 449 ctx.error('you are muted!') 450 return ctx.redirect('/post/new') 451 } 452 453 // validate title 454 if !app.validators.post_title.validate(title) { 455 ctx.error('invalid title') 456 return ctx.redirect('/post/new') 457 } 458 459 // validate body 460 if !app.validators.post_body.validate(body) { 461 ctx.error('invalid body') 462 return ctx.redirect('/post/new') 463 } 464 465 mut post := Post{ 466 author_id: user.id 467 title: title 468 body: body 469 } 470 471 if replying_to != 0 { 472 // check if replying post exists 473 app.get_post_by_id(replying_to) or { 474 ctx.error('the post you are trying to reply to does not exist') 475 return ctx.redirect('/post/new') 476 } 477 post.replying_to = replying_to 478 } 479 480 if !app.add_post(post) { 481 ctx.error('failed to post!') 482 println('failed to post: ${post} from user ${user.id}') 483 return ctx.redirect('/post/new') 484 } 485 486 // find the post's id to process mentions with 487 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 488 app.process_post_mentions(x) 489 return ctx.redirect('/post/${x.id}') 490 } else { 491 ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 492 return ctx.redirect('/me') 493 } 494} 495 496@['/api/post/delete'; post] 497fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 498 user := app.whoami(mut ctx) or { 499 ctx.error('not logged in!') 500 return ctx.redirect('/login') 501 } 502 503 post := app.get_post_by_id(id) or { 504 ctx.error('post does not exist') 505 return ctx.redirect('/') 506 } 507 508 if user.admin || user.id == post.author_id { 509 if !app.delete_post(post.id) { 510 ctx.error('failed to delete post') 511 eprintln('failed to delete post: ${id}') 512 return ctx.redirect('/') 513 } 514 println('deleted post: ${id}') 515 return ctx.redirect('/') 516 } else { 517 ctx.error('insufficient permissions!') 518 eprintln('insufficient perms to delete post: ${id} (${user.id})') 519 return ctx.redirect('/') 520 } 521} 522 523@['/api/post/like'] 524fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 525 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 526 527 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 528 529 if app.does_user_like_post(user.id, post.id) { 530 if !app.unlike_post(post.id, user.id) { 531 eprintln('user ${user.id} failed to unlike post ${id}') 532 return ctx.server_error('failed to unlike post') 533 } 534 return ctx.ok('unliked post') 535 } else { 536 // remove the old dislike, if it exists 537 if app.does_user_dislike_post(user.id, post.id) { 538 if !app.unlike_post(post.id, user.id) { 539 eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 540 } 541 } 542 543 like := Like{ 544 user_id: user.id 545 post_id: post.id 546 is_like: true 547 } 548 if !app.add_like(like) { 549 eprintln('user ${user.id} failed to like post ${id}') 550 return ctx.server_error('failed to like post') 551 } 552 return ctx.ok('liked post') 553 } 554} 555 556@['/api/post/dislike'] 557fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 558 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 559 560 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 561 562 if app.does_user_dislike_post(user.id, post.id) { 563 if !app.unlike_post(post.id, user.id) { 564 eprintln('user ${user.id} failed to undislike post ${id}') 565 return ctx.server_error('failed to undislike post') 566 } 567 return ctx.ok('undisliked post') 568 } else { 569 // remove the old like, if it exists 570 if app.does_user_like_post(user.id, post.id) { 571 if !app.unlike_post(post.id, user.id) { 572 eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 573 } 574 } 575 576 like := Like{ 577 user_id: user.id 578 post_id: post.id 579 is_like: false 580 } 581 if !app.add_like(like) { 582 eprintln('user ${user.id} failed to dislike post ${id}') 583 return ctx.server_error('failed to dislike post') 584 } 585 return ctx.ok('disliked post') 586 } 587} 588 589@['/api/post/save'] 590fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 591 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 592 593 if app.get_post_by_id(id) != none { 594 if app.toggle_save_post(user.id, id) { 595 return ctx.text('toggled save') 596 } else { 597 return ctx.server_error('failed to save post') 598 } 599 } else { 600 return ctx.server_error('post does not exist') 601 } 602} 603 604@['/api/post/save_for_later'] 605fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 606 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 607 608 if app.get_post_by_id(id) != none { 609 if app.toggle_save_for_later_post(user.id, id) { 610 return ctx.text('toggled save') 611 } else { 612 return ctx.server_error('failed to save post') 613 } 614 } else { 615 return ctx.server_error('post does not exist') 616 } 617} 618 619@['/api/post/get_title'] 620fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 621 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 622 return ctx.text(post.title) 623} 624 625@['/api/post/edit'; post] 626fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 627 user := app.whoami(mut ctx) or { 628 ctx.error('not logged in!') 629 return ctx.redirect('/login') 630 } 631 post := app.get_post_by_id(id) or { 632 ctx.error('no such post') 633 return ctx.redirect('/') 634 } 635 if post.author_id != user.id { 636 ctx.error('insufficient permissions') 637 return ctx.redirect('/') 638 } 639 640 if !app.update_post(id, title, body) { 641 eprintln('failed to update post') 642 ctx.error('failed to update post') 643 return ctx.redirect('/') 644 } 645 646 return ctx.redirect('/post/${id}') 647} 648 649@['/api/post/pin'; post] 650fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 651 user := app.whoami(mut ctx) or { 652 ctx.error('not logged in!') 653 return ctx.redirect('/login') 654 } 655 656 if user.admin { 657 if !app.pin_post(id) { 658 eprintln('failed to pin post: ${id}') 659 ctx.error('failed to pin post') 660 return ctx.redirect('/post/${id}') 661 } 662 return ctx.redirect('/post/${id}') 663 } else { 664 ctx.error('insufficient permissions!') 665 eprintln('insufficient perms to pin post: ${id} (${user.id})') 666 return ctx.redirect('/') 667 } 668} 669 670@['/api/post/get/<id>'; get] 671fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 672 post := app.get_post_by_id(id) or { 673 return ctx.text('no such post') 674 } 675 return ctx.json[Post](post) 676} 677 678@['/api/post/search'; get] 679fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 680 if limit >= search_hard_limit { 681 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 682 } 683 posts := app.search_for_posts(query, limit, offset) 684 return ctx.json[[]PostSearchResult](posts) 685} 686 687////// site ////// 688 689@['/api/site/set_motd'; post] 690fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 691 user := app.whoami(mut ctx) or { 692 ctx.error('not logged in!') 693 return ctx.redirect('/login') 694 } 695 696 if user.admin { 697 if !app.set_motd(motd) { 698 ctx.error('failed to set motd') 699 eprintln('failed to set motd: ${motd}') 700 return ctx.redirect('/') 701 } 702 println('set motd to: ${motd}') 703 return ctx.redirect('/') 704 } else { 705 ctx.error('insufficient permissions!') 706 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 707 return ctx.redirect('/') 708 } 709}