a mini social media app for small communities

document almost all api functions and minor refactors

Changed files
+78 -66
src
+2 -1
src/database/database.v
··· 1 module database 2 3 import db.pg 4 5 - // all interactions with the database should be handled through this struct. 6 pub struct DatabaseAccess { 7 pub mut: 8 db pg.DB
··· 1 + // **all** interactions with the database should be handled in this module. 2 module database 3 4 import db.pg 5 6 + // DatabaseAccess handles all interactions with the database. 7 pub struct DatabaseAccess { 8 pub mut: 9 db pg.DB
+1 -1
src/database/like.v
··· 2 3 import entity { Like, LikeCache } 4 5 - // returns the net likes of the given post 6 pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 7 // check cache 8 cache := sql app.db {
··· 2 3 import entity { Like, LikeCache } 4 5 + // get_net_likes_for_post returns the net likes of the given post. 6 pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 7 // check cache 8 cache := sql app.db {
+4 -3
src/database/notification.v
··· 2 3 import entity { Notification } 4 5 - // get a list of notifications for the given user 6 pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification { 7 notifications := sql app.db { 8 select from Notification where user_id == user_id ··· 10 return notifications 11 } 12 13 - // get the amount of notifications a user has, with a given limit 14 pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 15 notifications := sql app.db { 16 select from Notification where user_id == user_id limit limit ··· 18 return notifications.len 19 } 20 21 - // send a notification to the given user 22 pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) { 23 notification := Notification{ 24 user_id: user_id
··· 2 3 import entity { Notification } 4 5 + // get_notifications_for gets a list of notifications for the given user. 6 pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification { 7 notifications := sql app.db { 8 select from Notification where user_id == user_id ··· 10 return notifications 11 } 12 13 + // get_notification_count gets the amount of notifications a user has, with a 14 + // given limit. 15 pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 16 notifications := sql app.db { 17 select from Notification where user_id == user_id limit limit ··· 19 return notifications.len 20 } 21 22 + // send_notification_to sends a notification to the given user. 23 pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) { 24 notification := Notification{ 25 user_id: user_id
+11 -8
src/database/post.v
··· 3 import time 4 import entity { Post, Like, LikeCache } 5 6 - // get a post by its id, returns none if it does not exist 7 pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post { 8 posts := sql app.db { 9 select from Post where id == id limit 1 ··· 14 return posts[0] 15 } 16 17 - // get a post by its author and timestamp, returns none if it does not exist 18 pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 19 posts := sql app.db { 20 select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 ··· 25 return posts[0] 26 } 27 28 - // get a list of posts given a tag. this performs sql string operations and 29 - // probably is not very efficient, use sparingly. 30 pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post { 31 posts := sql app.db { 32 select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset ··· 34 return posts 35 } 36 37 - // returns a list of all pinned posts 38 pub fn (app &DatabaseAccess) get_pinned_posts() []Post { 39 posts := sql app.db { 40 select from Post where pinned == true ··· 42 return posts 43 } 44 45 - // returns a list of the ten most recent posts. 46 pub fn (app &DatabaseAccess) get_recent_posts() []Post { 47 posts := sql app.db { 48 select from Post order by posted_at desc limit 10 ··· 50 return posts 51 } 52 53 - // returns a list of the ten most liked posts. 54 // TODO: make this time-gated (i.e, top ten liked posts of the day) 55 pub fn (app &DatabaseAccess) get_popular_posts() []Post { 56 cached_likes := sql app.db { ··· 65 return posts 66 } 67 68 - // returns a list of all posts from a user in descending order of date 69 pub fn (app &DatabaseAccess) get_posts_from_user(user_id int) []Post { 70 posts := sql app.db { 71 select from Post where author_id == user_id order by posted_at desc
··· 3 import time 4 import entity { Post, Like, LikeCache } 5 6 + // get_post_by_id gets a post by its id, returns none if it does not exist. 7 pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post { 8 posts := sql app.db { 9 select from Post where id == id limit 1 ··· 14 return posts[0] 15 } 16 17 + // get_post_by_author_and_timestamp gets a post by its author and timestamp, 18 + // returns none if it does not exist 19 pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 20 posts := sql app.db { 21 select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 ··· 26 return posts[0] 27 } 28 29 + // get_posts_with_tag gets a list of the 10 most recent posts with the given tag. 30 + // this performs sql string operations and probably is not very efficient, use 31 + // sparingly. 32 pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post { 33 posts := sql app.db { 34 select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset ··· 36 return posts 37 } 38 39 + // get_pinned_posts returns a list of all pinned posts. 40 pub fn (app &DatabaseAccess) get_pinned_posts() []Post { 41 posts := sql app.db { 42 select from Post where pinned == true ··· 44 return posts 45 } 46 47 + // get_recent_posts returns a list of the ten most recent posts. 48 pub fn (app &DatabaseAccess) get_recent_posts() []Post { 49 posts := sql app.db { 50 select from Post order by posted_at desc limit 10 ··· 52 return posts 53 } 54 55 + // get_popular_posts returns a list of the ten most liked posts. 56 // TODO: make this time-gated (i.e, top ten liked posts of the day) 57 pub fn (app &DatabaseAccess) get_popular_posts() []Post { 58 cached_likes := sql app.db { ··· 67 return posts 68 } 69 70 + // get_posts_from_user returns a list of all posts from a user in descending 71 + // order by posting date. 72 pub fn (app &DatabaseAccess) get_posts_from_user(user_id int) []Post { 73 posts := sql app.db { 74 select from Post where author_id == user_id order by posted_at desc
+23 -20
src/database/user.v
··· 2 3 import entity { User, Notification, Like, Post } 4 5 - // creates a new user and returns their struct after creation. 6 pub fn (app &DatabaseAccess) new_user(user User) ?User { 7 sql app.db { 8 insert user into User ··· 16 return app.get_user_by_name(user.username) 17 } 18 19 - // updates the given user's username, returns true if this succeeded and false 20 - // otherwise. 21 pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool { 22 sql app.db { 23 update User set username = new_username where id == user_id ··· 28 return true 29 } 30 31 - // updates the given user's password, returns true if this succeeded and false 32 - // otherwise. 33 pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool { 34 sql app.db { 35 update User set password = hashed_new_password where id == user_id ··· 40 return true 41 } 42 43 - // updates the given user's nickname, returns true if this succeeded and false 44 - // otherwise. 45 pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool { 46 sql app.db { 47 update User set nickname = new_nickname where id == user_id ··· 52 return true 53 } 54 55 - // updates the given user's muted status, returns true if this succeeded and 56 - // false otherwise. 57 pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool { 58 sql app.db { 59 update User set muted = muted where id == user_id ··· 64 return true 65 } 66 67 - // updates the given user's theme url, returns true if this succeeded and false 68 - // otherwise. 69 pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { 70 sql app.db { 71 update User set theme = theme where id == user_id ··· 76 return true 77 } 78 79 - // updates the given user's pronouns, returns true if this succeeded and false 80 - // otherwise. 81 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { 82 sql app.db { 83 update User set pronouns = pronouns where id == user_id ··· 88 return true 89 } 90 91 - // updates the given user's bio, returns true if this succeeded and false 92 // otherwise. 93 pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool { 94 sql app.db { ··· 100 return true 101 } 102 103 - // get a user by their username, returns none if the user was not found. 104 pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User { 105 users := sql app.db { 106 select from User where username == username ··· 111 return users[0] 112 } 113 114 - // get a user by their id, returns none if the user was not found. 115 pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User { 116 users := sql app.db { 117 select from User where id == id ··· 122 return users[0] 123 } 124 125 - // returns all users 126 pub fn (app &DatabaseAccess) get_users() []User { 127 users := sql app.db { 128 select from User ··· 130 return users 131 } 132 133 - // returns true if a user likes the given post 134 pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 135 likes := sql app.db { 136 select from Like where user_id == user_id && post_id == post_id ··· 144 return likes.first().is_like 145 } 146 147 - // returns true if a user dislikes the given post 148 pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 149 likes := sql app.db { 150 select from Like where user_id == user_id && post_id == post_id ··· 158 return !likes.first().is_like 159 } 160 161 - // returns true if a user likes or dislikes the given post 162 pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 163 likes := sql app.db { 164 select from Like where user_id == user_id && post_id == post_id
··· 2 3 import entity { User, Notification, Like, Post } 4 5 + // new_user creates a new user and returns their struct after creation. 6 pub fn (app &DatabaseAccess) new_user(user User) ?User { 7 sql app.db { 8 insert user into User ··· 16 return app.get_user_by_name(user.username) 17 } 18 19 + // set_username sets the given user's username, returns true if this succeeded 20 + // and false otherwise. 21 pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool { 22 sql app.db { 23 update User set username = new_username where id == user_id ··· 28 return true 29 } 30 31 + // set_password sets the given user's password, returns true if this succeeded 32 + // and false otherwise. 33 pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool { 34 sql app.db { 35 update User set password = hashed_new_password where id == user_id ··· 40 return true 41 } 42 43 + // set_nickname sets the given user's nickname, returns true if this succeeded 44 + // and false otherwise. 45 pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool { 46 sql app.db { 47 update User set nickname = new_nickname where id == user_id ··· 52 return true 53 } 54 55 + // set_muted sets the given user's muted status, returns true if this succeeded 56 + // and false otherwise. 57 pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool { 58 sql app.db { 59 update User set muted = muted where id == user_id ··· 64 return true 65 } 66 67 + // set_theme sets the given user's theme url, returns true if this succeeded and 68 + // false otherwise. 69 pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { 70 sql app.db { 71 update User set theme = theme where id == user_id ··· 76 return true 77 } 78 79 + // set_pronouns sets the given user's pronouns, returns true if this succeeded 80 + // and false otherwise. 81 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { 82 sql app.db { 83 update User set pronouns = pronouns where id == user_id ··· 88 return true 89 } 90 91 + // set_bio sets the given user's bio, returns true if this succeeded and false 92 // otherwise. 93 pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool { 94 sql app.db { ··· 100 return true 101 } 102 103 + // get_user_by_name gets a user by their username, returns none if the user was 104 + // not found. 105 pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User { 106 users := sql app.db { 107 select from User where username == username ··· 112 return users[0] 113 } 114 115 + // get_user_by_id gets a user by their id, returns none if the user was not 116 + // found. 117 pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User { 118 users := sql app.db { 119 select from User where id == id ··· 124 return users[0] 125 } 126 127 + // get_users returns all users. 128 pub fn (app &DatabaseAccess) get_users() []User { 129 users := sql app.db { 130 select from User ··· 132 return users 133 } 134 135 + // does_user_like_post returns true if a user likes the given post. 136 pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 137 likes := sql app.db { 138 select from Like where user_id == user_id && post_id == post_id ··· 146 return likes.first().is_like 147 } 148 149 + // does_user_dislike_post returns true if a user dislikes the given post. 150 pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 151 likes := sql app.db { 152 select from Like where user_id == user_id && post_id == post_id ··· 160 return !likes.first().is_like 161 } 162 163 + // does_user_like_or_dislike_post returns true if a user likes *or* dislikes the 164 + // given post. 165 pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 166 likes := sql app.db { 167 select from Like where user_id == user_id && post_id == post_id
+2 -2
src/entity/likes.v
··· 1 module entity 2 3 - // stores like information for posts 4 pub struct Like { 5 pub mut: 6 id int @[primary; sql: serial] ··· 9 is_like bool 10 } 11 12 - // Stores total likes per post 13 pub struct LikeCache { 14 pub mut: 15 id int @[primary; sql: serial]
··· 1 module entity 2 3 + // Like stores like information for a post. 4 pub struct Like { 5 pub mut: 6 id int @[primary; sql: serial] ··· 9 is_like bool 10 } 11 12 + // LikeCache stores the total likes for a post. 13 pub struct LikeCache { 14 pub mut: 15 id int @[primary; sql: serial]
+1
src/entity/site.v
··· 1 module entity 2 3 pub struct Site { 4 pub mut: 5 id int @[primary; sql: serial]
··· 1 module entity 2 3 + // Site stores mutable site-wide config and data. 4 pub struct Site { 5 pub mut: 6 id int @[primary; sql: serial]
+5 -6
src/entity/user.v
··· 14 muted bool 15 admin bool 16 17 - theme ?string 18 19 bio string 20 pronouns string ··· 22 created_at time.Time = time.now() 23 } 24 25 @[inline] 26 pub fn (user User) get_name() string { 27 return user.nickname or { user.username } 28 } 29 30 - @[inline] 31 - pub fn (user User) get_theme() string { 32 - return user.theme or { '' } 33 - } 34 - 35 @[inline] 36 pub fn (user User) to_str_without_sensitive_data() string { 37 return user.str()
··· 14 muted bool 15 admin bool 16 17 + theme string 18 19 bio string 20 pronouns string ··· 22 created_at time.Time = time.now() 23 } 24 25 + // get_name returns the user's nickname if it is not none, if so then their 26 + // username is returned. 27 @[inline] 28 pub fn (user User) get_name() string { 29 return user.nickname or { user.username } 30 } 31 32 + // to_str_without_sensitive_data returns the stringified data for the user with 33 + // their password and salt censored. 34 @[inline] 35 pub fn (user User) to_str_without_sensitive_data() string { 36 return user.str()
+8 -12
src/main.v
··· 7 import os 8 import webapp { App, Context, StringValidator } 9 10 - fn init_db(db pg.DB) ! { 11 - sql db { 12 - create table entity.Site 13 - create table entity.User 14 - create table entity.Post 15 - create table entity.Like 16 - create table entity.LikeCache 17 - create table entity.Notification 18 - }! 19 - } 20 - 21 fn main() { 22 config := webapp.load_config_from(os.args[1]) 23 ··· 54 app.mount_static_folder_at(app.config.static_path, '/static')! 55 56 println('-> initializing database...') 57 - init_db(db)! 58 println('<- done') 59 60 // make the website config, if it does not exist
··· 7 import os 8 import webapp { App, Context, StringValidator } 9 10 fn main() { 11 config := webapp.load_config_from(os.args[1]) 12 ··· 43 app.mount_static_folder_at(app.config.static_path, '/static')! 44 45 println('-> initializing database...') 46 + sql db { 47 + create table entity.Site 48 + create table entity.User 49 + create table entity.Post 50 + create table entity.Like 51 + create table entity.LikeCache 52 + create table entity.Notification 53 + }! 54 println('<- done') 55 56 // make the website config, if it does not exist
+2 -2
src/templates/partial/header.html
··· 10 11 @include 'assets/style.html' 12 13 - @if ctx.is_logged_in() && user.theme != none 14 - <link rel="stylesheet" href="@user.get_theme()"> 15 @else if app.config.instance.default_theme != '' 16 <link rel="stylesheet" href="@app.config.instance.default_theme"> 17 @endif
··· 10 11 @include 'assets/style.html' 12 13 + @if ctx.is_logged_in() && user.theme != '' 14 + <link rel="stylesheet" href="@user.theme"> 15 @else if app.config.instance.default_theme != '' 16 <link rel="stylesheet" href="@app.config.instance.default_theme"> 17 @endif
+1 -1
src/templates/settings.html
··· 62 63 <form action="/api/user/set_theme" method="post"> 64 <label for="url">theme:</label> 65 - <input type="url" name="url" id="url" value="@{user.theme or { '' }}"> 66 <input type="submit" value="save"> 67 </form> 68 @end
··· 62 63 <form action="/api/user/set_theme" method="post"> 64 <label for="url">theme:</label> 65 + <input type="url" name="url" id="url" value="@user.theme"> 66 <input type="submit" value="save"> 67 </form> 68 @end
+12 -9
src/webapp/app.v
··· 26 } 27 } 28 29 - // get a user by their token, returns none if the user was not found. 30 pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 31 user_token := app.auth.find_token(token, ctx.ip()) or { 32 eprintln('no such user corresponding to token') ··· 35 return app.get_user_by_id(user_token.user_id) 36 } 37 38 - // returns the current logged in user, or none if the user is not logged in. 39 pub fn (app &App) whoami(mut ctx Context) ?User { 40 token := ctx.get_cookie('token') or { return none }.trim_space() 41 if token == '' { ··· 69 } 70 } 71 72 - // get a user representing an unknown user 73 pub fn (app &App) get_unknown_user() User { 74 return User{ 75 username: 'unknown' 76 } 77 } 78 79 - // get a post representing an unknown post 80 pub fn (app &App) get_unknown_post() Post { 81 return Post{ 82 title: 'unknown' 83 } 84 } 85 86 - // returns true if the user is logged in as the provided user id. 87 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 88 if !ctx.is_logged_in() { 89 return false ··· 91 return app.whoami(mut ctx) or { return false }.id == id 92 } 93 94 - // get the site's message of the day. 95 @[inline] 96 pub fn (app &App) get_motd() string { 97 site := app.get_or_create_site_config() 98 return site.motd 99 } 100 101 - // get the notification count for a given user, formatted for usage on the 102 - // frontend. 103 pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 104 count := app.get_notification_count(user_id, limit) 105 if count == 0 { ··· 111 } 112 } 113 114 - // processes a post's body to send notifications for mentions or replies. 115 pub fn (app &App) process_post_mentions(post &Post) { 116 author := app.get_user_by_id(post.author_id) or { 117 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
··· 26 } 27 } 28 29 + // get_user_by_token returns a user by their token, returns none if the user was 30 + // not found. 31 pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 32 user_token := app.auth.find_token(token, ctx.ip()) or { 33 eprintln('no such user corresponding to token') ··· 36 return app.get_user_by_id(user_token.user_id) 37 } 38 39 + // whoami returns the current logged in user, or none if the user is not logged 40 + // in. 41 pub fn (app &App) whoami(mut ctx Context) ?User { 42 token := ctx.get_cookie('token') or { return none }.trim_space() 43 if token == '' { ··· 71 } 72 } 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 + // logged_in_as returns true if the user is logged in as the provided user id. 89 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 90 if !ctx.is_logged_in() { 91 return false ··· 93 return app.whoami(mut ctx) or { return false }.id == id 94 } 95 96 + // get_motd returns the site's message of the day. 97 @[inline] 98 pub fn (app &App) get_motd() string { 99 site := app.get_or_create_site_config() 100 return site.motd 101 } 102 103 + // get_notification_count_for_frontend returns the notification count for a 104 + // given user, formatted for usage on the frontend. 105 pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 106 count := app.get_notification_count(user_id, limit) 107 if count == 0 { ··· 113 } 114 } 115 116 + // process_post_mentions parses a post's body to send notifications for mentions 117 + // or replies. 118 pub fn (app &App) process_post_mentions(post &Post) { 119 author := app.get_user_by_id(post.author_id) or { 120 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
+1
src/webapp/config.v
··· 2 3 import emmathemartian.maple 4 5 pub struct Config { 6 pub mut: 7 dev_mode bool
··· 2 3 import emmathemartian.maple 4 5 + // Config stores constant site-wide configuration data. 6 pub struct Config { 7 pub mut: 8 dev_mode bool
+5 -1
src/webapp/validation.v
··· 2 3 import regex 4 5 - // handles validation of user-input fields 6 pub struct StringValidator { 7 pub: 8 min_len int ··· 10 pattern regex.RE 11 } 12 13 @[inline] 14 pub fn (validator StringValidator) validate(str string) bool { 15 return str.len > validator.min_len && str.len < validator.max_len 16 && validator.pattern.matches_string(str) 17 } 18 19 pub fn StringValidator.new(min int, max int, pattern string) StringValidator { 20 mut re := regex.new() 21 re.compile_opt(pattern) or { panic(err) }
··· 2 3 import regex 4 5 + // StringValidator handles validation of user-input fields. 6 pub struct StringValidator { 7 pub: 8 min_len int ··· 10 pattern regex.RE 11 } 12 13 + // validate validates a given string and returns true if it succeeded and false 14 + // otherwise. 15 @[inline] 16 pub fn (validator StringValidator) validate(str string) bool { 17 return str.len > validator.min_len && str.len < validator.max_len 18 && validator.pattern.matches_string(str) 19 } 20 21 + // StringValidator.new creates a new StringValidator with the given min, max, 22 + // and pattern. 23 pub fn StringValidator.new(min int, max int, pattern string) StringValidator { 24 mut re := regex.new() 25 re.compile_opt(pattern) or { panic(err) }