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
64// pub fn (app &App) get_popular_posts() []Post {
65// posts := sql app.db {
66// select from Post order by likes desc limit 10
67// } or { [] }
68// return posts
69// }
70
71pub fn (app &App) get_posts_from_user(user_id int) []Post {
72 posts := sql app.db {
73 select from Post where author_id == user_id order by posted_at desc
74 } or { [] }
75 return posts
76}
77
78pub fn (app &App) get_users() []User {
79 users := sql app.db {
80 select from User
81 } or { [] }
82 return users
83}
84
85pub fn (app &App) get_post_by_id(id int) ?Post {
86 posts := sql app.db {
87 select from Post where id == id limit 1
88 } or { [] }
89 if posts.len != 1 {
90 return none
91 }
92 return posts[0]
93}
94
95pub fn (app &App) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post {
96 posts := sql app.db {
97 select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1
98 } or { [] }
99 if posts.len == 0 {
100 return none
101 }
102 return posts[0]
103}
104
105pub fn (app &App) get_pinned_posts() []Post {
106 posts := sql app.db {
107 select from Post where pinned == true
108 } or { [] }
109 return posts
110}
111
112pub fn (app &App) whoami(mut ctx Context) ?User {
113 token := ctx.get_cookie('token') or { return none }.trim_space()
114 if token == '' {
115 return none
116 }
117 if user := app.get_user_by_token(ctx, token) {
118 if user.username == '' || user.id == 0 {
119 eprintln('a user had a token for the blank user')
120 // Clear token
121 ctx.set_cookie(
122 name: 'token'
123 value: ''
124 same_site: .same_site_none_mode
125 secure: true
126 path: '/'
127 )
128 return none
129 }
130 return user
131 } else {
132 eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)')
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}
144
145pub fn (app &App) get_unknown_user() User {
146 return User{
147 username: 'unknown'
148 }
149}
150
151pub fn (app &App) logged_in_as(mut ctx Context, id int) bool {
152 if !ctx.is_logged_in() {
153 return false
154 }
155 return app.whoami(mut ctx) or { return false }.id == id
156}
157
158pub fn (app &App) does_user_like_post(user_id int, post_id int) bool {
159 likes := sql app.db {
160 select from Like where user_id == user_id && post_id == post_id
161 } or { [] }
162 if likes.len > 1 {
163 // something is very wrong lol
164 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
165 } else if likes.len == 0 {
166 return false
167 }
168 return likes.first().is_like
169}
170
171pub fn (app &App) does_user_dislike_post(user_id int, post_id int) bool {
172 likes := sql app.db {
173 select from Like where user_id == user_id && post_id == post_id
174 } or { [] }
175 if likes.len > 1 {
176 // something is very wrong lol
177 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
178 } else if likes.len == 0 {
179 return false
180 }
181 return !likes.first().is_like
182}
183
184pub fn (app &App) does_user_like_or_dislike_post(user_id int, post_id int) bool {
185 likes := sql app.db {
186 select from Like where user_id == user_id && post_id == post_id
187 } or { [] }
188 if likes.len > 1 {
189 // something is very wrong lol
190 eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
191 }
192 return likes.len == 1
193}
194
195pub fn (app &App) get_net_likes_for_post(post_id int) int {
196 // check cache
197 cache := sql app.db {
198 select from LikeCache where post_id == post_id limit 1
199 } or { [] }
200
201 mut likes := 0
202
203 if cache.len != 1 {
204 println('calculating net likes for post: ${post_id}')
205 // calculate
206 db_likes := sql app.db {
207 select from Like where post_id == post_id
208 } or { [] }
209
210 for like in db_likes {
211 if like.is_like {
212 likes++
213 } else {
214 likes--
215 }
216 }
217
218 // cache
219 cached := LikeCache{
220 post_id: post_id
221 likes: likes
222 }
223 sql app.db {
224 insert cached into LikeCache
225 } or {
226 eprintln('failed to cache like: ${cached}')
227 return likes
228 }
229 } else {
230 likes = cache.first().likes
231 }
232
233 return likes
234}
235
236pub fn (app &App) get_or_create_site_config() Site {
237 configs := sql app.db {
238 select from Site
239 } or { [] }
240 if configs.len == 0 {
241 // make the site config
242 site_config := Site{}
243 sql app.db {
244 insert site_config into Site
245 } or { panic('failed to create site config (${err})') }
246 } else if configs.len > 1 {
247 // this should never happen
248 panic('there are multiple site configs')
249 }
250 return configs[0]
251}
252
253@[inline]
254pub fn (app &App) get_motd() string {
255 site := app.get_or_create_site_config()
256 return site.motd
257}
258
259pub fn (app &App) get_notifications_for(user_id int) []Notification {
260 notifications := sql app.db {
261 select from Notification where user_id == user_id
262 } or { [] }
263 return notifications
264}
265
266pub fn (app &App) get_notification_count(user_id int, limit int) int {
267 notifications := sql app.db {
268 select from Notification where user_id == user_id limit limit
269 } or { [] }
270 return notifications.len
271}
272
273pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string {
274 count := app.get_notification_count(user_id, limit)
275 if count == 0 {
276 return ''
277 } else if count > limit {
278 return ' (${count}+)'
279 } else {
280 return ' (${count})'
281 }
282}
283
284pub fn (app &App) send_notification_to(user_id int, summary string, body string) {
285 notification := Notification{
286 user_id: user_id
287 summary: summary
288 body: body
289 }
290 sql app.db {
291 insert notification into Notification
292 } or {
293 eprintln('failed to send notification ${notification}')
294 }
295}
296
297// sends notifications to each user mentioned in a post
298pub fn (app &App) process_post_mentions(post &Post) {
299 author := app.get_user_by_id(post.author_id) or {
300 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
301 return
302 }
303 author_name := author.get_name()
304
305 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or {
306 eprintln('failed to compile regex for process_post_mentions (err: ${err})')
307 return
308 }
309 matches := re.find_all_str(post.body)
310 mut mentioned_users := []int{}
311 for mat in matches {
312 println('found mentioned user: ${mat}')
313 username := mat#[2..-1]
314 user := app.get_user_by_name(username) or {
315 continue
316 }
317
318 if user.id in mentioned_users || user.id == author.id {
319 continue
320 }
321 mentioned_users << user.id
322
323 app.send_notification_to(
324 user.id,
325 '${author_name} mentioned you!',
326 'you have been mentioned in this post: *(${post.id})'
327 )
328 }
329}