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/save']
569fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result {
570 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
571
572 if app.get_post_by_id(id) != none {
573 if app.toggle_save_post(user.id, id) {
574 return ctx.text('toggled save')
575 } else {
576 return ctx.server_error('failed to save post')
577 }
578 } else {
579 return ctx.server_error('post does not exist')
580 }
581}
582
583@['/api/post/save_for_later']
584fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result {
585 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
586
587 if app.get_post_by_id(id) != none {
588 if app.toggle_save_for_later_post(user.id, id) {
589 return ctx.text('toggled save')
590 } else {
591 return ctx.server_error('failed to save post')
592 }
593 } else {
594 return ctx.server_error('post does not exist')
595 }
596}
597
598@['/api/post/get_title']
599fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
600 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
601 return ctx.text(post.title)
602}
603
604@['/api/post/edit'; post]
605fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result {
606 user := app.whoami(mut ctx) or {
607 ctx.error('not logged in!')
608 return ctx.redirect('/login')
609 }
610 post := app.get_post_by_id(id) or {
611 ctx.error('no such post')
612 return ctx.redirect('/')
613 }
614 if post.author_id != user.id {
615 ctx.error('insufficient permissions')
616 return ctx.redirect('/')
617 }
618
619 if !app.update_post(id, title, body) {
620 eprintln('failed to update post')
621 ctx.error('failed to update post')
622 return ctx.redirect('/')
623 }
624
625 return ctx.redirect('/post/${id}')
626}
627
628@['/api/post/pin'; post]
629fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result {
630 user := app.whoami(mut ctx) or {
631 ctx.error('not logged in!')
632 return ctx.redirect('/login')
633 }
634
635 if user.admin {
636 if !app.pin_post(id) {
637 eprintln('failed to pin post: ${id}')
638 ctx.error('failed to pin post')
639 return ctx.redirect('/post/${id}')
640 }
641 return ctx.redirect('/post/${id}')
642 } else {
643 ctx.error('insufficient permissions!')
644 eprintln('insufficient perms to pin post: ${id} (${user.id})')
645 return ctx.redirect('/')
646 }
647}
648
649@['/api/post/get/<id>'; get]
650fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
651 post := app.get_post_by_id(id) or {
652 return ctx.text('no such post')
653 }
654 return ctx.json[Post](post)
655}
656
657@['/api/post/search'; get]
658fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result {
659 if limit >= search_hard_limit {
660 return ctx.text('limit exceeds hard limit (${search_hard_limit})')
661 }
662 posts := app.search_for_posts(query, limit, offset)
663 return ctx.json[[]PostSearchResult](posts)
664}
665
666////// site //////
667
668@['/api/site/set_motd'; post]
669fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
670 user := app.whoami(mut ctx) or {
671 ctx.error('not logged in!')
672 return ctx.redirect('/login')
673 }
674
675 if user.admin {
676 if !app.set_motd(motd) {
677 ctx.error('failed to set motd')
678 eprintln('failed to set motd: ${motd}')
679 return ctx.redirect('/')
680 }
681 println('set motd to: ${motd}')
682 return ctx.redirect('/')
683 } else {
684 ctx.error('insufficient permissions!')
685 eprintln('insufficient perms to set motd to: ${motd} (${user.id})')
686 return ctx.redirect('/')
687 }
688}