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