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