a mini social media app for small communities
1module main 2 3import veb 4import db.pg 5import regex 6import time 7import auth 8import entity { LikeCache, Like, Post, Site, User, Notification } 9 10pub struct App { 11 veb.StaticHandler 12pub: 13 config Config 14pub 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 29pub 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 39pub 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 49pub 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 57pub 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 64pub 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 77pub 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 84pub fn (app &App) get_users() []User { 85 users := sql app.db { 86 select from User 87 } or { [] } 88 return users 89} 90 91pub 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 101pub 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 111pub 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 118pub 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 125pub 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 158pub fn (app &App) get_unknown_user() User { 159 return User{ 160 username: 'unknown' 161 } 162} 163 164pub fn (app &App) get_unknown_post() Post { 165 return Post{ 166 title: 'unknown' 167 } 168} 169 170pub 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 177pub 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 190pub 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 203pub 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 214pub 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 255pub 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] 273pub fn (app &App) get_motd() string { 274 site := app.get_or_create_site_config() 275 return site.motd 276} 277 278pub 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 285pub 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 292pub 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 303pub 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 317pub 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}