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) logged_in_as(mut ctx Context, id int) bool {
158 if !ctx.is_logged_in() {
159 return false
160 }
161 return app.whoami(mut ctx) or { return false }.id == id
162}
163
164pub fn (app &App) does_user_like_post(user_id int, post_id int) bool {
165 likes := sql app.db {
166 select from Like where user_id == user_id && post_id == post_id
167 } or { [] }
168 if likes.len > 1 {
169 // something is very wrong lol
170 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
171 } else if likes.len == 0 {
172 return false
173 }
174 return likes.first().is_like
175}
176
177pub fn (app &App) does_user_dislike_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_like_or_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 }
198 return likes.len == 1
199}
200
201pub fn (app &App) get_net_likes_for_post(post_id int) int {
202 // check cache
203 cache := sql app.db {
204 select from LikeCache where post_id == post_id limit 1
205 } or { [] }
206
207 mut likes := 0
208
209 if cache.len != 1 {
210 println('calculating net likes for post: ${post_id}')
211 // calculate
212 db_likes := sql app.db {
213 select from Like where post_id == post_id
214 } or { [] }
215
216 for like in db_likes {
217 if like.is_like {
218 likes++
219 } else {
220 likes--
221 }
222 }
223
224 // cache
225 cached := LikeCache{
226 post_id: post_id
227 likes: likes
228 }
229 sql app.db {
230 insert cached into LikeCache
231 } or {
232 eprintln('failed to cache like: ${cached}')
233 return likes
234 }
235 } else {
236 likes = cache.first().likes
237 }
238
239 return likes
240}
241
242pub fn (app &App) get_or_create_site_config() Site {
243 configs := sql app.db {
244 select from Site
245 } or { [] }
246 if configs.len == 0 {
247 // make the site config
248 site_config := Site{}
249 sql app.db {
250 insert site_config into Site
251 } or { panic('failed to create site config (${err})') }
252 } else if configs.len > 1 {
253 // this should never happen
254 panic('there are multiple site configs')
255 }
256 return configs[0]
257}
258
259@[inline]
260pub fn (app &App) get_motd() string {
261 site := app.get_or_create_site_config()
262 return site.motd
263}
264
265pub fn (app &App) get_notifications_for(user_id int) []Notification {
266 notifications := sql app.db {
267 select from Notification where user_id == user_id
268 } or { [] }
269 return notifications
270}
271
272pub fn (app &App) get_notification_count(user_id int, limit int) int {
273 notifications := sql app.db {
274 select from Notification where user_id == user_id limit limit
275 } or { [] }
276 return notifications.len
277}
278
279pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string {
280 count := app.get_notification_count(user_id, limit)
281 if count == 0 {
282 return ''
283 } else if count > limit {
284 return ' (${count}+)'
285 } else {
286 return ' (${count})'
287 }
288}
289
290pub fn (app &App) send_notification_to(user_id int, summary string, body string) {
291 notification := Notification{
292 user_id: user_id
293 summary: summary
294 body: body
295 }
296 sql app.db {
297 insert notification into Notification
298 } or {
299 eprintln('failed to send notification ${notification}')
300 }
301}
302
303// sends notifications to each user mentioned in a post
304pub fn (app &App) process_post_mentions(post &Post) {
305 author := app.get_user_by_id(post.author_id) or {
306 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
307 return
308 }
309 author_name := author.get_name()
310
311 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or {
312 eprintln('failed to compile regex for process_post_mentions (err: ${err})')
313 return
314 }
315 matches := re.find_all_str(post.body)
316 mut mentioned_users := []int{}
317 for mat in matches {
318 println('found mentioned user: ${mat}')
319 username := mat#[2..-1]
320 user := app.get_user_by_name(username) or {
321 continue
322 }
323
324 if user.id in mentioned_users || user.id == author.id {
325 continue
326 }
327 mentioned_users << user.id
328
329 app.send_notification_to(
330 user.id,
331 '${author_name} mentioned you!',
332 'you have been mentioned in this post: *(${post.id})'
333 )
334 }
335}