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}