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}