+4
doc/resources.md
+4
doc/resources.md
+24
-6
doc/todo.md
+24
-6
doc/todo.md
···
4
4
5
5
## in-progress
6
6
7
-
- [ ] post:embedded links (links added to a post will be embedded into the post
8
-
as images, music links, etc)
9
-
- should have special handling for spotify, apple music, youtube,
10
-
discord, and other common links. we want those ones to look fancy!
7
+
- [x] post:search for posts
8
+
- [ ] filters:
9
+
```
10
+
created-at:<date>
11
+
created-after:<date>
12
+
created-before:<date>
13
+
is:pinned
14
+
has-tag:<tag>
15
+
posted-by:<user>
16
+
!excluded-query
17
+
```
18
+
- [x] user:search for users
19
+
- [ ] filters:
20
+
```
21
+
created-at:<date>
22
+
created-after:<date>
23
+
created-before:<date>
24
+
is:admin
25
+
```
11
26
12
27
## planing
13
28
···
15
30
> however, i will not be fixing it, because it is funny.
16
31
17
32
- [ ] post:saving (add the post to a list of saved posts that a user can view later)
18
-
- [ ] post:search for posts
19
-
- [ ] user:search for users
33
+
- [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc)
20
34
- [ ] user:follow other users (send notifications on new posts)
21
35
22
36
## ideas
···
40
54
- [x] post:editing
41
55
- [x] post:replies
42
56
- [x] post:tags ('hashtags')
57
+
- [x] post:embedded links (links added to a post will be embedded into the post
58
+
as images, music links, etc)
59
+
- should have special handling for spotify, apple music, youtube,
60
+
discord, and other common links. we want those ones to look fancy!
43
61
- [x] site:message of the day (admins can add a welcome message displayed on index.html)
44
62
45
63
## graveyard
+15
src/database/database.v
+15
src/database/database.v
···
2
2
module database
3
3
4
4
import db.pg
5
+
import entity { User, Post }
5
6
6
7
// DatabaseAccess handles all interactions with the database.
7
8
pub struct DatabaseAccess {
8
9
pub mut:
9
10
db pg.DB
10
11
}
12
+
13
+
// get_unknown_user returns a user representing an unknown user
14
+
pub fn (app &DatabaseAccess) get_unknown_user() User {
15
+
return User{
16
+
username: 'unknown'
17
+
}
18
+
}
19
+
20
+
// get_unknown_post returns a post representing an unknown post
21
+
pub fn (app &DatabaseAccess) get_unknown_post() Post {
22
+
return Post{
23
+
title: 'unknown'
24
+
}
25
+
}
+26
src/database/like.v
+26
src/database/like.v
···
2
2
3
3
import entity { Like, LikeCache }
4
4
5
+
// add_like adds a like to the database, returns true if this succeeds and false
6
+
// otherwise.
7
+
pub fn (app &DatabaseAccess) add_like(like &Like) bool {
8
+
sql app.db {
9
+
insert like into Like
10
+
// yeet the old cached like value
11
+
delete from LikeCache where post_id == like.post_id
12
+
} or {
13
+
return false
14
+
}
15
+
return true
16
+
}
17
+
5
18
// get_net_likes_for_post returns the net likes of the given post.
6
19
pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int {
7
20
// check cache
···
43
56
44
57
return likes
45
58
}
59
+
60
+
// unlike_post removes a (dis)like from the given post, returns true if this
61
+
// succeeds and false otherwise.
62
+
pub fn (app &DatabaseAccess) unlike_post(post_id int, user_id int) bool {
63
+
sql app.db {
64
+
delete from Like where user_id == user_id && post_id == post_id
65
+
// yeet the old cached like value
66
+
delete from LikeCache where post_id == post_id
67
+
} or {
68
+
return false
69
+
}
70
+
return true
71
+
}
+34
src/database/notification.v
+34
src/database/notification.v
···
2
2
3
3
import entity { Notification }
4
4
5
+
// get_notification_by_id gets a notification by its given id, returns none if
6
+
// the notification does not exist.
7
+
pub fn (app &DatabaseAccess) get_notification_by_id(id int) ?Notification {
8
+
notifications := sql app.db {
9
+
select from Notification where id == id
10
+
} or { [] }
11
+
if notifications.len != 1 {
12
+
return none
13
+
}
14
+
return notifications[0]
15
+
}
16
+
17
+
// delete_notification deletes the given notification, returns true if this
18
+
// succeeded and false otherwise.
19
+
pub fn (app &DatabaseAccess) delete_notification(id int) bool {
20
+
sql app.db {
21
+
delete from Notification where id == id
22
+
} or {
23
+
return false
24
+
}
25
+
return true
26
+
}
27
+
28
+
// delete_notifications_for_user deletes all notifications for the given user,
29
+
// returns true if this succeeded and false otherwise.
30
+
pub fn (app &DatabaseAccess) delete_notifications_for_user(user_id int) bool {
31
+
sql app.db {
32
+
delete from Notification where user_id == user_id
33
+
} or {
34
+
return false
35
+
}
36
+
return true
37
+
}
38
+
5
39
// get_notifications_for gets a list of notifications for the given user.
6
40
pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification {
7
41
notifications := sql app.db {
+97
-1
src/database/post.v
+97
-1
src/database/post.v
···
1
1
module database
2
2
3
3
import time
4
-
import entity { Post, Like, LikeCache }
4
+
import entity { Post, User, Like, LikeCache }
5
+
6
+
// add_post adds a new post to the database, returns true if this succeeded and
7
+
// false otherwise.
8
+
pub fn (app &DatabaseAccess) add_post(post &Post) bool {
9
+
sql app.db {
10
+
insert post into Post
11
+
} or {
12
+
return false
13
+
}
14
+
return true
15
+
}
5
16
6
17
// get_post_by_id gets a post by its id, returns none if it does not exist.
7
18
pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post {
···
84
95
} or { [] }
85
96
return posts
86
97
}
98
+
99
+
// pin_post pins the given post, returns true if this succeeds and false
100
+
// otherwise.
101
+
pub fn (app &DatabaseAccess) pin_post(post_id int) bool {
102
+
sql app.db {
103
+
update Post set pinned = true where id == post_id
104
+
} or {
105
+
return false
106
+
}
107
+
return true
108
+
}
109
+
110
+
// update_post updates the given post's title and body with the given title and
111
+
// body, returns true if this succeeds and false otherwise.
112
+
pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string) bool {
113
+
sql app.db {
114
+
update Post set body = new_body, title = new_title where id == post_id
115
+
} or {
116
+
return false
117
+
}
118
+
return true
119
+
}
120
+
121
+
// delete_post deletes the given post and all likes associated with it, returns
122
+
// true if this succeeds and false otherwise.
123
+
pub fn (app &DatabaseAccess) delete_post(id int) bool {
124
+
sql app.db {
125
+
delete from Post where id == id
126
+
delete from Like where post_id == id
127
+
delete from LikeCache where post_id == id
128
+
} or {
129
+
return false
130
+
}
131
+
return true
132
+
}
133
+
134
+
////// searching //////
135
+
136
+
// PostSearchResult represents a search result for a post.
137
+
pub struct PostSearchResult {
138
+
pub mut:
139
+
post Post
140
+
author User
141
+
}
142
+
143
+
@[inline]
144
+
pub fn PostSearchResult.from_post(app &DatabaseAccess, post &Post) PostSearchResult {
145
+
return PostSearchResult{
146
+
post: post
147
+
author: app.get_user_by_id(post.author_id) or { app.get_unknown_user() }
148
+
}
149
+
}
150
+
151
+
@[inline]
152
+
pub fn PostSearchResult.from_post_list(app &DatabaseAccess, posts []Post) []PostSearchResult {
153
+
mut results := []PostSearchResult{
154
+
cap: posts.len,
155
+
len: posts.len
156
+
}
157
+
for index, post in posts {
158
+
results[index] = PostSearchResult.from_post(app, post)
159
+
}
160
+
return results
161
+
}
162
+
163
+
// search_for_posts searches for posts matching the given query.
164
+
// todo: query options/filters, such as user:beep, !excluded-text, etc
165
+
pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult {
166
+
sql_query := "\
167
+
SELECT *, CASE
168
+
WHEN title LIKE '%${query}%' THEN 1
169
+
WHEN body LIKE '%${query}%' THEN 2
170
+
END AS priority
171
+
FROM \"Post\"
172
+
WHERE title LIKE '%${query}%' OR body LIKE '%${query}%'
173
+
ORDER BY priority ASC LIMIT ${limit} OFFSET ${offset}"
174
+
175
+
queried_posts := app.db.q_strings(sql_query) or {
176
+
eprintln('search_for_posts error in app.db.q_strings: ${err}')
177
+
[]
178
+
}
179
+
180
+
posts := queried_posts.map(|it| Post.from_row(it))
181
+
return PostSearchResult.from_post_list(app, posts)
182
+
}
+11
src/database/site.v
+11
src/database/site.v
···
18
18
}
19
19
return configs[0]
20
20
}
21
+
22
+
// set_motd sets the site's current message of the day, returns true if this
23
+
// succeeds and false otherwise.
24
+
pub fn (app &DatabaseAccess) set_motd(motd string) bool {
25
+
sql app.db {
26
+
update Site set motd = motd where id == 1
27
+
} or {
28
+
return false
29
+
}
30
+
return true
31
+
}
+56
-1
src/database/user.v
+56
-1
src/database/user.v
···
1
1
module database
2
2
3
-
import entity { User, Notification, Like, Post }
3
+
import entity { User, Notification, Like, LikeCache, Post }
4
4
5
5
// new_user creates a new user and returns their struct after creation.
6
6
pub fn (app &DatabaseAccess) new_user(user User) ?User {
···
172
172
}
173
173
return likes.len == 1
174
174
}
175
+
176
+
// delete_user deletes the given user and their data, returns true if this
177
+
// succeeded and false otherwise.
178
+
pub fn (app &DatabaseAccess) delete_user(user_id int) bool {
179
+
sql app.db {
180
+
delete from User where id == user_id
181
+
delete from Like where user_id == user_id
182
+
delete from Notification where user_id == user_id
183
+
} or {
184
+
return false
185
+
}
186
+
187
+
// delete posts and their likes
188
+
posts_from_this_user := sql app.db {
189
+
select from Post where author_id == user_id
190
+
} or { [] }
191
+
192
+
for post in posts_from_this_user {
193
+
sql app.db {
194
+
delete from Like where post_id == post.id
195
+
delete from LikeCache where post_id == post.id
196
+
} or {
197
+
eprintln('failed to delete like cache for post during user deletion: ${post.id}')
198
+
}
199
+
}
200
+
201
+
sql app.db {
202
+
delete from Post where author_id == user_id
203
+
} or {
204
+
eprintln('failed to delete posts by deleting user: ${user_id}')
205
+
}
206
+
207
+
return true
208
+
}
209
+
210
+
// search_for_users searches for posts matching the given query.
211
+
// todo: query options/filters, such as created-after:<date>, created-before:<date>, etc
212
+
pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User {
213
+
sql_query := "\
214
+
SELECT *, CASE
215
+
WHEN username LIKE '%${query}%' THEN 1
216
+
WHEN nickname LIKE '%${query}%' THEN 2
217
+
END AS priority
218
+
FROM \"User\"
219
+
WHERE username LIKE '%${query}%' OR nickname LIKE '%${query}%'
220
+
ORDER BY priority ASC LIMIT ${limit} OFFSET ${offset}"
221
+
222
+
queried_users := app.db.q_strings(sql_query) or {
223
+
eprintln('search_for_users error in app.db.q_strings: ${err}')
224
+
[]
225
+
}
226
+
227
+
users := queried_users.map(|it| User.from_row(it))
228
+
return users
229
+
}
+23
src/entity/post.v
+23
src/entity/post.v
···
1
1
module entity
2
2
3
+
import db.pg
3
4
import time
5
+
import util
4
6
5
7
pub struct Post {
6
8
pub mut:
···
15
17
16
18
posted_at time.Time = time.now()
17
19
}
20
+
21
+
// Post.from_row creates a post object from the given database row.
22
+
// see src/database/post.v#search_for_posts for usage.
23
+
@[inline]
24
+
pub fn Post.from_row(row pg.Row) Post {
25
+
// this throws a cgen error when put in Post{}
26
+
//todo: report this
27
+
posted_at := time.parse(util.or_throw[string](row.vals[6])) or { panic(err) }
28
+
29
+
return Post{
30
+
id: util.or_throw[string](row.vals[0]).int()
31
+
author_id: util.or_throw[string](row.vals[1]).int()
32
+
replying_to: if row.vals[2] == none { ?int(none) } else {
33
+
util.map_or_throw[string, int](row.vals[2], |it| it.int())
34
+
}
35
+
title: util.or_throw[string](row.vals[3])
36
+
body: util.or_throw[string](row.vals[4])
37
+
pinned: util.map_or_throw[string, bool](row.vals[5], |it| it.bool())
38
+
posted_at: posted_at
39
+
}
40
+
}
+27
src/entity/user.v
+27
src/entity/user.v
···
1
1
module entity
2
2
3
+
import db.pg
3
4
import time
5
+
import util
4
6
5
7
pub struct User {
6
8
pub mut:
···
37
39
.replace(user.password, '*'.repeat(16))
38
40
.replace(user.password_salt, '*'.repeat(16))
39
41
}
42
+
43
+
// User.from_row creates a user object from the given database row.
44
+
// see src/database/user.v#search_for_users for usage.
45
+
@[inline]
46
+
pub fn User.from_row(row pg.Row) User {
47
+
// this throws a cgen error when put in User{}
48
+
//todo: report this
49
+
created_at := time.parse(util.or_throw[string](row.vals[10])) or { panic(err) }
50
+
51
+
return User{
52
+
id: util.or_throw[string](row.vals[0]).int()
53
+
username: util.or_throw[string](row.vals[1])
54
+
nickname: if row.vals[2] == none { ?string(none) } else {
55
+
util.or_throw[string](row.vals[2])
56
+
}
57
+
password: 'haha lol, nope'
58
+
password_salt: 'haha lol, nope'
59
+
muted: util.map_or_throw[string, bool](row.vals[5], |it| it.bool())
60
+
admin: util.map_or_throw[string, bool](row.vals[6], |it| it.bool())
61
+
theme: util.or_throw[string](row.vals[7])
62
+
bio: util.or_throw[string](row.vals[8])
63
+
pronouns: util.or_throw[string](row.vals[9])
64
+
created_at: created_at
65
+
}
66
+
}
+15
src/static/js/search.js
+15
src/static/js/search.js
···
1
+
const search_posts = async (query, limit, offset) => {
2
+
const data = await fetch(`/api/post/search?query=${query}&limit=${limit}&offset=${offset}`, {
3
+
method: 'GET'
4
+
})
5
+
const json = await data.json()
6
+
return json
7
+
}
8
+
9
+
const search_users = async (query, limit, offset) => {
10
+
const data = await fetch(`/api/user/search?query=${query}&limit=${limit}&offset=${offset}`, {
11
+
method: 'GET'
12
+
})
13
+
const json = await data.json()
14
+
return json
15
+
}
+1
src/static/js/user_utils.js
+1
src/static/js/user_utils.js
···
1
+
const get_display_name = user => user.nickname == undefined ? user.username : user.nickname
+5
src/templates/components/user_card_mini.html
+5
src/templates/components/user_card_mini.html
+5
-2
src/templates/partial/header.html
+5
-2
src/templates/partial/header.html
···
22
22
<body>
23
23
24
24
<header>
25
+
@if ctx.is_logged_in()
26
+
<a href="/me">@@@user.get_name()</a>
27
+
-
28
+
@end
29
+
25
30
@if app.config.dev_mode
26
31
<span><strong>dev mode</strong></span>
27
32
-
···
31
36
-
32
37
33
38
@if ctx.is_logged_in()
34
-
<a href="/me">profile</a>
35
-
-
36
39
<a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a>
37
40
@else
38
41
<a href="/login">log in</a>
+173
src/templates/search.html
+173
src/templates/search.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<script src="/static/js/user_utils.js"></script>
4
+
<script src="/static/js/search.js"></script>
5
+
6
+
<h1>search</h1>
7
+
8
+
<div>
9
+
<input type="text" name="query" id="query">
10
+
<div>
11
+
<p>search for:</p>
12
+
<input type="radio" name="search-for" id="search-for-posts" value="posts" checked aria-checked>
13
+
<label for="search-for-posts">posts</label>
14
+
<input type="radio" name="search-for" id="search-for-users" value="users">
15
+
<label for="search-for-users">users</label>
16
+
</div>
17
+
<br>
18
+
<button id="search">search</button>
19
+
</div>
20
+
21
+
<br>
22
+
23
+
<div id="pages">
24
+
</div>
25
+
26
+
<div id="results">
27
+
</div>
28
+
29
+
<script>
30
+
const params = new URLSearchParams(window.location.search)
31
+
32
+
const pages = document.getElementById('pages')
33
+
const results = document.getElementById('results')
34
+
35
+
const query = document.getElementById('query')
36
+
if (query.value == '' && params.get('q')) {
37
+
query.value = params.get('q')
38
+
}
39
+
40
+
let limit = params.get('limit')
41
+
if (!limit) {
42
+
limit = 10
43
+
}
44
+
45
+
let offset = params.get('offset')
46
+
if (!limit) {
47
+
offset = 0
48
+
}
49
+
50
+
const add_post_result = result => {
51
+
// same as components/post_mini.html except js
52
+
const element = document.createElement('div')
53
+
element.classList.add('post', 'post-mini')
54
+
const p = document.createElement('p')
55
+
56
+
const user_link = document.createElement('a')
57
+
user_link.href = '/user/' + result.author.username
58
+
const user_text = document.createElement('strong')
59
+
user_text.innerText = get_display_name(result.author)
60
+
user_link.appendChild(user_text)
61
+
p.appendChild(user_link)
62
+
63
+
p.innerHTML += ': '
64
+
65
+
const post_link = document.createElement('a')
66
+
post_link.href = '/post/' + result.post.id
67
+
post_link.innerText = result.post.title
68
+
p.appendChild(post_link)
69
+
70
+
element.appendChild(p)
71
+
results.appendChild(element)
72
+
}
73
+
74
+
const add_user_result = user => {
75
+
const element = document.createElement('div')
76
+
const p = document.createElement('p')
77
+
const user_link = document.createElement('a')
78
+
user_link.href = '/user/' + user.username
79
+
user_link.innerText = get_display_name(user)
80
+
p.appendChild(user_link)
81
+
element.appendChild(p)
82
+
results.appendChild(element)
83
+
}
84
+
85
+
const add_pages = () => {
86
+
// creates a separator
87
+
const sep = () => {
88
+
const span = document.createElement('span')
89
+
span.innerText = ' - '
90
+
pages.appendChild(span)
91
+
}
92
+
93
+
const first_link = document.createElement('a')
94
+
// we escape the $ here because otherwise V will try to perform replacements at compile-time.
95
+
//todo: report this, this behaviour should be changed or at least looked into further.
96
+
first_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=0'
97
+
first_link.innerText = '0'
98
+
pages.appendChild(first_link)
99
+
100
+
sep()
101
+
102
+
const back_link = document.createElement('a')
103
+
back_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + Math.min(0, offset - 10)
104
+
back_link.innerText = '<'
105
+
pages.appendChild(back_link)
106
+
107
+
sep()
108
+
109
+
const next_link = document.createElement('a')
110
+
next_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + (offset + 10)
111
+
next_link.innerText = '>'
112
+
pages.appendChild(next_link)
113
+
}
114
+
115
+
document.getElementById('search').addEventListener('click', async () => {
116
+
results.innerHTML = '' // yeet the children!
117
+
pages.innerHTML = '' // yeet more children!
118
+
119
+
var search_for
120
+
for (const radio of document.getElementsByName('search-for')) {
121
+
if (radio.checked) {
122
+
search_for = radio.value
123
+
break
124
+
}
125
+
}
126
+
if (search_for == undefined) {
127
+
alert('please select either "users" or "posts" to search for.')
128
+
return
129
+
}
130
+
131
+
console.log('search: ', query.value, limit, offset)
132
+
133
+
var search_results
134
+
if (search_for == 'users') {
135
+
search_results = await search_users(query.value, limit, offset)
136
+
} else if (search_for == 'posts') {
137
+
search_results = await search_posts(query.value, limit, offset)
138
+
} else {
139
+
// this should never happen
140
+
alert('something wrong occured while searching, please report this (01)')
141
+
return
142
+
}
143
+
144
+
console.log(search_results)
145
+
146
+
if (search_results.length >= 0) {
147
+
// i iterate inside the if statements so that i do not have to perform a redundant
148
+
// string comparison for every single result.
149
+
if (search_for == 'users') {
150
+
for (result of search_results) {
151
+
add_user_result(result)
152
+
}
153
+
} else if (search_for == 'posts') {
154
+
for (result of search_results) {
155
+
add_post_result(result)
156
+
}
157
+
} else {
158
+
// this should never happen
159
+
alert('something wrong occured while searching, please report this (02)')
160
+
return
161
+
}
162
+
163
+
// set up pagination, but only if we actually have pages to display
164
+
if (offset > 0) {
165
+
add_pages()
166
+
}
167
+
} else {
168
+
results.innerText = 'no results!'
169
+
}
170
+
})
171
+
</script>
172
+
173
+
@include 'partial/footer.html'
+21
src/util/none.v
+21
src/util/none.v
···
1
+
module util
2
+
3
+
@[inline]
4
+
pub fn map_or[T, R](val ?T, mapper fn (T) R, or_else R) R {
5
+
return if val == none { or_else } else { mapper(val) }
6
+
}
7
+
8
+
@[inline]
9
+
pub fn map_or_throw[T, R](val ?T, mapper fn (T) R) R {
10
+
return if val == none { panic('value was none: ${val}') } else { mapper(val) }
11
+
}
12
+
13
+
@[inline]
14
+
pub fn map_or_opt[T, R](val ?T, mapper fn (T) ?R, or_else ?R) ?R {
15
+
return if val == none { or_else } else { mapper(val) }
16
+
}
17
+
18
+
@[inline]
19
+
pub fn or_throw[T](val ?T) T {
20
+
return if val == none { panic('value was none: ${val}') } else { val }
21
+
}
+61
-78
src/webapp/api.v
+61
-78
src/webapp/api.v
···
3
3
import veb
4
4
import auth
5
5
import entity { Like, LikeCache, Post, Site, User, Notification }
6
+
import database { PostSearchResult }
7
+
8
+
// search_hard_limit is the maximum limit for a search query, used to prevent
9
+
// people from requesting searches with huge limits and straining the SQL server
10
+
pub const search_hard_limit := 50
6
11
7
12
////// user //////
8
13
···
329
334
330
335
@['/api/user/notification/clear']
331
336
fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result {
332
-
if !ctx.is_logged_in() {
337
+
user := app.whoami(mut ctx) or {
333
338
ctx.error('you are not logged in!')
334
339
return ctx.redirect('/login')
335
340
}
336
-
sql app.db {
337
-
delete from Notification where id == id
338
-
} or {
339
-
ctx.error('failed to delete notification')
340
-
return ctx.redirect('/inbox')
341
+
342
+
if notification := app.get_notification_by_id(id) {
343
+
if notification.user_id != user.id {
344
+
ctx.error('no such notification for user')
345
+
return ctx.redirect('/inbox')
346
+
} else {
347
+
if !app.delete_notification(id) {
348
+
ctx.error('failed to delete notification')
349
+
return ctx.redirect('/inbox')
350
+
}
351
+
}
352
+
} else {
353
+
ctx.error('no such notification for user')
341
354
}
355
+
342
356
return ctx.redirect('/inbox')
343
357
}
344
358
···
348
362
ctx.error('you are not logged in!')
349
363
return ctx.redirect('/login')
350
364
}
351
-
sql app.db {
352
-
delete from Notification where user_id == user.id
353
-
} or {
365
+
if !app.delete_notifications_for_user(user.id) {
354
366
ctx.error('failed to delete notifications')
355
367
return ctx.redirect('/inbox')
356
368
}
···
368
380
369
381
if user.admin || user.id == id {
370
382
// yeet
371
-
sql app.db {
372
-
delete from User where id == id
373
-
delete from Like where user_id == id
374
-
delete from Notification where user_id == id
375
-
} or {
383
+
if !app.delete_user(user.id) {
376
384
ctx.error('failed to delete user: ${id}')
377
385
return ctx.redirect('/')
378
386
}
379
387
380
-
// delete posts and their likes
381
-
posts_from_this_user := sql app.db {
382
-
select from Post where author_id == id
383
-
} or { [] }
384
-
385
-
for post in posts_from_this_user {
386
-
sql app.db {
387
-
delete from Like where post_id == post.id
388
-
delete from LikeCache where post_id == post.id
389
-
} or {
390
-
eprintln('failed to delete like cache for post during user deletion: ${post.id}')
391
-
}
392
-
}
393
-
394
-
sql app.db {
395
-
delete from Post where author_id == id
396
-
} or {
397
-
eprintln('failed to delete posts by deleting user: ${user.id}')
398
-
}
399
-
400
388
app.auth.delete_tokens_for_user(id) or {
401
389
eprintln('failed to delete tokens for user during deletion: ${id}')
402
390
}
···
418
406
return ctx.redirect('/')
419
407
}
420
408
409
+
@['/api/user/search'; get]
410
+
fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result {
411
+
if limit >= search_hard_limit {
412
+
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
413
+
}
414
+
users := app.search_for_users(query, limit, offset)
415
+
return ctx.json[[]User](users)
416
+
}
417
+
421
418
////// post //////
422
419
423
420
@['/api/post/new_post'; post]
···
459
456
post.replying_to = replying_to
460
457
}
461
458
462
-
sql app.db {
463
-
insert post into Post
464
-
} or {
459
+
if !app.add_post(post) {
465
460
ctx.error('failed to post!')
466
461
println('failed to post: ${post} from user ${user.id}')
467
462
return ctx.redirect('/post/new')
···
490
485
}
491
486
492
487
if user.admin || user.id == post.author_id {
493
-
sql app.db {
494
-
delete from Post where id == id
495
-
delete from Like where post_id == id
496
-
} or {
488
+
if !app.delete_post(post.id) {
497
489
ctx.error('failed to delete post')
498
490
eprintln('failed to delete post: ${id}')
499
491
return ctx.redirect('/')
···
514
506
post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
515
507
516
508
if app.does_user_like_post(user.id, post.id) {
517
-
sql app.db {
518
-
delete from Like where user_id == user.id && post_id == post.id
519
-
// yeet the old cached like value
520
-
delete from LikeCache where post_id == post.id
521
-
} or {
509
+
if !app.unlike_post(post.id, user.id) {
522
510
eprintln('user ${user.id} failed to unlike post ${id}')
523
511
return ctx.server_error('failed to unlike post')
524
512
}
···
526
514
} else {
527
515
// remove the old dislike, if it exists
528
516
if app.does_user_dislike_post(user.id, post.id) {
529
-
sql app.db {
530
-
delete from Like where user_id == user.id && post_id == post.id
531
-
} or {
517
+
if !app.unlike_post(post.id, user.id) {
532
518
eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it')
533
519
}
534
520
}
···
538
524
post_id: post.id
539
525
is_like: true
540
526
}
541
-
sql app.db {
542
-
insert like into Like
543
-
// yeet the old cached like value
544
-
delete from LikeCache where post_id == post.id
545
-
} or {
527
+
if !app.add_like(like) {
546
528
eprintln('user ${user.id} failed to like post ${id}')
547
529
return ctx.server_error('failed to like post')
548
530
}
···
557
539
post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
558
540
559
541
if app.does_user_dislike_post(user.id, post.id) {
560
-
sql app.db {
561
-
delete from Like where user_id == user.id && post_id == post.id
562
-
// yeet the old cached like value
563
-
delete from LikeCache where post_id == post.id
564
-
} or {
565
-
eprintln('user ${user.id} failed to unlike post ${id}')
566
-
return ctx.server_error('failed to unlike post')
542
+
if !app.unlike_post(post.id, user.id) {
543
+
eprintln('user ${user.id} failed to undislike post ${id}')
544
+
return ctx.server_error('failed to undislike post')
567
545
}
568
546
return ctx.ok('undisliked post')
569
547
} else {
570
548
// remove the old like, if it exists
571
549
if app.does_user_like_post(user.id, post.id) {
572
-
sql app.db {
573
-
delete from Like where user_id == user.id && post_id == post.id
574
-
} or {
550
+
if !app.unlike_post(post.id, user.id) {
575
551
eprintln('user ${user.id} failed to remove like on post ${id} when disliking it')
576
552
}
577
553
}
···
581
557
post_id: post.id
582
558
is_like: false
583
559
}
584
-
sql app.db {
585
-
insert like into Like
586
-
// yeet the old cached like value
587
-
delete from LikeCache where post_id == post.id
588
-
} or {
560
+
if !app.add_like(like) {
589
561
eprintln('user ${user.id} failed to dislike post ${id}')
590
562
return ctx.server_error('failed to dislike post')
591
563
}
···
614
586
return ctx.redirect('/')
615
587
}
616
588
617
-
sql app.db {
618
-
update Post set body = body, title = title where id == id
619
-
} or {
589
+
if !app.update_post(id, title, body) {
620
590
eprintln('failed to update post')
621
591
ctx.error('failed to update post')
622
592
return ctx.redirect('/')
···
633
603
}
634
604
635
605
if user.admin {
636
-
sql app.db {
637
-
update Post set pinned = true where id == id
638
-
} or {
606
+
if !app.pin_post(id) {
639
607
eprintln('failed to pin post: ${id}')
640
608
ctx.error('failed to pin post')
641
609
return ctx.redirect('/post/${id}')
···
648
616
}
649
617
}
650
618
619
+
@['/api/post/get/<id>'; get]
620
+
fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
621
+
post := app.get_post_by_id(id) or {
622
+
return ctx.text('no such post')
623
+
}
624
+
return ctx.json[Post](post)
625
+
}
626
+
627
+
@['/api/post/search'; get]
628
+
fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result {
629
+
if limit >= search_hard_limit {
630
+
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
631
+
}
632
+
posts := app.search_for_posts(query, limit, offset)
633
+
return ctx.json[[]PostSearchResult](posts)
634
+
}
635
+
651
636
////// site //////
652
637
653
638
@['/api/site/set_motd'; post]
···
658
643
}
659
644
660
645
if user.admin {
661
-
sql app.db {
662
-
update Site set motd = motd where id == 1
663
-
} or {
646
+
if !app.set_motd(motd) {
664
647
ctx.error('failed to set motd')
665
648
eprintln('failed to set motd: ${motd}')
666
649
return ctx.redirect('/')
-14
src/webapp/app.v
-14
src/webapp/app.v
···
71
71
}
72
72
}
73
73
74
-
// get_unknown_user returns a user representing an unknown user
75
-
pub fn (app &App) get_unknown_user() User {
76
-
return User{
77
-
username: 'unknown'
78
-
}
79
-
}
80
-
81
-
// get_unknown_post returns a post representing an unknown post
82
-
pub fn (app &App) get_unknown_post() Post {
83
-
return Post{
84
-
title: 'unknown'
85
-
}
86
-
}
87
-
88
74
// logged_in_as returns true if the user is logged in as the provided user id.
89
75
pub fn (app &App) logged_in_as(mut ctx Context, id int) bool {
90
76
if !ctx.is_logged_in() {
+10
src/webapp/pages.v
+10
src/webapp/pages.v
···
173
173
ctx.title = '${app.config.instance.name} - #${tag}'
174
174
return $veb.html('../templates/tag.html')
175
175
}
176
+
177
+
@['/search']
178
+
fn (mut app App) search(mut ctx Context, q string, offset int) veb.Result {
179
+
user := app.whoami(mut ctx) or {
180
+
ctx.error('not logged in')
181
+
return ctx.redirect('/login')
182
+
}
183
+
ctx.title = '${app.config.instance.name} - search'
184
+
return $veb.html('../templates/search.html')
185
+
}