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('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('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 // invalidate tokens 114 app.auth.delete_tokens_for_user(user.id) or { 115 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 116 return ctx.redirect('/settings') 117 } 118 119 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 120 121 if !app.set_password(user.id, hashed_new_password) { 122 ctx.error('failed to update password') 123 } 124 125 return ctx.redirect('/login') 126} 127 128@['/api/user/login'; post] 129fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 130 user := app.get_user_by_name(username) or { 131 ctx.error('invalid credentials') 132 return ctx.redirect('/login') 133 } 134 135 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 136 ctx.error('invalid credentials') 137 return ctx.redirect('/login') 138 } 139 140 token := app.auth.add_token(user.id, ctx.ip()) or { 141 eprintln('failed to add token on log in: ${err}') 142 ctx.error('could not create token for user with id ${user.id}') 143 return ctx.redirect('/login') 144 } 145 146 ctx.set_cookie( 147 name: 'token' 148 value: token 149 same_site: .same_site_none_mode 150 secure: true 151 path: '/' 152 ) 153 154 return ctx.redirect('/') 155} 156 157@['/api/user/logout'] 158fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 159 if token := ctx.get_cookie('token') { 160 if user := app.get_user_by_token(ctx, token) { 161 app.auth.delete_tokens_for_ip(ctx.ip()) or { 162 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 163 return ctx.redirect('/login') 164 } 165 } else { 166 eprintln('failed to get user for token for logout') 167 } 168 } else { 169 eprintln('failed to get token cookie for logout') 170 } 171 172 ctx.set_cookie( 173 name: 'token' 174 value: '' 175 same_site: .same_site_none_mode 176 secure: true 177 path: '/' 178 ) 179 180 return ctx.redirect('/login') 181} 182 183@['/api/user/full_logout'] 184fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 185 if token := ctx.get_cookie('token') { 186 if user := app.get_user_by_token(ctx, token) { 187 app.auth.delete_tokens_for_user(user.id) or { 188 eprintln('failed to yeet tokens for ${user.id}') 189 return ctx.redirect('/login') 190 } 191 } else { 192 eprintln('failed to get user for token for full_logout') 193 } 194 } else { 195 eprintln('failed to get token cookie for full_logout') 196 } 197 198 ctx.set_cookie( 199 name: 'token' 200 value: '' 201 same_site: .same_site_none_mode 202 secure: true 203 path: '/' 204 ) 205 206 return ctx.redirect('/login') 207} 208 209@['/api/user/set_nickname'; post] 210fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 211 user := app.whoami(mut ctx) or { 212 ctx.error('you are not logged in!') 213 return ctx.redirect('/login') 214 } 215 216 mut clean_nickname := ?string(nickname.trim_space()) 217 if clean_nickname or { '' } == '' { 218 clean_nickname = none 219 } 220 221 // validate 222 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 223 ctx.error('invalid nickname') 224 return ctx.redirect('/me') 225 } 226 227 if !app.set_nickname(user.id, clean_nickname) { 228 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 229 return ctx.redirect('/me') 230 } 231 232 return ctx.redirect('/me') 233} 234 235@['/api/user/set_muted'; post] 236fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 237 user := app.whoami(mut ctx) or { 238 ctx.error('you are not logged in!') 239 return ctx.redirect('/login') 240 } 241 242 to_mute := app.get_user_by_id(id) or { 243 ctx.error('no such user') 244 return ctx.redirect('/') 245 } 246 247 if user.admin { 248 if !app.set_muted(to_mute.id, muted) { 249 ctx.error('failed to change mute status') 250 return ctx.redirect('/user/${to_mute.username}') 251 } 252 return ctx.redirect('/user/${to_mute.username}') 253 } else { 254 ctx.error('insufficient permissions!') 255 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 256 return ctx.redirect('/user/${to_mute.username}') 257 } 258} 259 260@['/api/user/set_theme'; post] 261fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 262 if !app.config.instance.allow_changing_theme { 263 ctx.error('this instance disallows changing themes :(') 264 return ctx.redirect('/me') 265 } 266 267 user := app.whoami(mut ctx) or { 268 ctx.error('you are not logged in!') 269 return ctx.redirect('/login') 270 } 271 272 mut theme := ?string(none) 273 if url.trim_space() != '' { 274 theme = url.trim_space() 275 } 276 277 if !app.set_theme(user.id, theme) { 278 ctx.error('failed to change theme') 279 return ctx.redirect('/me') 280 } 281 282 return ctx.redirect('/me') 283} 284 285@['/api/user/set_pronouns'; post] 286fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 287 user := app.whoami(mut ctx) or { 288 ctx.error('you are not logged in!') 289 return ctx.redirect('/login') 290 } 291 292 clean_pronouns := pronouns.trim_space() 293 if !app.validators.pronouns.validate(clean_pronouns) { 294 ctx.error('invalid pronouns') 295 return ctx.redirect('/me') 296 } 297 298 if !app.set_pronouns(user.id, clean_pronouns) { 299 ctx.error('failed to change pronouns') 300 return ctx.redirect('/me') 301 } 302 303 return ctx.redirect('/me') 304} 305 306@['/api/user/set_bio'; post] 307fn (mut app App) api_user_set_bio(mut ctx Context, bio 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_bio := bio.trim_space() 314 if !app.validators.user_bio.validate(clean_bio) { 315 ctx.error('invalid bio') 316 return ctx.redirect('/me') 317 } 318 319 if !app.set_bio(user.id, clean_bio) { 320 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 321 return ctx.redirect('/me') 322 } 323 324 return ctx.redirect('/me') 325} 326 327@['/api/user/get_name'] 328fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 329 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 330 return ctx.text(user.get_name()) 331} 332 333/// user/notification /// 334 335@['/api/user/notification/clear'] 336fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 337 user := app.whoami(mut ctx) or { 338 ctx.error('you are not logged in!') 339 return ctx.redirect('/login') 340 } 341 342 if notification := app.get_notification_by_id(id) { 343 if notification.user_id != user.id { 344 ctx.error('no such notification for user') 345 return ctx.redirect('/inbox') 346 } else { 347 if !app.delete_notification(id) { 348 ctx.error('failed to delete notification') 349 return ctx.redirect('/inbox') 350 } 351 } 352 } else { 353 ctx.error('no such notification for user') 354 } 355 356 return ctx.redirect('/inbox') 357} 358 359@['/api/user/notification/clear_all'] 360fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 361 user := app.whoami(mut ctx) or { 362 ctx.error('you are not logged in!') 363 return ctx.redirect('/login') 364 } 365 if !app.delete_notifications_for_user(user.id) { 366 ctx.error('failed to delete notifications') 367 return ctx.redirect('/inbox') 368 } 369 return ctx.redirect('/inbox') 370} 371 372@['/api/user/delete'] 373fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 374 user := app.whoami(mut ctx) or { 375 ctx.error('you are not logged in!') 376 return ctx.redirect('/login') 377 } 378 379 println('attempting to delete ${id} as ${user.id}') 380 381 if user.admin || user.id == id { 382 // yeet 383 if !app.delete_user(user.id) { 384 ctx.error('failed to delete user: ${id}') 385 return ctx.redirect('/') 386 } 387 388 app.auth.delete_tokens_for_user(id) or { 389 eprintln('failed to delete tokens for user during deletion: ${id}') 390 } 391 // log out 392 if user.id == id { 393 ctx.set_cookie( 394 name: 'token' 395 value: '' 396 same_site: .same_site_none_mode 397 secure: true 398 path: '/' 399 ) 400 } 401 println('deleted user ${id}') 402 } else { 403 ctx.error('be nice. deleting other users is off-limits.') 404 } 405 406 return ctx.redirect('/') 407} 408 409@['/api/user/search'; get] 410fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 411 if limit >= search_hard_limit { 412 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 413 } 414 users := app.search_for_users(query, limit, offset) 415 return ctx.json[[]User](users) 416} 417 418////// post ////// 419 420@['/api/post/new_post'; post] 421fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 422 user := app.whoami(mut ctx) or { 423 ctx.error('not logged in!') 424 return ctx.redirect('/login') 425 } 426 427 if user.muted { 428 ctx.error('you are muted!') 429 return ctx.redirect('/post/new') 430 } 431 432 // validate title 433 if !app.validators.post_title.validate(title) { 434 ctx.error('invalid title') 435 return ctx.redirect('/post/new') 436 } 437 438 // validate body 439 if !app.validators.post_body.validate(body) { 440 ctx.error('invalid body') 441 return ctx.redirect('/post/new') 442 } 443 444 mut post := Post{ 445 author_id: user.id 446 title: title 447 body: body 448 } 449 450 if replying_to != 0 { 451 // check if replying post exists 452 app.get_post_by_id(replying_to) or { 453 ctx.error('the post you are trying to reply to does not exist') 454 return ctx.redirect('/post/new') 455 } 456 post.replying_to = replying_to 457 } 458 459 if !app.add_post(post) { 460 ctx.error('failed to post!') 461 println('failed to post: ${post} from user ${user.id}') 462 return ctx.redirect('/post/new') 463 } 464 465 // find the post's id to process mentions with 466 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 467 app.process_post_mentions(x) 468 return ctx.redirect('/post/${x.id}') 469 } else { 470 ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 471 return ctx.redirect('/me') 472 } 473} 474 475@['/api/post/delete'; post] 476fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 477 user := app.whoami(mut ctx) or { 478 ctx.error('not logged in!') 479 return ctx.redirect('/login') 480 } 481 482 post := app.get_post_by_id(id) or { 483 ctx.error('post does not exist') 484 return ctx.redirect('/') 485 } 486 487 if user.admin || user.id == post.author_id { 488 if !app.delete_post(post.id) { 489 ctx.error('failed to delete post') 490 eprintln('failed to delete post: ${id}') 491 return ctx.redirect('/') 492 } 493 println('deleted post: ${id}') 494 return ctx.redirect('/') 495 } else { 496 ctx.error('insufficient permissions!') 497 eprintln('insufficient perms to delete post: ${id} (${user.id})') 498 return ctx.redirect('/') 499 } 500} 501 502@['/api/post/like'] 503fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 504 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 505 506 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 507 508 if app.does_user_like_post(user.id, post.id) { 509 if !app.unlike_post(post.id, user.id) { 510 eprintln('user ${user.id} failed to unlike post ${id}') 511 return ctx.server_error('failed to unlike post') 512 } 513 return ctx.ok('unliked post') 514 } else { 515 // remove the old dislike, if it exists 516 if app.does_user_dislike_post(user.id, post.id) { 517 if !app.unlike_post(post.id, user.id) { 518 eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 519 } 520 } 521 522 like := Like{ 523 user_id: user.id 524 post_id: post.id 525 is_like: true 526 } 527 if !app.add_like(like) { 528 eprintln('user ${user.id} failed to like post ${id}') 529 return ctx.server_error('failed to like post') 530 } 531 return ctx.ok('liked post') 532 } 533} 534 535@['/api/post/dislike'] 536fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 537 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 538 539 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 540 541 if app.does_user_dislike_post(user.id, post.id) { 542 if !app.unlike_post(post.id, user.id) { 543 eprintln('user ${user.id} failed to undislike post ${id}') 544 return ctx.server_error('failed to undislike post') 545 } 546 return ctx.ok('undisliked post') 547 } else { 548 // remove the old like, if it exists 549 if app.does_user_like_post(user.id, post.id) { 550 if !app.unlike_post(post.id, user.id) { 551 eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 552 } 553 } 554 555 like := Like{ 556 user_id: user.id 557 post_id: post.id 558 is_like: false 559 } 560 if !app.add_like(like) { 561 eprintln('user ${user.id} failed to dislike post ${id}') 562 return ctx.server_error('failed to dislike post') 563 } 564 return ctx.ok('disliked post') 565 } 566} 567 568@['/api/post/get_title'] 569fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 570 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 571 return ctx.text(post.title) 572} 573 574@['/api/post/edit'; post] 575fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 576 user := app.whoami(mut ctx) or { 577 ctx.error('not logged in!') 578 return ctx.redirect('/login') 579 } 580 post := app.get_post_by_id(id) or { 581 ctx.error('no such post') 582 return ctx.redirect('/') 583 } 584 if post.author_id != user.id { 585 ctx.error('insufficient permissions') 586 return ctx.redirect('/') 587 } 588 589 if !app.update_post(id, title, body) { 590 eprintln('failed to update post') 591 ctx.error('failed to update post') 592 return ctx.redirect('/') 593 } 594 595 return ctx.redirect('/post/${id}') 596} 597 598@['/api/post/pin'; post] 599fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 600 user := app.whoami(mut ctx) or { 601 ctx.error('not logged in!') 602 return ctx.redirect('/login') 603 } 604 605 if user.admin { 606 if !app.pin_post(id) { 607 eprintln('failed to pin post: ${id}') 608 ctx.error('failed to pin post') 609 return ctx.redirect('/post/${id}') 610 } 611 return ctx.redirect('/post/${id}') 612 } else { 613 ctx.error('insufficient permissions!') 614 eprintln('insufficient perms to pin post: ${id} (${user.id})') 615 return ctx.redirect('/') 616 } 617} 618 619@['/api/post/get/<id>'; get] 620fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 621 post := app.get_post_by_id(id) or { 622 return ctx.text('no such post') 623 } 624 return ctx.json[Post](post) 625} 626 627@['/api/post/search'; get] 628fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 629 if limit >= search_hard_limit { 630 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 631 } 632 posts := app.search_for_posts(query, limit, offset) 633 return ctx.json[[]PostSearchResult](posts) 634} 635 636////// site ////// 637 638@['/api/site/set_motd'; post] 639fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 640 user := app.whoami(mut ctx) or { 641 ctx.error('not logged in!') 642 return ctx.redirect('/login') 643 } 644 645 if user.admin { 646 if !app.set_motd(motd) { 647 ctx.error('failed to set motd') 648 eprintln('failed to set motd: ${motd}') 649 return ctx.redirect('/') 650 } 651 println('set motd to: ${motd}') 652 return ctx.redirect('/') 653 } else { 654 ctx.error('insufficient permissions!') 655 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 656 return ctx.redirect('/') 657 } 658}