a mini social media app for small communities

the great refactor

+20 -45
src/api.v src/webapp/api.v
··· 1 - module main 1 + module webapp 2 2 3 3 import veb 4 4 import auth ··· 36 36 user.theme = app.config.instance.default_theme 37 37 } 38 38 39 - sql app.db { 40 - insert user into User 41 - } or { 42 - eprintln('failed to insert user ${user}') 43 - return ctx.redirect('/') 44 - } 45 - 46 - println('reg: ${username}') 47 - 48 - if x := app.get_user_by_name(username) { 39 + if x := app.new_user(user) { 49 40 app.send_notification_to( 50 41 x.id, 51 42 app.config.welcome.summary.replace('%s', x.get_name()), ··· 89 80 return ctx.redirect('/settings') 90 81 } 91 82 92 - sql app.db { 93 - update User set username = new_username where id == user.id 94 - } or { 95 - eprintln('failed to update username for ${user.id}') 83 + if !app.set_username(user.id, new_username) { 96 84 ctx.error('failed to update username') 97 85 } 98 86 ··· 125 113 126 114 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 127 115 128 - sql app.db { 129 - update User set password = hashed_new_password where id == user.id 130 - } or { 131 - eprintln('failed to update password for ${user.id}') 116 + if !app.set_password(user.id, hashed_new_password) { 132 117 ctx.error('failed to update password') 133 118 } 134 119 ··· 234 219 return ctx.redirect('/me') 235 220 } 236 221 237 - sql app.db { 238 - update User set nickname = clean_nickname where id == user.id 239 - } or { 240 - ctx.error('failed to change nickname') 222 + if !app.set_nickname(user.id, clean_nickname) { 241 223 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 242 224 return ctx.redirect('/me') 243 225 } ··· 246 228 } 247 229 248 230 @['/api/user/set_muted'; post] 249 - fn (mut app App) api_user_set_muted(mut ctx Context, muted bool) veb.Result { 231 + fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 250 232 user := app.whoami(mut ctx) or { 251 233 ctx.error('you are not logged in!') 252 234 return ctx.redirect('/login') 253 235 } 254 236 255 - if user.admin || app.config.dev_mode { 256 - sql app.db { 257 - update User set muted = muted where id == user.id 258 - } or { 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) { 259 244 ctx.error('failed to change mute status') 260 - eprintln('failed to update mute status for ${user} (${user.muted} -> ${muted})') 261 - return ctx.redirect('/user/${user.username}') 245 + return ctx.redirect('/user/${to_mute.username}') 262 246 } 263 - return ctx.redirect('/user/${user.username}') 247 + return ctx.redirect('/user/${to_mute.username}') 264 248 } else { 265 249 ctx.error('insufficient permissions!') 266 - eprintln('insufficient perms to update mute status for ${user} (${user.muted} -> ${muted})') 267 - return ctx.redirect('/user/${user.username}') 250 + eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 251 + return ctx.redirect('/user/${to_mute.username}') 268 252 } 269 253 } 270 254 ··· 285 269 theme = url.trim_space() 286 270 } 287 271 288 - sql app.db { 289 - update User set theme = theme where id == user.id 290 - } or { 272 + if !app.set_theme(user.id, theme) { 291 273 ctx.error('failed to change theme') 292 - eprintln('failed to update theme for ${user} (${user.theme} -> ${theme})') 293 274 return ctx.redirect('/me') 294 275 } 295 276 ··· 309 290 return ctx.redirect('/me') 310 291 } 311 292 312 - sql app.db { 313 - update User set pronouns = clean_pronouns where id == user.id 314 - } or { 293 + if !app.set_pronouns(user.id, clean_pronouns) { 315 294 ctx.error('failed to change pronouns') 316 - eprintln('failed to update pronouns for ${user} (${user.pronouns} -> ${clean_pronouns})') 317 295 return ctx.redirect('/me') 318 296 } 319 297 ··· 333 311 return ctx.redirect('/me') 334 312 } 335 313 336 - sql app.db { 337 - update User set bio = clean_bio where id == user.id 338 - } or { 339 - ctx.error('failed to change bio') 314 + if !app.set_bio(user.id, clean_bio) { 340 315 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 341 316 return ctx.redirect('/me') 342 317 }
-358
src/app.v
··· 1 - module main 2 - 3 - import veb 4 - import db.pg 5 - import regex 6 - import time 7 - import auth 8 - import entity { LikeCache, Like, Post, Site, User, Notification } 9 - 10 - pub struct App { 11 - veb.StaticHandler 12 - pub: 13 - config Config 14 - pub mut: 15 - db pg.DB 16 - auth auth.Auth[pg.DB] 17 - validators struct { 18 - pub mut: 19 - username StringValidator 20 - password StringValidator 21 - nickname StringValidator 22 - pronouns StringValidator 23 - user_bio StringValidator 24 - post_title StringValidator 25 - post_body StringValidator 26 - } 27 - } 28 - 29 - pub fn (app &App) get_user_by_name(username string) ?User { 30 - users := sql app.db { 31 - select from User where username == username 32 - } or { [] } 33 - if users.len != 1 { 34 - return none 35 - } 36 - return users[0] 37 - } 38 - 39 - pub fn (app &App) get_user_by_id(id int) ?User { 40 - users := sql app.db { 41 - select from User where id == id 42 - } or { [] } 43 - if users.len != 1 { 44 - return none 45 - } 46 - return users[0] 47 - } 48 - 49 - pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 50 - user_token := app.auth.find_token(token, ctx.ip()) or { 51 - eprintln('no such user corresponding to token') 52 - return none 53 - } 54 - return app.get_user_by_id(user_token.user_id) 55 - } 56 - 57 - pub fn (app &App) get_recent_posts() []Post { 58 - posts := sql app.db { 59 - select from Post order by posted_at desc limit 10 60 - } or { [] } 61 - return posts 62 - } 63 - 64 - pub fn (app &App) get_popular_posts() []Post { 65 - cached_likes := sql app.db { 66 - select from LikeCache order by likes desc limit 10 67 - } or { [] } 68 - posts := cached_likes.map(fn [app] (it LikeCache) Post { 69 - return app.get_post_by_id(it.post_id) or { 70 - eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 71 - return Post{} 72 - } 73 - }).filter(it.id != 0) 74 - return posts 75 - } 76 - 77 - pub fn (app &App) get_posts_from_user(user_id int) []Post { 78 - posts := sql app.db { 79 - select from Post where author_id == user_id order by posted_at desc 80 - } or { [] } 81 - return posts 82 - } 83 - 84 - pub fn (app &App) get_users() []User { 85 - users := sql app.db { 86 - select from User 87 - } or { [] } 88 - return users 89 - } 90 - 91 - pub fn (app &App) get_post_by_id(id int) ?Post { 92 - posts := sql app.db { 93 - select from Post where id == id limit 1 94 - } or { [] } 95 - if posts.len != 1 { 96 - return none 97 - } 98 - return posts[0] 99 - } 100 - 101 - pub fn (app &App) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 102 - posts := sql app.db { 103 - select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 104 - } or { [] } 105 - if posts.len == 0 { 106 - return none 107 - } 108 - return posts[0] 109 - } 110 - 111 - pub fn (app &App) get_posts_with_tag(tag string, offset int) []Post { 112 - posts := sql app.db { 113 - select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset 114 - } or { [] } 115 - return posts 116 - } 117 - 118 - pub fn (app &App) get_pinned_posts() []Post { 119 - posts := sql app.db { 120 - select from Post where pinned == true 121 - } or { [] } 122 - return posts 123 - } 124 - 125 - pub fn (app &App) whoami(mut ctx Context) ?User { 126 - token := ctx.get_cookie('token') or { return none }.trim_space() 127 - if token == '' { 128 - return none 129 - } 130 - if user := app.get_user_by_token(ctx, token) { 131 - if user.username == '' || user.id == 0 { 132 - eprintln('a user had a token for the blank user') 133 - // Clear token 134 - ctx.set_cookie( 135 - name: 'token' 136 - value: '' 137 - same_site: .same_site_none_mode 138 - secure: true 139 - path: '/' 140 - ) 141 - return none 142 - } 143 - return user 144 - } else { 145 - eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)') 146 - // Clear token 147 - ctx.set_cookie( 148 - name: 'token' 149 - value: '' 150 - same_site: .same_site_none_mode 151 - secure: true 152 - path: '/' 153 - ) 154 - return none 155 - } 156 - } 157 - 158 - pub fn (app &App) get_unknown_user() User { 159 - return User{ 160 - username: 'unknown' 161 - } 162 - } 163 - 164 - pub fn (app &App) get_unknown_post() Post { 165 - return Post{ 166 - title: 'unknown' 167 - } 168 - } 169 - 170 - pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 171 - if !ctx.is_logged_in() { 172 - return false 173 - } 174 - return app.whoami(mut ctx) or { return false }.id == id 175 - } 176 - 177 - pub fn (app &App) does_user_like_post(user_id int, post_id int) bool { 178 - likes := sql app.db { 179 - select from Like where user_id == user_id && post_id == post_id 180 - } or { [] } 181 - if likes.len > 1 { 182 - // something is very wrong lol 183 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 184 - } else if likes.len == 0 { 185 - return false 186 - } 187 - return likes.first().is_like 188 - } 189 - 190 - pub fn (app &App) does_user_dislike_post(user_id int, post_id int) bool { 191 - likes := sql app.db { 192 - select from Like where user_id == user_id && post_id == post_id 193 - } or { [] } 194 - if likes.len > 1 { 195 - // something is very wrong lol 196 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 197 - } else if likes.len == 0 { 198 - return false 199 - } 200 - return !likes.first().is_like 201 - } 202 - 203 - pub fn (app &App) does_user_like_or_dislike_post(user_id int, post_id int) bool { 204 - likes := sql app.db { 205 - select from Like where user_id == user_id && post_id == post_id 206 - } or { [] } 207 - if likes.len > 1 { 208 - // something is very wrong lol 209 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 210 - } 211 - return likes.len == 1 212 - } 213 - 214 - pub fn (app &App) get_net_likes_for_post(post_id int) int { 215 - // check cache 216 - cache := sql app.db { 217 - select from LikeCache where post_id == post_id limit 1 218 - } or { [] } 219 - 220 - mut likes := 0 221 - 222 - if cache.len != 1 { 223 - println('calculating net likes for post: ${post_id}') 224 - // calculate 225 - db_likes := sql app.db { 226 - select from Like where post_id == post_id 227 - } or { [] } 228 - 229 - for like in db_likes { 230 - if like.is_like { 231 - likes++ 232 - } else { 233 - likes-- 234 - } 235 - } 236 - 237 - // cache 238 - cached := LikeCache{ 239 - post_id: post_id 240 - likes: likes 241 - } 242 - sql app.db { 243 - insert cached into LikeCache 244 - } or { 245 - eprintln('failed to cache like: ${cached}') 246 - return likes 247 - } 248 - } else { 249 - likes = cache.first().likes 250 - } 251 - 252 - return likes 253 - } 254 - 255 - pub fn (app &App) get_or_create_site_config() Site { 256 - configs := sql app.db { 257 - select from Site 258 - } or { [] } 259 - if configs.len == 0 { 260 - // make the site config 261 - site_config := Site{} 262 - sql app.db { 263 - insert site_config into Site 264 - } or { panic('failed to create site config (${err})') } 265 - } else if configs.len > 1 { 266 - // this should never happen 267 - panic('there are multiple site configs') 268 - } 269 - return configs[0] 270 - } 271 - 272 - @[inline] 273 - pub fn (app &App) get_motd() string { 274 - site := app.get_or_create_site_config() 275 - return site.motd 276 - } 277 - 278 - pub fn (app &App) get_notifications_for(user_id int) []Notification { 279 - notifications := sql app.db { 280 - select from Notification where user_id == user_id 281 - } or { [] } 282 - return notifications 283 - } 284 - 285 - pub fn (app &App) get_notification_count(user_id int, limit int) int { 286 - notifications := sql app.db { 287 - select from Notification where user_id == user_id limit limit 288 - } or { [] } 289 - return notifications.len 290 - } 291 - 292 - pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 293 - count := app.get_notification_count(user_id, limit) 294 - if count == 0 { 295 - return '' 296 - } else if count > limit { 297 - return ' (${count}+)' 298 - } else { 299 - return ' (${count})' 300 - } 301 - } 302 - 303 - pub fn (app &App) send_notification_to(user_id int, summary string, body string) { 304 - notification := Notification{ 305 - user_id: user_id 306 - summary: summary 307 - body: body 308 - } 309 - sql app.db { 310 - insert notification into Notification 311 - } or { 312 - eprintln('failed to send notification ${notification}') 313 - } 314 - } 315 - 316 - // sends notifications to each user mentioned in a post 317 - pub fn (app &App) process_post_mentions(post &Post) { 318 - author := app.get_user_by_id(post.author_id) or { 319 - eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') 320 - return 321 - } 322 - author_name := author.get_name() 323 - 324 - // used so we do not send more than one notification per post 325 - mut notified_users := []int{} 326 - 327 - // notify who we replied to, if applicable 328 - if post.replying_to != none { 329 - if x := app.get_post_by_id(post.replying_to) { 330 - app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})') 331 - } 332 - } 333 - 334 - // find mentions 335 - mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 336 - eprintln('failed to compile regex for process_post_mentions (err: ${err})') 337 - return 338 - } 339 - matches := re.find_all_str(post.body) 340 - for mat in matches { 341 - println('found mentioned user: ${mat}') 342 - username := mat#[2..-1] 343 - user := app.get_user_by_name(username) or { 344 - continue 345 - } 346 - 347 - if user.id in notified_users || user.id == author.id { 348 - continue 349 - } 350 - notified_users << user.id 351 - 352 - app.send_notification_to( 353 - user.id, 354 - '${author_name} mentioned you!', 355 - 'you have been mentioned in this post: *(${post.id})' 356 - ) 357 - } 358 - }
+1 -1
src/config.v src/webapp/config.v
··· 1 - module main 1 + module webapp 2 2 3 3 import emmathemartian.maple 4 4
+1 -1
src/context.v src/webapp/context.v
··· 1 - module main 1 + module webapp 2 2 3 3 import veb 4 4
+9
src/database/database.v
··· 1 + module database 2 + 3 + import db.pg 4 + 5 + // all interactions with the database should be handled through this struct. 6 + pub struct DatabaseAccess { 7 + pub mut: 8 + db pg.DB 9 + }
+45
src/database/like.v
··· 1 + module database 2 + 3 + import entity { Like, LikeCache } 4 + 5 + // returns the net likes of the given post 6 + pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 7 + // check cache 8 + cache := sql app.db { 9 + select from LikeCache where post_id == post_id limit 1 10 + } or { [] } 11 + 12 + mut likes := 0 13 + 14 + if cache.len != 1 { 15 + println('calculating net likes for post: ${post_id}') 16 + // calculate 17 + db_likes := sql app.db { 18 + select from Like where post_id == post_id 19 + } or { [] } 20 + 21 + for like in db_likes { 22 + if like.is_like { 23 + likes++ 24 + } else { 25 + likes-- 26 + } 27 + } 28 + 29 + // cache 30 + cached := LikeCache{ 31 + post_id: post_id 32 + likes: likes 33 + } 34 + sql app.db { 35 + insert cached into LikeCache 36 + } or { 37 + eprintln('failed to cache like: ${cached}') 38 + return likes 39 + } 40 + } else { 41 + likes = cache.first().likes 42 + } 43 + 44 + return likes 45 + }
+33
src/database/notification.v
··· 1 + module database 2 + 3 + import entity { Notification } 4 + 5 + // get a list of notifications for the given user 6 + pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification { 7 + notifications := sql app.db { 8 + select from Notification where user_id == user_id 9 + } or { [] } 10 + return notifications 11 + } 12 + 13 + // get the amount of notifications a user has, with a given limit 14 + pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 15 + notifications := sql app.db { 16 + select from Notification where user_id == user_id limit limit 17 + } or { [] } 18 + return notifications.len 19 + } 20 + 21 + // send a notification to the given user 22 + pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) { 23 + notification := Notification{ 24 + user_id: user_id 25 + summary: summary 26 + body: body 27 + } 28 + sql app.db { 29 + insert notification into Notification 30 + } or { 31 + eprintln('failed to send notification ${notification}') 32 + } 33 + }
+74
src/database/post.v
··· 1 + module database 2 + 3 + import time 4 + import entity { Post, Like, LikeCache } 5 + 6 + // get a post by its id, returns none if it does not exist 7 + pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post { 8 + posts := sql app.db { 9 + select from Post where id == id limit 1 10 + } or { [] } 11 + if posts.len != 1 { 12 + return none 13 + } 14 + return posts[0] 15 + } 16 + 17 + // get a post by its author and timestamp, returns none if it does not exist 18 + pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 19 + posts := sql app.db { 20 + select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 21 + } or { [] } 22 + if posts.len == 0 { 23 + return none 24 + } 25 + return posts[0] 26 + } 27 + 28 + // get a list of posts given a tag. this performs sql string operations and 29 + // probably is not very efficient, use sparingly. 30 + pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post { 31 + posts := sql app.db { 32 + select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset 33 + } or { [] } 34 + return posts 35 + } 36 + 37 + // returns a list of all pinned posts 38 + pub fn (app &DatabaseAccess) get_pinned_posts() []Post { 39 + posts := sql app.db { 40 + select from Post where pinned == true 41 + } or { [] } 42 + return posts 43 + } 44 + 45 + // returns a list of the ten most recent posts. 46 + pub fn (app &DatabaseAccess) get_recent_posts() []Post { 47 + posts := sql app.db { 48 + select from Post order by posted_at desc limit 10 49 + } or { [] } 50 + return posts 51 + } 52 + 53 + // returns a list of the ten most liked posts. 54 + // TODO: make this time-gated (i.e, top ten liked posts of the day) 55 + pub fn (app &DatabaseAccess) get_popular_posts() []Post { 56 + cached_likes := sql app.db { 57 + select from LikeCache order by likes desc limit 10 58 + } or { [] } 59 + posts := cached_likes.map(fn [app] (it LikeCache) Post { 60 + return app.get_post_by_id(it.post_id) or { 61 + eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 62 + return Post{} 63 + } 64 + }).filter(it.id != 0) 65 + return posts 66 + } 67 + 68 + // returns a list of all posts from a user in descending order of date 69 + pub fn (app &DatabaseAccess) get_posts_from_user(user_id int) []Post { 70 + posts := sql app.db { 71 + select from Post where author_id == user_id order by posted_at desc 72 + } or { [] } 73 + return posts 74 + }
+20
src/database/site.v
··· 1 + module database 2 + 3 + import entity { Site } 4 + 5 + pub fn (app &DatabaseAccess) get_or_create_site_config() Site { 6 + configs := sql app.db { 7 + select from Site 8 + } or { [] } 9 + if configs.len == 0 { 10 + // make the site config 11 + site_config := Site{} 12 + sql app.db { 13 + insert site_config into Site 14 + } or { panic('failed to create site config (${err})') } 15 + } else if configs.len > 1 { 16 + // this should never happen 17 + panic('there are multiple site configs') 18 + } 19 + return configs[0] 20 + }
+171
src/database/user.v
··· 1 + module database 2 + 3 + import entity { User, Notification, Like, Post } 4 + 5 + // creates a new user and returns their struct after creation. 6 + pub fn (app &DatabaseAccess) new_user(user User) ?User { 7 + sql app.db { 8 + insert user into User 9 + } or { 10 + eprintln('failed to insert user ${user}') 11 + return none 12 + } 13 + 14 + println('reg: ${user.username}') 15 + 16 + return app.get_user_by_name(user.username) 17 + } 18 + 19 + // updates the given user's username, returns true if this succeeded and false 20 + // otherwise. 21 + pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool { 22 + sql app.db { 23 + update User set username = new_username where id == user_id 24 + } or { 25 + eprintln('failed to update username for ${user_id}') 26 + return false 27 + } 28 + return true 29 + } 30 + 31 + // updates the given user's password, returns true if this succeeded and false 32 + // otherwise. 33 + pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool { 34 + sql app.db { 35 + update User set password = hashed_new_password where id == user_id 36 + } or { 37 + eprintln('failed to update password for ${user_id}') 38 + return false 39 + } 40 + return true 41 + } 42 + 43 + // updates the given user's nickname, returns true if this succeeded and false 44 + // otherwise. 45 + pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool { 46 + sql app.db { 47 + update User set nickname = new_nickname where id == user_id 48 + } or { 49 + eprintln('failed to update nickname for ${user_id}') 50 + return false 51 + } 52 + return true 53 + } 54 + 55 + // updates the given user's muted status, returns true if this succeeded and 56 + // false otherwise. 57 + pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool { 58 + sql app.db { 59 + update User set muted = muted where id == user_id 60 + } or { 61 + eprintln('failed to update muted status for ${user_id}') 62 + return false 63 + } 64 + return true 65 + } 66 + 67 + // updates the given user's theme url, returns true if this succeeded and false 68 + // otherwise. 69 + pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { 70 + sql app.db { 71 + update User set theme = theme where id == user_id 72 + } or { 73 + eprintln('failed to update theme url for ${user_id}') 74 + return false 75 + } 76 + return true 77 + } 78 + 79 + // updates the given user's pronouns, returns true if this succeeded and false 80 + // otherwise. 81 + pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { 82 + sql app.db { 83 + update User set pronouns = pronouns where id == user_id 84 + } or { 85 + eprintln('failed to update pronouns for ${user_id}') 86 + return false 87 + } 88 + return true 89 + } 90 + 91 + // updates the given user's bio, returns true if this succeeded and false 92 + // otherwise. 93 + pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool { 94 + sql app.db { 95 + update User set bio = bio where id == user_id 96 + } or { 97 + eprintln('failed to update bio for ${user_id}') 98 + return false 99 + } 100 + return true 101 + } 102 + 103 + // get a user by their username, returns none if the user was not found. 104 + pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User { 105 + users := sql app.db { 106 + select from User where username == username 107 + } or { [] } 108 + if users.len != 1 { 109 + return none 110 + } 111 + return users[0] 112 + } 113 + 114 + // get a user by their id, returns none if the user was not found. 115 + pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User { 116 + users := sql app.db { 117 + select from User where id == id 118 + } or { [] } 119 + if users.len != 1 { 120 + return none 121 + } 122 + return users[0] 123 + } 124 + 125 + // returns all users 126 + pub fn (app &DatabaseAccess) get_users() []User { 127 + users := sql app.db { 128 + select from User 129 + } or { [] } 130 + return users 131 + } 132 + 133 + // returns true if a user likes the given post 134 + pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 135 + likes := sql app.db { 136 + select from Like where user_id == user_id && post_id == post_id 137 + } or { [] } 138 + if likes.len > 1 { 139 + // something is very wrong lol 140 + eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 141 + } else if likes.len == 0 { 142 + return false 143 + } 144 + return likes.first().is_like 145 + } 146 + 147 + // returns true if a user dislikes the given post 148 + pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 149 + likes := sql app.db { 150 + select from Like where user_id == user_id && post_id == post_id 151 + } or { [] } 152 + if likes.len > 1 { 153 + // something is very wrong lol 154 + eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 155 + } else if likes.len == 0 { 156 + return false 157 + } 158 + return !likes.first().is_like 159 + } 160 + 161 + // returns true if a user likes or dislikes the given post 162 + pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 163 + likes := sql app.db { 164 + select from Like where user_id == user_id && post_id == post_id 165 + } or { [] } 166 + if likes.len > 1 { 167 + // something is very wrong lol 168 + eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 169 + } 170 + return likes.len == 1 171 + }
+2 -1
src/main.v
··· 5 5 import auth 6 6 import entity 7 7 import os 8 + import webapp { App, Context, StringValidator } 8 9 9 10 fn init_db(db pg.DB) ! { 10 11 sql db { ··· 18 19 } 19 20 20 21 fn main() { 21 - config := load_config_from(os.args[1]) 22 + config := webapp.load_config_from(os.args[1]) 22 23 23 24 println('-> connecting to db...') 24 25 mut db := pg.connect(pg.Config{
+15 -15
src/pages.v src/webapp/pages.v
··· 1 - module main 1 + module webapp 2 2 3 3 import veb 4 4 import entity { User } ··· 9 9 recent_posts := app.get_recent_posts() 10 10 pinned_posts := app.get_pinned_posts() 11 11 motd := app.get_motd() 12 - return $veb.html() 12 + return $veb.html('../templates/index.html') 13 13 } 14 14 15 15 fn (mut app App) login(mut ctx Context) veb.Result { 16 16 ctx.title = 'login to ${app.config.instance.name}' 17 17 user := app.whoami(mut ctx) or { User{} } 18 - return $veb.html() 18 + return $veb.html('../templates/login.html') 19 19 } 20 20 21 21 fn (mut app App) register(mut ctx Context) veb.Result { 22 22 ctx.title = 'register for ${app.config.instance.name}' 23 23 user := app.whoami(mut ctx) or { User{} } 24 - return $veb.html() 24 + return $veb.html('../templates/register.html') 25 25 } 26 26 27 27 fn (mut app App) me(mut ctx Context) veb.Result { ··· 39 39 return ctx.redirect('/login') 40 40 } 41 41 ctx.title = '${app.config.instance.name} - settings' 42 - return $veb.html() 42 + return $veb.html('../templates/settings.html') 43 43 } 44 44 45 45 fn (mut app App) admin(mut ctx Context) veb.Result { 46 46 ctx.title = '${app.config.instance.name} dashboard' 47 47 user := app.whoami(mut ctx) or { User{} } 48 - return $veb.html() 48 + return $veb.html('../templates/admin.html') 49 49 } 50 50 51 51 fn (mut app App) inbox(mut ctx Context) veb.Result { ··· 55 55 } 56 56 ctx.title = '${app.config.instance.name} inbox' 57 57 notifications := app.get_notifications_for(user.id) 58 - return $veb.html() 58 + return $veb.html('../templates/inbox.html') 59 59 } 60 60 61 61 fn (mut app App) logout(mut ctx Context) veb.Result { ··· 64 64 return ctx.redirect('/login') 65 65 } 66 66 ctx.title = '${app.config.instance.name} logout' 67 - return $veb.html() 67 + return $veb.html('../templates/logout.html') 68 68 } 69 69 70 70 @['/user/:username'] ··· 75 75 return ctx.redirect('/') 76 76 } 77 77 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 78 - return $veb.html() 78 + return $veb.html('../templates/user.html') 79 79 } 80 80 81 81 @['/post/:post_id'] ··· 99 99 } 100 100 } 101 101 102 - return $veb.html() 102 + return $veb.html('../templates/post.html') 103 103 } 104 104 105 105 @['/post/:post_id/edit'] ··· 117 117 return ctx.redirect('/post/${post_id}') 118 118 } 119 119 ctx.title = '${app.config.instance.name} - editing ${post.title}' 120 - return $veb.html() 120 + return $veb.html('../templates/edit.html') 121 121 } 122 122 123 123 @['/post/:post_id/reply'] ··· 137 137 ctx.error('no such post') 138 138 return ctx.redirect('/') 139 139 } 140 - return $veb.html('templates/new_post.html') 140 + return $veb.html('../templates/new_post.html') 141 141 } 142 142 143 143 @['/post/new'] ··· 150 150 replying := false 151 151 replying_to := 0 152 152 replying_to_user := User{} 153 - return $veb.html('templates/new_post.html') 153 + return $veb.html('../templates/new_post.html') 154 154 } 155 155 156 156 @['/tag/:tag'] ··· 161 161 } 162 162 ctx.title = '${app.config.instance.name} - #${tag}' 163 163 offset := 0 164 - return $veb.html() 164 + return $veb.html('../templates/tag.html') 165 165 } 166 166 167 167 @['/tag/:tag/:offset'] ··· 171 171 return ctx.redirect('/login') 172 172 } 173 173 ctx.title = '${app.config.instance.name} - #${tag}' 174 - return $veb.html('templates/tag.html') 174 + return $veb.html('../templates/tag.html') 175 175 }
+2
src/templates/post.html
··· 70 70 <input type="submit" value="delete"> 71 71 </form> 72 72 73 + @if user.admin 73 74 <form action="/api/post/pin" method="post"> 74 75 <input 75 76 type="number" ··· 84 85 > 85 86 <input type="submit" value="pin"> 86 87 </form> 88 + @end 87 89 88 90 </div> 89 91 @end
+8 -1
src/templates/user.html
··· 56 56 @if viewing.bio != '' 57 57 <div> 58 58 <h2>bio:</h2> 59 - <p>@viewing.bio</p> 59 + <pre id="bio">@viewing.bio</pre> 60 60 </div> 61 61 @end 62 62 ··· 105 105 @end 106 106 </form> 107 107 </div> 108 + @end 109 + 110 + @if viewing.bio != '' 111 + <script src="/static/js/render_body.js"></script> 112 + <script> 113 + render_body('bio') 114 + </script> 108 115 @end 109 116 110 117 @include 'partial/footer.html'
+1 -1
src/validation.v src/webapp/validation.v
··· 1 - module main 1 + module webapp 2 2 3 3 import regex 4 4
+156
src/webapp/app.v
··· 1 + module webapp 2 + 3 + import veb 4 + import db.pg 5 + import regex 6 + import auth 7 + import entity { LikeCache, Like, Post, Site, User, Notification } 8 + import database { DatabaseAccess } 9 + 10 + pub struct App { 11 + veb.StaticHandler 12 + DatabaseAccess 13 + pub: 14 + config Config 15 + pub mut: 16 + auth auth.Auth[pg.DB] 17 + validators struct { 18 + pub mut: 19 + username StringValidator 20 + password StringValidator 21 + nickname StringValidator 22 + pronouns StringValidator 23 + user_bio StringValidator 24 + post_title StringValidator 25 + post_body StringValidator 26 + } 27 + } 28 + 29 + // get a user by their token, returns none if the user was not found. 30 + pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 31 + user_token := app.auth.find_token(token, ctx.ip()) or { 32 + eprintln('no such user corresponding to token') 33 + return none 34 + } 35 + return app.get_user_by_id(user_token.user_id) 36 + } 37 + 38 + // returns the current logged in user, or none if the user is not logged in. 39 + pub fn (app &App) whoami(mut ctx Context) ?User { 40 + token := ctx.get_cookie('token') or { return none }.trim_space() 41 + if token == '' { 42 + return none 43 + } 44 + if user := app.get_user_by_token(ctx, token) { 45 + if user.username == '' || user.id == 0 { 46 + eprintln('a user had a token for the blank user') 47 + // Clear token 48 + ctx.set_cookie( 49 + name: 'token' 50 + value: '' 51 + same_site: .same_site_none_mode 52 + secure: true 53 + path: '/' 54 + ) 55 + return none 56 + } 57 + return user 58 + } else { 59 + eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)') 60 + // Clear token 61 + ctx.set_cookie( 62 + name: 'token' 63 + value: '' 64 + same_site: .same_site_none_mode 65 + secure: true 66 + path: '/' 67 + ) 68 + return none 69 + } 70 + } 71 + 72 + // get a user representing an unknown user 73 + pub fn (app &App) get_unknown_user() User { 74 + return User{ 75 + username: 'unknown' 76 + } 77 + } 78 + 79 + // get a post representing an unknown post 80 + pub fn (app &App) get_unknown_post() Post { 81 + return Post{ 82 + title: 'unknown' 83 + } 84 + } 85 + 86 + // returns true if the user is logged in as the provided user id. 87 + pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 88 + if !ctx.is_logged_in() { 89 + return false 90 + } 91 + return app.whoami(mut ctx) or { return false }.id == id 92 + } 93 + 94 + // get the site's message of the day. 95 + @[inline] 96 + pub fn (app &App) get_motd() string { 97 + site := app.get_or_create_site_config() 98 + return site.motd 99 + } 100 + 101 + // get the notification count for a given user, formatted for usage on the 102 + // frontend. 103 + pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 104 + count := app.get_notification_count(user_id, limit) 105 + if count == 0 { 106 + return '' 107 + } else if count > limit { 108 + return ' (${count}+)' 109 + } else { 110 + return ' (${count})' 111 + } 112 + } 113 + 114 + // processes a post's body to send notifications for mentions or replies. 115 + pub fn (app &App) process_post_mentions(post &Post) { 116 + author := app.get_user_by_id(post.author_id) or { 117 + eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') 118 + return 119 + } 120 + author_name := author.get_name() 121 + 122 + // used so we do not send more than one notification per post 123 + mut notified_users := []int{} 124 + 125 + // notify who we replied to, if applicable 126 + if post.replying_to != none { 127 + if x := app.get_post_by_id(post.replying_to) { 128 + app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})') 129 + } 130 + } 131 + 132 + // find mentions 133 + mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 134 + eprintln('failed to compile regex for process_post_mentions (err: ${err})') 135 + return 136 + } 137 + matches := re.find_all_str(post.body) 138 + for mat in matches { 139 + println('found mentioned user: ${mat}') 140 + username := mat#[2..-1] 141 + user := app.get_user_by_name(username) or { 142 + continue 143 + } 144 + 145 + if user.id in notified_users || user.id == author.id { 146 + continue 147 + } 148 + notified_users << user.id 149 + 150 + app.send_notification_to( 151 + user.id, 152 + '${author_name} mentioned you!', 153 + 'you have been mentioned in this post: *(${post.id})' 154 + ) 155 + } 156 + }