a mini social media app for small communities

Merge pull request #2 from EmmaTheMartian/feature/search

feature/search

authored by Emma and committed by GitHub 64607c1e 7749b5a6

Changed files
+608 -102
doc
src
+4
doc/resources.md
··· 9 9 ## database design 10 10 11 11 - https://stackoverflow.com/questions/59505855/liked-posts-design-specifics 12 + 13 + ## sql 14 + 15 + - https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + const get_display_name = user => user.nickname == undefined ? user.username : user.nickname
+5
src/templates/components/user_card_mini.html
··· 1 + <div class="user-card user-card-mini"> 2 + <p> 3 + <a href="/user/@{user.username}">@{user.get_name()}</a> 4 + </p> 5 + </div>
+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
··· 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
··· 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
··· 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
··· 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
··· 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 + }