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}