module webapp import veb import auth import entity { Like, Post, User } import database { PostSearchResult } import net.http import json // search_hard_limit is the maximum limit for a search query, used to prevent // people from requesting searches with huge limits and straining the SQL server pub const search_hard_limit = 50 pub const not_logged_in_msg = 'you are not logged in!' ////// user ////// struct HcaptchaResponse { pub: success bool error_codes []string @[json: 'error-codes'] } @['/api/user/register'; post] fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { // before doing *anything*, check the captchas if app.config.hcaptcha.enabled { token := ctx.form['h-captcha-response'] response := http.post_form('https://api.hcaptcha.com/siteverify', { 'secret': app.config.hcaptcha.secret 'remoteip': ctx.ip() 'response': token }) or { return ctx.server_error('failed to post hcaptcha response: ${err}') } data := json.decode(HcaptchaResponse, response.body) or { return ctx.server_error('failed to decode hcaptcha response: ${err}') } if !data.success { return ctx.server_error('failed to verify hcaptcha: ${data}') } } if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { return ctx.server_error('invalid invite code') } if app.get_user_by_name(username) != none { return ctx.server_error('username taken') } // validate username if !app.validators.username.validate(username) { return ctx.server_error('invalid username') } // validate password if !app.validators.password.validate(password) { return ctx.server_error('invalid password') } if password != ctx.form['confirm-password'] { return ctx.server_error('passwords do not match') } salt := auth.generate_salt() mut user := User{ username: username password: auth.hash_password_with_salt(password, salt) password_salt: salt } if app.config.instance.default_theme != '' { user.theme = app.config.instance.default_theme } if x := app.new_user(user) { app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), app.config.welcome.body.replace('%s', x.get_name())) token := app.auth.add_token(x.id) or { eprintln('api_user_register: could not create token for user with id ${x.id}: ${err}') return ctx.server_error('could not create token for user') } ctx.set_cookie( name: 'token' value: token same_site: .same_site_none_mode secure: true path: '/' ) } else { eprintln('api_user_register: could not log into newly-created user: ${user}') return ctx.server_error('could not log into newly-created user.') } return ctx.ok('user registered') } @['/api/user/set_username'; post] fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if app.get_user_by_name(new_username) != none { return ctx.server_error('username taken') } // validate username if !app.validators.username.validate(new_username) { return ctx.server_error('invalid username') } if !app.set_username(user.id, new_username) { return ctx.server_error('failed to update username') } return ctx.ok('username updated') } @['/api/user/set_password'; post] fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { return ctx.server_error('current_password is incorrect') } // validate password if !app.validators.password.validate(new_password) { return ctx.server_error('invalid password') } if new_password != ctx.form['confirm_password'] { return ctx.server_error('passwords do not match') } hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) if !app.set_password(user.id, hashed_new_password) { return ctx.server_error('failed to update password') } // invalidate tokens and log out app.auth.delete_tokens_for_user(user.id) or { eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') return ctx.server_error('failed to delete tokens during password deletion') } ctx.set_cookie( name: 'token' value: '' same_site: .same_site_none_mode secure: true path: '/' ) return ctx.ok('password updated') } @['/api/user/login'; post] fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { user := app.get_user_by_name(username) or { return ctx.server_error('invalid credentials') } if !auth.compare_password_with_hash(password, user.password_salt, user.password) { return ctx.server_error('invalid credentials') } token := app.auth.add_token(user.id) or { eprintln('failed to add token on log in: ${err}') return ctx.server_error('could not create token for user with id ${user.id}') } ctx.set_cookie( name: 'token' value: token same_site: .same_site_none_mode secure: true path: '/' ) return ctx.ok('logged in') } @['/api/user/logout'; post] fn (mut app App) api_user_logout(mut ctx Context) veb.Result { if token := ctx.get_cookie('token') { if user := app.get_user_by_token(token) { // app.auth.delete_tokens_for_ip(ctx.ip()) or { // eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') // return ctx.redirect('/login') // } app.auth.delete_tokens_for_value(token) or { eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') } } else { eprintln('failed to get user for token for logout') } } else { eprintln('failed to get token cookie for logout') } ctx.set_cookie( name: 'token' value: '' same_site: .same_site_none_mode secure: true path: '/' ) return ctx.ok('logged out') } @['/api/user/full_logout'; post] fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { if token := ctx.get_cookie('token') { if user := app.get_user_by_token(token) { app.auth.delete_tokens_for_user(user.id) or { eprintln('failed to yeet tokens for ${user.id}') } } else { eprintln('failed to get user for token for full_logout') } } else { eprintln('failed to get token cookie for full_logout') } ctx.set_cookie( name: 'token' value: '' same_site: .same_site_none_mode secure: true path: '/' ) return ctx.ok('logged out') } @['/api/user/set_nickname'; post] fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } mut clean_nickname := ?string(nickname.trim_space()) if clean_nickname or { '' } == '' { clean_nickname = none } // validate if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { return ctx.server_error('invalid nickname') } if !app.set_nickname(user.id, clean_nickname) { eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') return ctx.server_error('failed to update nickname') } return ctx.ok('updated nickname') } @['/api/user/set_muted'; post] fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } to_mute := app.get_user_by_id(id) or { return ctx.server_error('no such user') } if user.admin { if !app.set_muted(to_mute.id, muted) { return ctx.server_error('failed to change mute status') } return ctx.ok('muted user') } else { eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') return ctx.unauthorized('insufficient permissions') } } @['/api/user/set_automated'; post] fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if !app.set_automated(user.id, is_automated) { return ctx.server_error('failed to set automated status.') } if is_automated { return ctx.ok('you\'re now a bot! :D') } else { return ctx.ok('you\'re no longer a bot :(') } } @['/api/user/set_theme'; post] fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { if !app.config.instance.allow_changing_theme { return ctx.server_error('this instance disallows changing themes :(') } user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } mut theme := ?string(none) if url.trim_space() == '' { theme = app.config.instance.default_theme } else { theme = url.trim_space() } if !app.set_theme(user.id, theme) { return ctx.server_error('failed to change theme') } return ctx.ok('theme updated') } @['/api/user/set_css'; post] fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { if !app.config.instance.allow_changing_theme { return ctx.server_error('this instance disallows changing themes :(') } user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() } if !app.set_css(user.id, c) { return ctx.server_error('failed to change css') } return ctx.ok('css updated') } @['/api/user/set_pronouns'; post] fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } clean_pronouns := pronouns.trim_space() if !app.validators.pronouns.validate(clean_pronouns) { return ctx.server_error('invalid pronouns') } if !app.set_pronouns(user.id, clean_pronouns) { return ctx.server_error('failed to change pronouns') } return ctx.ok('pronouns updated') } @['/api/user/set_bio'; post] fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } clean_bio := bio.trim_space() if !app.validators.user_bio.validate(clean_bio) { return ctx.server_error('invalid bio') } if !app.set_bio(user.id, clean_bio) { eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') return ctx.server_error('failed to update bio') } return ctx.ok('bio updated') } @['/api/user/get_name'; get] fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { if !app.config.instance.public_data { _ := app.whoami(mut ctx) or { return ctx.unauthorized('no such user') } } user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } return ctx.text(user.get_name()) } @['/api/user/delete'; post] fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if user.admin || user.id == id { println('attempting to delete ${id} as ${user.id}') // yeet if !app.delete_user(user.id) { return ctx.server_error('failed to delete user: ${id}') } app.auth.delete_tokens_for_user(id) or { eprintln('failed to delete tokens for user during deletion: ${id}') } // log out if user.id == id { ctx.set_cookie( name: 'token' value: '' same_site: .same_site_none_mode secure: true path: '/' ) } println('deleted user ${id}') return ctx.ok('user deleted') } else { return ctx.unauthorized('be nice. deleting other users is off-limits.') } } @['/api/user/search'; get] fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if limit >= search_hard_limit { return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') } users := app.search_for_users(query, limit, offset) return ctx.json[[]User](users) } @['/api/user/whoami'; get] fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } return ctx.text(user.username) } /// user/notification /// @['/api/user/notification/clear'; post] fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if notification := app.get_notification_by_id(id) { if notification.user_id != user.id { return ctx.server_error('no such notification for user') } else if !app.delete_notification(id) { return ctx.server_error('failed to delete notification') } } else { return ctx.server_error('no such notification for user') } return ctx.ok('cleared notification') } @['/api/user/notification/clear_all'; post] fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if !app.delete_notifications_for_user(user.id) { return ctx.server_error('failed to delete notifications') } return ctx.ok('cleared notifications') } ////// post ////// @['/api/post/new_post'; post] fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if user.muted { return ctx.server_error('you are muted!') } // validate title if !app.validators.post_title.validate(title) { return ctx.server_error('invalid title') } // validate body if !app.validators.post_body.validate(body) { return ctx.server_error('invalid body') } nsfw := 'nsfw' in ctx.form if nsfw && !app.config.post.allow_nsfw { return ctx.server_error('nsfw posts are not allowed on this instance') } mut post := Post{ author_id: user.id title: title body: body nsfw: nsfw } if replying_to != 0 { // check if replying post exists app.get_post_by_id(replying_to) or { return ctx.server_error('the post you are trying to reply to does not exist') } post.replying_to = replying_to } if !app.add_post(post) { println('failed to post: ${post} from user ${user.id}') return ctx.server_error('failed to post') } //TODO: Can I not just get the ID directly?? This method feels dicey at best. // find the post's id to process mentions with if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { app.process_post_mentions(x) return ctx.ok('posted. id=${x.id}') } else { eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}') return ctx.server_error('failed to get post ID, this error should never happen') } } @['/api/post/delete'; post] fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } if user.admin || user.id == post.author_id { if !app.delete_post(post.id) { eprintln('api_post_delete: failed to delete post: ${id}') return ctx.server_error('failed to delete post') } println('deleted post: ${id}') return ctx.ok('post deleted') } else { eprintln('insufficient perms to delete post: ${id} (${user.id})') return ctx.unauthorized('insufficient permissions') } } @['/api/post/like'; post] fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } if app.does_user_like_post(user.id, post.id) { if !app.unlike_post(post.id, user.id) { eprintln('user ${user.id} failed to unlike post ${id}') return ctx.server_error('failed to unlike post') } return ctx.ok('unliked post') } else { // remove the old dislike, if it exists if app.does_user_dislike_post(user.id, post.id) { if !app.unlike_post(post.id, user.id) { eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') } } like := Like{ user_id: user.id post_id: post.id is_like: true } if !app.add_like(like) { eprintln('user ${user.id} failed to like post ${id}') return ctx.server_error('failed to like post') } return ctx.ok('liked post') } } @['/api/post/dislike'; post] fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } if app.does_user_dislike_post(user.id, post.id) { if !app.unlike_post(post.id, user.id) { eprintln('user ${user.id} failed to undislike post ${id}') return ctx.server_error('failed to undislike post') } return ctx.ok('undisliked post') } else { // remove the old like, if it exists if app.does_user_like_post(user.id, post.id) { if !app.unlike_post(post.id, user.id) { eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') } } like := Like{ user_id: user.id post_id: post.id is_like: false } if !app.add_like(like) { eprintln('user ${user.id} failed to dislike post ${id}') return ctx.server_error('failed to dislike post') } return ctx.ok('disliked post') } } @['/api/post/save'; post] fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if app.get_post_by_id(id) != none { if app.toggle_save_post(user.id, id) { return ctx.text('toggled save') } else { return ctx.server_error('failed to save post') } } else { return ctx.server_error('post does not exist') } } @['/api/post/save_for_later'; post] fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if app.get_post_by_id(id) != none { if app.toggle_save_for_later_post(user.id, id) { return ctx.text('toggled save') } else { return ctx.server_error('failed to save post') } } else { return ctx.server_error('post does not exist') } } @['/api/post/get_title'; get] fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { if !app.config.instance.public_data { _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } } post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } return ctx.text(post.title) } @['/api/post/edit'; post] fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } if post.author_id != user.id { return ctx.unauthorized('insufficient permissions') } nsfw := if 'nsfw' in ctx.form { app.config.post.allow_nsfw } else { post.nsfw } if !app.update_post(id, title, body, nsfw) { eprintln('failed to update post') return ctx.server_error('failed to update post') } return ctx.ok('posted edited') } @['/api/post/pin'; post] fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if user.admin { if !app.pin_post(id) { eprintln('failed to pin post: ${id}') return ctx.server_error('failed to pin post') } return ctx.ok('post pinned') } else { eprintln('insufficient perms to pin post: ${id} (${user.id})') return ctx.unauthorized('insufficient permissions') } } @['/api/post/get/'; get] fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { if !app.config.instance.public_data { _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } } post := app.get_post_by_id(id) or { return ctx.text('no such post') } return ctx.json[Post](post) } @['/api/post/search'; get] fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if limit >= search_hard_limit { return ctx.text('limit exceeds hard limit (${search_hard_limit})') } posts := app.search_for_posts(query, limit, offset) return ctx.json[[]PostSearchResult](posts) } ////// site ////// @['/api/site/set_motd'; post] fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { user := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } if user.admin { if !app.set_motd(motd) { eprintln('failed to set motd: ${motd}') return ctx.server_error('failed to set motd') } println('set motd to: ${motd}') return ctx.ok('motd updated') } else { eprintln('insufficient perms to set motd to: ${motd} (${user.id})') return ctx.unauthorized('insufficient permissions') } }