a mini social media app for small communities
1module webapp
2
3import veb
4import auth
5import entity { Like, LikeCache, Post, Site, User, Notification }
6
7////// user //////
8
9@['/api/user/register'; post]
10fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result {
11 if app.get_user_by_name(username) != none {
12 ctx.error('username taken')
13 return ctx.redirect('/register')
14 }
15
16 // validate username
17 if !app.validators.username.validate(username) {
18 ctx.error('invalid username')
19 return ctx.redirect('/register')
20 }
21
22 // validate password
23 if !app.validators.password.validate(password) {
24 ctx.error('invalid password')
25 return ctx.redirect('/register')
26 }
27
28 salt := auth.generate_salt()
29 mut user := User{
30 username: username
31 password: auth.hash_password_with_salt(password, salt)
32 password_salt: salt
33 }
34
35 if app.config.instance.default_theme != '' {
36 user.theme = app.config.instance.default_theme
37 }
38
39 if x := app.new_user(user) {
40 app.send_notification_to(
41 x.id,
42 app.config.welcome.summary.replace('%s', x.get_name()),
43 app.config.welcome.body.replace('%s', x.get_name())
44 )
45 token := app.auth.add_token(x.id, ctx.ip()) or {
46 eprintln(err)
47 ctx.error('could not create token for user with id ${x.id}')
48 return ctx.redirect('/')
49 }
50 ctx.set_cookie(
51 name: 'token'
52 value: token
53 same_site: .same_site_none_mode
54 secure: true
55 path: '/'
56 )
57 } else {
58 eprintln('could not log into newly-created user: ${user}')
59 ctx.error('could not log into newly-created user.')
60 }
61
62 return ctx.redirect('/')
63}
64
65@['/api/user/set_username'; post]
66fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result {
67 user := app.whoami(mut ctx) or {
68 ctx.error('you are not logged in!')
69 return ctx.redirect('/login')
70 }
71
72 if app.get_user_by_name(new_username) != none {
73 ctx.error('username taken')
74 return ctx.redirect('/settings')
75 }
76
77 // validate username
78 if !app.validators.username.validate(new_username) {
79 ctx.error('invalid username')
80 return ctx.redirect('/settings')
81 }
82
83 if !app.set_username(user.id, new_username) {
84 ctx.error('failed to update username')
85 }
86
87 return ctx.redirect('/settings')
88}
89
90@['/api/user/set_password'; post]
91fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result {
92 user := app.whoami(mut ctx) or {
93 ctx.error('you are not logged in!')
94 return ctx.redirect('/login')
95 }
96
97 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) {
98 ctx.error('current_password is incorrect')
99 return ctx.redirect('/settings')
100 }
101
102 // validate password
103 if !app.validators.password.validate(new_password) {
104 ctx.error('invalid password')
105 return ctx.redirect('/settings')
106 }
107
108 // invalidate tokens
109 app.auth.delete_tokens_for_user(user.id) or {
110 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})')
111 return ctx.redirect('/settings')
112 }
113
114 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt)
115
116 if !app.set_password(user.id, hashed_new_password) {
117 ctx.error('failed to update password')
118 }
119
120 return ctx.redirect('/login')
121}
122
123@['/api/user/login'; post]
124fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result {
125 user := app.get_user_by_name(username) or {
126 ctx.error('invalid credentials')
127 return ctx.redirect('/login')
128 }
129
130 if !auth.compare_password_with_hash(password, user.password_salt, user.password) {
131 ctx.error('invalid credentials')
132 return ctx.redirect('/login')
133 }
134
135 token := app.auth.add_token(user.id, ctx.ip()) or {
136 eprintln('failed to add token on log in: ${err}')
137 ctx.error('could not create token for user with id ${user.id}')
138 return ctx.redirect('/login')
139 }
140
141 ctx.set_cookie(
142 name: 'token'
143 value: token
144 same_site: .same_site_none_mode
145 secure: true
146 path: '/'
147 )
148
149 return ctx.redirect('/')
150}
151
152@['/api/user/logout']
153fn (mut app App) api_user_logout(mut ctx Context) veb.Result {
154 if token := ctx.get_cookie('token') {
155 if user := app.get_user_by_token(ctx, token) {
156 app.auth.delete_tokens_for_ip(ctx.ip()) or {
157 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})')
158 return ctx.redirect('/login')
159 }
160 } else {
161 eprintln('failed to get user for token for logout')
162 }
163 } else {
164 eprintln('failed to get token cookie for logout')
165 }
166
167 ctx.set_cookie(
168 name: 'token'
169 value: ''
170 same_site: .same_site_none_mode
171 secure: true
172 path: '/'
173 )
174
175 return ctx.redirect('/login')
176}
177
178@['/api/user/full_logout']
179fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result {
180 if token := ctx.get_cookie('token') {
181 if user := app.get_user_by_token(ctx, token) {
182 app.auth.delete_tokens_for_user(user.id) or {
183 eprintln('failed to yeet tokens for ${user.id}')
184 return ctx.redirect('/login')
185 }
186 } else {
187 eprintln('failed to get user for token for full_logout')
188 }
189 } else {
190 eprintln('failed to get token cookie for full_logout')
191 }
192
193 ctx.set_cookie(
194 name: 'token'
195 value: ''
196 same_site: .same_site_none_mode
197 secure: true
198 path: '/'
199 )
200
201 return ctx.redirect('/login')
202}
203
204@['/api/user/set_nickname'; post]
205fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result {
206 user := app.whoami(mut ctx) or {
207 ctx.error('you are not logged in!')
208 return ctx.redirect('/login')
209 }
210
211 mut clean_nickname := ?string(nickname.trim_space())
212 if clean_nickname or { '' } == '' {
213 clean_nickname = none
214 }
215
216 // validate
217 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
218 ctx.error('invalid nickname')
219 return ctx.redirect('/me')
220 }
221
222 if !app.set_nickname(user.id, clean_nickname) {
223 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
224 return ctx.redirect('/me')
225 }
226
227 return ctx.redirect('/me')
228}
229
230@['/api/user/set_muted'; post]
231fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result {
232 user := app.whoami(mut ctx) or {
233 ctx.error('you are not logged in!')
234 return ctx.redirect('/login')
235 }
236
237 to_mute := app.get_user_by_id(id) or {
238 ctx.error('no such user')
239 return ctx.redirect('/')
240 }
241
242 if user.admin {
243 if !app.set_muted(to_mute.id, muted) {
244 ctx.error('failed to change mute status')
245 return ctx.redirect('/user/${to_mute.username}')
246 }
247 return ctx.redirect('/user/${to_mute.username}')
248 } else {
249 ctx.error('insufficient permissions!')
250 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})')
251 return ctx.redirect('/user/${to_mute.username}')
252 }
253}
254
255@['/api/user/set_theme'; post]
256fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
257 if !app.config.instance.allow_changing_theme {
258 ctx.error('this instance disallows changing themes :(')
259 return ctx.redirect('/me')
260 }
261
262 user := app.whoami(mut ctx) or {
263 ctx.error('you are not logged in!')
264 return ctx.redirect('/login')
265 }
266
267 mut theme := ?string(none)
268 if url.trim_space() != '' {
269 theme = url.trim_space()
270 }
271
272 if !app.set_theme(user.id, theme) {
273 ctx.error('failed to change theme')
274 return ctx.redirect('/me')
275 }
276
277 return ctx.redirect('/me')
278}
279
280@['/api/user/set_pronouns'; post]
281fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result {
282 user := app.whoami(mut ctx) or {
283 ctx.error('you are not logged in!')
284 return ctx.redirect('/login')
285 }
286
287 clean_pronouns := pronouns.trim_space()
288 if !app.validators.pronouns.validate(clean_pronouns) {
289 ctx.error('invalid pronouns')
290 return ctx.redirect('/me')
291 }
292
293 if !app.set_pronouns(user.id, clean_pronouns) {
294 ctx.error('failed to change pronouns')
295 return ctx.redirect('/me')
296 }
297
298 return ctx.redirect('/me')
299}
300
301@['/api/user/set_bio'; post]
302fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result {
303 user := app.whoami(mut ctx) or {
304 ctx.error('you are not logged in!')
305 return ctx.redirect('/login')
306 }
307
308 clean_bio := bio.trim_space()
309 if !app.validators.user_bio.validate(clean_bio) {
310 ctx.error('invalid bio')
311 return ctx.redirect('/me')
312 }
313
314 if !app.set_bio(user.id, clean_bio) {
315 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
316 return ctx.redirect('/me')
317 }
318
319 return ctx.redirect('/me')
320}
321
322@['/api/user/get_name']
323fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result {
324 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') }
325 return ctx.text(user.get_name())
326}
327
328/// user/notification ///
329
330@['/api/user/notification/clear']
331fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result {
332 if !ctx.is_logged_in() {
333 ctx.error('you are not logged in!')
334 return ctx.redirect('/login')
335 }
336 sql app.db {
337 delete from Notification where id == id
338 } or {
339 ctx.error('failed to delete notification')
340 return ctx.redirect('/inbox')
341 }
342 return ctx.redirect('/inbox')
343}
344
345@['/api/user/notification/clear_all']
346fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result {
347 user := app.whoami(mut ctx) or {
348 ctx.error('you are not logged in!')
349 return ctx.redirect('/login')
350 }
351 sql app.db {
352 delete from Notification where user_id == user.id
353 } or {
354 ctx.error('failed to delete notifications')
355 return ctx.redirect('/inbox')
356 }
357 return ctx.redirect('/inbox')
358}
359
360@['/api/user/delete']
361fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result {
362 user := app.whoami(mut ctx) or {
363 ctx.error('you are not logged in!')
364 return ctx.redirect('/login')
365 }
366
367 println('attempting to delete ${id} as ${user.id}')
368
369 if user.admin || user.id == id {
370 // yeet
371 sql app.db {
372 delete from User where id == id
373 delete from Like where user_id == id
374 delete from Notification where user_id == id
375 } or {
376 ctx.error('failed to delete user: ${id}')
377 return ctx.redirect('/')
378 }
379
380 // delete posts and their likes
381 posts_from_this_user := sql app.db {
382 select from Post where author_id == id
383 } or { [] }
384
385 for post in posts_from_this_user {
386 sql app.db {
387 delete from Like where post_id == post.id
388 delete from LikeCache where post_id == post.id
389 } or {
390 eprintln('failed to delete like cache for post during user deletion: ${post.id}')
391 }
392 }
393
394 sql app.db {
395 delete from Post where author_id == id
396 } or {
397 eprintln('failed to delete posts by deleting user: ${user.id}')
398 }
399
400 app.auth.delete_tokens_for_user(id) or {
401 eprintln('failed to delete tokens for user during deletion: ${id}')
402 }
403 // log out
404 if user.id == id {
405 ctx.set_cookie(
406 name: 'token'
407 value: ''
408 same_site: .same_site_none_mode
409 secure: true
410 path: '/'
411 )
412 }
413 println('deleted user ${id}')
414 } else {
415 ctx.error('be nice. deleting other users is off-limits.')
416 }
417
418 return ctx.redirect('/')
419}
420
421////// post //////
422
423@['/api/post/new_post'; post]
424fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result {
425 user := app.whoami(mut ctx) or {
426 ctx.error('not logged in!')
427 return ctx.redirect('/login')
428 }
429
430 if user.muted {
431 ctx.error('you are muted!')
432 return ctx.redirect('/post/new')
433 }
434
435 // validate title
436 if !app.validators.post_title.validate(title) {
437 ctx.error('invalid title')
438 return ctx.redirect('/post/new')
439 }
440
441 // validate body
442 if !app.validators.post_body.validate(body) {
443 ctx.error('invalid body')
444 return ctx.redirect('/post/new')
445 }
446
447 mut post := Post{
448 author_id: user.id
449 title: title
450 body: body
451 }
452
453 if replying_to != 0 {
454 // check if replying post exists
455 app.get_post_by_id(replying_to) or {
456 ctx.error('the post you are trying to reply to does not exist')
457 return ctx.redirect('/post/new')
458 }
459 post.replying_to = replying_to
460 }
461
462 sql app.db {
463 insert post into Post
464 } or {
465 ctx.error('failed to post!')
466 println('failed to post: ${post} from user ${user.id}')
467 return ctx.redirect('/post/new')
468 }
469
470 // find the post's id to process mentions with
471 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) {
472 app.process_post_mentions(x)
473 return ctx.redirect('/post/${x.id}')
474 } else {
475 ctx.error('failed to get_post_by_timestamp_and_author for ${post}')
476 return ctx.redirect('/me')
477 }
478}
479
480@['/api/post/delete'; post]
481fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result {
482 user := app.whoami(mut ctx) or {
483 ctx.error('not logged in!')
484 return ctx.redirect('/login')
485 }
486
487 post := app.get_post_by_id(id) or {
488 ctx.error('post does not exist')
489 return ctx.redirect('/')
490 }
491
492 if user.admin || user.id == post.author_id {
493 sql app.db {
494 delete from Post where id == id
495 delete from Like where post_id == id
496 } or {
497 ctx.error('failed to delete post')
498 eprintln('failed to delete post: ${id}')
499 return ctx.redirect('/')
500 }
501 println('deleted post: ${id}')
502 return ctx.redirect('/')
503 } else {
504 ctx.error('insufficient permissions!')
505 eprintln('insufficient perms to delete post: ${id} (${user.id})')
506 return ctx.redirect('/')
507 }
508}
509
510@['/api/post/like']
511fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result {
512 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
513
514 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
515
516 if app.does_user_like_post(user.id, post.id) {
517 sql app.db {
518 delete from Like where user_id == user.id && post_id == post.id
519 // yeet the old cached like value
520 delete from LikeCache where post_id == post.id
521 } or {
522 eprintln('user ${user.id} failed to unlike post ${id}')
523 return ctx.server_error('failed to unlike post')
524 }
525 return ctx.ok('unliked post')
526 } else {
527 // remove the old dislike, if it exists
528 if app.does_user_dislike_post(user.id, post.id) {
529 sql app.db {
530 delete from Like where user_id == user.id && post_id == post.id
531 } or {
532 eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it')
533 }
534 }
535
536 like := Like{
537 user_id: user.id
538 post_id: post.id
539 is_like: true
540 }
541 sql app.db {
542 insert like into Like
543 // yeet the old cached like value
544 delete from LikeCache where post_id == post.id
545 } or {
546 eprintln('user ${user.id} failed to like post ${id}')
547 return ctx.server_error('failed to like post')
548 }
549 return ctx.ok('liked post')
550 }
551}
552
553@['/api/post/dislike']
554fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result {
555 user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
556
557 post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
558
559 if app.does_user_dislike_post(user.id, post.id) {
560 sql app.db {
561 delete from Like where user_id == user.id && post_id == post.id
562 // yeet the old cached like value
563 delete from LikeCache where post_id == post.id
564 } or {
565 eprintln('user ${user.id} failed to unlike post ${id}')
566 return ctx.server_error('failed to unlike post')
567 }
568 return ctx.ok('undisliked post')
569 } else {
570 // remove the old like, if it exists
571 if app.does_user_like_post(user.id, post.id) {
572 sql app.db {
573 delete from Like where user_id == user.id && post_id == post.id
574 } or {
575 eprintln('user ${user.id} failed to remove like on post ${id} when disliking it')
576 }
577 }
578
579 like := Like{
580 user_id: user.id
581 post_id: post.id
582 is_like: false
583 }
584 sql app.db {
585 insert like into Like
586 // yeet the old cached like value
587 delete from LikeCache where post_id == post.id
588 } or {
589 eprintln('user ${user.id} failed to dislike post ${id}')
590 return ctx.server_error('failed to dislike post')
591 }
592 return ctx.ok('disliked post')
593 }
594}
595
596@['/api/post/get_title']
597fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
598 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
599 return ctx.text(post.title)
600}
601
602@['/api/post/edit'; post]
603fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result {
604 user := app.whoami(mut ctx) or {
605 ctx.error('not logged in!')
606 return ctx.redirect('/login')
607 }
608 post := app.get_post_by_id(id) or {
609 ctx.error('no such post')
610 return ctx.redirect('/')
611 }
612 if post.author_id != user.id {
613 ctx.error('insufficient permissions')
614 return ctx.redirect('/')
615 }
616
617 sql app.db {
618 update Post set body = body, title = title where id == id
619 } or {
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 sql app.db {
637 update Post set pinned = true where id == id
638 } or {
639 eprintln('failed to pin post: ${id}')
640 ctx.error('failed to pin post')
641 return ctx.redirect('/post/${id}')
642 }
643 return ctx.redirect('/post/${id}')
644 } else {
645 ctx.error('insufficient permissions!')
646 eprintln('insufficient perms to pin post: ${id} (${user.id})')
647 return ctx.redirect('/')
648 }
649}
650
651////// site //////
652
653@['/api/site/set_motd'; post]
654fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
655 user := app.whoami(mut ctx) or {
656 ctx.error('not logged in!')
657 return ctx.redirect('/login')
658 }
659
660 if user.admin {
661 sql app.db {
662 update Site set motd = motd where id == 1
663 } or {
664 ctx.error('failed to set motd')
665 eprintln('failed to set motd: ${motd}')
666 return ctx.redirect('/')
667 }
668 println('set motd to: ${motd}')
669 return ctx.redirect('/')
670 } else {
671 ctx.error('insufficient permissions!')
672 eprintln('insufficient perms to set motd to: ${motd} (${user.id})')
673 return ctx.redirect('/')
674 }
675}