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