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