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_pinned_posts() []Post {
112 posts := sql app.db {
113 select from Post where pinned == true
114 } or { [] }
115 return posts
116}
117
118pub fn (app &App) whoami(mut ctx Context) ?User {
119 token := ctx.get_cookie('token') or { return none }.trim_space()
120 if token == '' {
121 return none
122 }
123 if user := app.get_user_by_token(ctx, token) {
124 if user.username == '' || user.id == 0 {
125 eprintln('a user had a token for the blank user')
126 // Clear token
127 ctx.set_cookie(
128 name: 'token'
129 value: ''
130 same_site: .same_site_none_mode
131 secure: true
132 path: '/'
133 )
134 return none
135 }
136 return user
137 } else {
138 eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)')
139 // Clear token
140 ctx.set_cookie(
141 name: 'token'
142 value: ''
143 same_site: .same_site_none_mode
144 secure: true
145 path: '/'
146 )
147 return none
148 }
149}
150
151pub fn (app &App) get_unknown_user() User {
152 return User{
153 username: 'unknown'
154 }
155}
156
157pub fn (app &App) get_unknown_post() Post {
158 return Post{
159 title: 'unknown'
160 }
161}
162
163pub fn (app &App) logged_in_as(mut ctx Context, id int) bool {
164 if !ctx.is_logged_in() {
165 return false
166 }
167 return app.whoami(mut ctx) or { return false }.id == id
168}
169
170pub fn (app &App) does_user_like_post(user_id int, post_id int) bool {
171 likes := sql app.db {
172 select from Like where user_id == user_id && post_id == post_id
173 } or { [] }
174 if likes.len > 1 {
175 // something is very wrong lol
176 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
177 } else if likes.len == 0 {
178 return false
179 }
180 return likes.first().is_like
181}
182
183pub fn (app &App) does_user_dislike_post(user_id int, post_id int) bool {
184 likes := sql app.db {
185 select from Like where user_id == user_id && post_id == post_id
186 } or { [] }
187 if likes.len > 1 {
188 // something is very wrong lol
189 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
190 } else if likes.len == 0 {
191 return false
192 }
193 return !likes.first().is_like
194}
195
196pub fn (app &App) does_user_like_or_dislike_post(user_id int, post_id int) bool {
197 likes := sql app.db {
198 select from Like where user_id == user_id && post_id == post_id
199 } or { [] }
200 if likes.len > 1 {
201 // something is very wrong lol
202 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
203 }
204 return likes.len == 1
205}
206
207pub fn (app &App) get_net_likes_for_post(post_id int) int {
208 // check cache
209 cache := sql app.db {
210 select from LikeCache where post_id == post_id limit 1
211 } or { [] }
212
213 mut likes := 0
214
215 if cache.len != 1 {
216 println('calculating net likes for post: ${post_id}')
217 // calculate
218 db_likes := sql app.db {
219 select from Like where post_id == post_id
220 } or { [] }
221
222 for like in db_likes {
223 if like.is_like {
224 likes++
225 } else {
226 likes--
227 }
228 }
229
230 // cache
231 cached := LikeCache{
232 post_id: post_id
233 likes: likes
234 }
235 sql app.db {
236 insert cached into LikeCache
237 } or {
238 eprintln('failed to cache like: ${cached}')
239 return likes
240 }
241 } else {
242 likes = cache.first().likes
243 }
244
245 return likes
246}
247
248pub fn (app &App) get_or_create_site_config() Site {
249 configs := sql app.db {
250 select from Site
251 } or { [] }
252 if configs.len == 0 {
253 // make the site config
254 site_config := Site{}
255 sql app.db {
256 insert site_config into Site
257 } or { panic('failed to create site config (${err})') }
258 } else if configs.len > 1 {
259 // this should never happen
260 panic('there are multiple site configs')
261 }
262 return configs[0]
263}
264
265@[inline]
266pub fn (app &App) get_motd() string {
267 site := app.get_or_create_site_config()
268 return site.motd
269}
270
271pub fn (app &App) get_notifications_for(user_id int) []Notification {
272 notifications := sql app.db {
273 select from Notification where user_id == user_id
274 } or { [] }
275 return notifications
276}
277
278pub fn (app &App) get_notification_count(user_id int, limit int) int {
279 notifications := sql app.db {
280 select from Notification where user_id == user_id limit limit
281 } or { [] }
282 return notifications.len
283}
284
285pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string {
286 count := app.get_notification_count(user_id, limit)
287 if count == 0 {
288 return ''
289 } else if count > limit {
290 return ' (${count}+)'
291 } else {
292 return ' (${count})'
293 }
294}
295
296pub fn (app &App) send_notification_to(user_id int, summary string, body string) {
297 notification := Notification{
298 user_id: user_id
299 summary: summary
300 body: body
301 }
302 sql app.db {
303 insert notification into Notification
304 } or {
305 eprintln('failed to send notification ${notification}')
306 }
307}
308
309// sends notifications to each user mentioned in a post
310pub fn (app &App) process_post_mentions(post &Post) {
311 author := app.get_user_by_id(post.author_id) or {
312 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
313 return
314 }
315 author_name := author.get_name()
316
317 // used so we do not send more than one notification per post
318 mut notified_users := []int{}
319
320 // notify who we replied to, if applicable
321 if post.replying_to != none {
322 if x := app.get_post_by_id(post.replying_to) {
323 app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})')
324 }
325 }
326
327 // find mentions
328 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or {
329 eprintln('failed to compile regex for process_post_mentions (err: ${err})')
330 return
331 }
332 matches := re.find_all_str(post.body)
333 for mat in matches {
334 println('found mentioned user: ${mat}')
335 username := mat#[2..-1]
336 user := app.get_user_by_name(username) or {
337 continue
338 }
339
340 if user.id in notified_users || user.id == author.id {
341 continue
342 }
343 notified_users << user.id
344
345 app.send_notification_to(
346 user.id,
347 '${author_name} mentioned you!',
348 'you have been mentioned in this post: *(${post.id})'
349 )
350 }
351}