a mini social media app for small communities

Compare changes

Choose any two refs to compare.

+4
.gitignore
··· 1 # Binaries 2 /beep 3 /build/ 4 5 # Editor/system specific metadata 6 .DS_Store ··· 9 # Secrets 10 /config.real.maple 11 .env 12 13 # Local V and Clockwork install (Gitpod) 14 /clockwork
··· 1 # Binaries 2 /beep 3 /build/ 4 + /scripts/fetchbuildinfo 5 6 # Editor/system specific metadata 7 .DS_Store ··· 10 # Secrets 11 /config.real.maple 12 .env 13 + 14 + # Build data 15 + /buildinfo.maple 16 17 # Local V and Clockwork install (Gitpod) 18 /clockwork
+1 -1
Dockerfile
··· 40 41 STOPSIGNAL SIGINT 42 EXPOSE 8008 43 - CMD ["./beep", "./config.real.maple"]
··· 40 41 STOPSIGNAL SIGINT 42 EXPOSE 8008 43 + CMD ["./beep"]
+29 -12
build.maple
··· 1 plugins = [ 'v' ] 2 3 task:db.init = { 4 description = 'Initialize and start a local Postgres database via Docker' 5 category = 'db' ··· 43 run = 'docker rm beep-database && docker volume rm beep-data' 44 } 45 46 task:ngrok = { 47 description = 'Open an ngrok tunnel for testing.' 48 category = 'misc' ··· 55 run = 'ngrok http --url=${args} http://localhost:8008' 56 } 57 58 task:run.watch = { 59 description = 'Watch/run beep' 60 category = 'run' 61 run = '${v} -d veb_livereload watch run ${v_main} config.maple' 62 } 63 64 task:run.watch.real = { 65 description = 'Watch/run beep using config.real.maple' 66 category = 'run' 67 - run = '${v} watch run ${v_main} config.real.maple' 68 - } 69 - 70 - task:run = { 71 - description = 'Run beep' 72 - category = 'run' 73 - run = '${v} run ${v_main} config.maple' 74 } 75 76 - task:run.real = { 77 - description = 'Run beep using config.real.maple' 78 - category = 'run' 79 - run = '${v} run ${v_main} config.real.maple' 80 - } 81 82 task:cloc = { 83 description = 'Get the lines of code for beep!'
··· 1 plugins = [ 'v' ] 2 3 + task::fetch-build-info = { 4 + description = 'Fetch misc build information, mainly for the about page' 5 + run = 'v scripts/fetchbuildinfo.vsh' 6 + } 7 + 8 + // Database 9 + 10 task:db.init = { 11 description = 'Initialize and start a local Postgres database via Docker' 12 category = 'db' ··· 50 run = 'docker rm beep-database && docker volume rm beep-data' 51 } 52 53 + // Ngrok 54 + 55 task:ngrok = { 56 description = 'Open an ngrok tunnel for testing.' 57 category = 'misc' ··· 64 run = 'ngrok http --url=${args} http://localhost:8008' 65 } 66 67 + // Run 68 + 69 + task:run = { 70 + description = 'Run beep' 71 + category = 'run' 72 + depends = [':fetch-build-info'] 73 + run = '${v} run ${v_main} config.maple' 74 + } 75 + 76 + task:run.real = { 77 + description = 'Run beep using config.real.maple' 78 + category = 'run' 79 + depends = [':fetch-build-info'] 80 + run = '${v} run ${v_main}' 81 + } 82 + 83 task:run.watch = { 84 description = 'Watch/run beep' 85 category = 'run' 86 + depends = [':fetch-build-info'] 87 run = '${v} -d veb_livereload watch run ${v_main} config.maple' 88 } 89 90 task:run.watch.real = { 91 description = 'Watch/run beep using config.real.maple' 92 category = 'run' 93 + depends = [':fetch-build-info'] 94 + run = '${v} watch run ${v_main}' 95 } 96 97 + // Misc 98 99 task:cloc = { 100 description = 'Get the lines of code for beep!'
+9 -1
config.maple
··· 11 // Set this to '' if your instance is closed source. This is shown on the about page. 12 source = 'https://tangled.org/emmeline.girlkisser.top/beep' 13 14 // The instance's name, used for the page titles and on the homepage. 15 name = 'beep' 16 // The welcome message to show on the homepage. ··· 18 19 // TODO: Move default_theme and allow_changing_theme to user settings 20 // Default theme applied for all users. 21 - default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css' 22 // Whether or not users should be able to change their theme. 23 allow_changing_theme = true 24 ··· 29 30 // Toggle to allow any non-logged-in user to view data (posts, users, etc) 31 public_data = false 32 } 33 34 http = {
··· 11 // Set this to '' if your instance is closed source. This is shown on the about page. 12 source = 'https://tangled.org/emmeline.girlkisser.top/beep' 13 14 + // Source for your V compiler. Unless you're using a fork of V, you shouldn't need to change this. 15 + v_source = 'https://github.com/vlang/v' 16 + 17 // The instance's name, used for the page titles and on the homepage. 18 name = 'beep' 19 // The welcome message to show on the homepage. ··· 21 22 // TODO: Move default_theme and allow_changing_theme to user settings 23 // Default theme applied for all users. 24 + default_theme = '/static/themes/default.css' 25 + // Default custom CSS applied for all users. 26 + default_css = '' 27 // Whether or not users should be able to change their theme. 28 allow_changing_theme = true 29 ··· 34 35 // Toggle to allow any non-logged-in user to view data (posts, users, etc) 36 public_data = false 37 + 38 + // Owner's username. This is linked on the about page. Leave empty to disable. 39 + owner_username = '' 40 } 41 42 http = {
+3
doc/database_spec.md
··· 20 | `admin` | bool | controls whether or not this user is an admin | 21 | `automated` | bool | controls whether or not this user is automated | 22 | `theme` | ?string | controls per-user css themes | 23 | `bio` | string | bio for this user | 24 | `pronouns` | string | pronouns for this user | 25 | `created_at` | time.Time | a timestamp of when this user was made | ··· 35 | `replying_to` | ?int | id of the post that this post is replying to | 36 | `title` | string | the title of this post | 37 | `body` | string | the body of this post | 38 | `posted_at` | time.Time | a timestamp of when this post was made | 39 40 ## `Like`
··· 20 | `admin` | bool | controls whether or not this user is an admin | 21 | `automated` | bool | controls whether or not this user is automated | 22 | `theme` | ?string | controls per-user css themes | 23 + | `css` | ?string | controls per-user css | 24 | `bio` | string | bio for this user | 25 | `pronouns` | string | pronouns for this user | 26 | `created_at` | time.Time | a timestamp of when this user was made | ··· 36 | `replying_to` | ?int | id of the post that this post is replying to | 37 | `title` | string | the title of this post | 38 | `body` | string | the body of this post | 39 + | `pinned` | bool | if this post in globally pinned | 40 + | `nsfw` | bool | if this post in marked as nsfw | 41 | `posted_at` | time.Time | a timestamp of when this post was made | 42 43 ## `Like`
+5 -6
doc/themes.md
··· 30 31 ## beep-specific 32 33 - | name | source | css theme url | 34 - |------|--------|---------------| 35 - | | | | 36 - 37 - > there is nothing here yet! do you want to be the one to change that? 38 39 ## built-in 40 41 | name | based on (if applicable) | css theme url | 42 |-----------------------------|---------------------------------|---------------------------------| 43 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 44 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 45 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 48 > beep also features some built-in themes, some of which are based on the themes 49 > present in the "it just works" list! 50 51 - > make sure to prefix the url with `<instance url>/static/themes/`
··· 30 31 ## beep-specific 32 33 + | name | source | css theme url | 34 + |---------|----------------------------------------------------|----------------------------| 35 + | default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css | 36 37 ## built-in 38 39 | name | based on (if applicable) | css theme url | 40 |-----------------------------|---------------------------------|---------------------------------| 41 + | default | n/a | default.css | 42 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 43 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 44 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 47 > beep also features some built-in themes, some of which are based on the themes 48 > present in the "it just works" list! 49 50 + > make sure to prefix the url with `/static/themes/`
+1 -1
readme
··· 41 (assumes you already have a database somewhere) 42 $ v install EmmaTheMartian.Maple 43 $ v -cflags "-O3 -flto" . 44 - $ ./beep config.real.maple 45 46 If `v install ...` fails then you can install Maple 47 manually:
··· 41 (assumes you already have a database somewhere) 42 $ v install EmmaTheMartian.Maple 43 $ v -cflags "-O3 -flto" . 44 + $ ./beep 45 46 If `v install ...` fails then you can install Maple 47 manually:
+15
scripts/fetchbuildinfo.vsh
···
··· 1 + #!/usr/bin/env v 2 + 3 + import os 4 + import emmathemartian.maple 5 + 6 + commit_res := os.execute('git rev-parse HEAD') 7 + if commit_res.exit_code != 0 { 8 + eprintln('failed to fetch commit: ${commit_res.output}') 9 + exit(1) 10 + } 11 + commit := commit_res.output.trim_space() 12 + 13 + maple.save_file('buildinfo.maple', { 14 + 'commit': maple.ValueT(commit) 15 + })!
+2 -2
src/database/post.v
··· 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 }
··· 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, new_nsfw bool) bool { 113 sql app.db { 114 + update Post set body = new_body, title = new_title, nsfw = new_nsfw where id == post_id 115 } or { 116 return false 117 }
+12
src/database/user.v
··· 90 return true 91 } 92 93 // set_pronouns sets the given user's pronouns, returns true if this succeeded 94 // and false otherwise. 95 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool {
··· 90 return true 91 } 92 93 + // set_css sets the given user's custom CSS, returns true if this succeeded and 94 + // false otherwise. 95 + pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool { 96 + sql app.db { 97 + update User set css = css where id == user_id 98 + } or { 99 + eprintln('failed to update css for ${user_id}') 100 + return false 101 + } 102 + return true 103 + } 104 + 105 // set_pronouns sets the given user's pronouns, returns true if this succeeded 106 // and false otherwise. 107 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool {
+1
src/entity/user.v
··· 18 automated bool 19 20 theme string 21 22 bio string 23 pronouns string
··· 18 automated bool 19 20 theme string 21 + css string 22 23 bio string 24 pronouns string
+15 -3
src/main.v
··· 57 fn main() { 58 mut stopwatch := util.Stopwatch.new() 59 60 - config := webapp.load_config_from(os.args[1]) 61 - mut app := &App{ config: config } 62 63 // connect to database 64 util.time_it( ··· 96 // make the website config, if it does not exist 97 app.get_or_create_site_config() 98 99 - if config.dev_mode { 100 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 101 } 102
··· 57 fn main() { 58 mut stopwatch := util.Stopwatch.new() 59 60 + mut app := &App{ 61 + config: if os.args.len > 1 { 62 + webapp.load_config_from(os.args[1]) 63 + } else if os.exists('config.real.maple') { 64 + webapp.load_config_from('config.real.maple') 65 + } else { 66 + panic('no config found nor specified!') 67 + } 68 + buildinfo: if os.exists('buildinfo.maple') { 69 + webapp.load_buildinfo_from('buildinfo.maple') 70 + } else { 71 + webapp.BuildInfo{} 72 + } 73 + } 74 75 // connect to database 76 util.time_it( ··· 108 // make the website config, if it does not exist 109 app.get_or_create_site_config() 110 111 + if app.config.dev_mode { 112 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 113 } 114
+52
src/static/js/form.js
···
··· 1 + async function _submit(event, element) 2 + { 3 + event.preventDefault(); 4 + 5 + /* debug */ 6 + console.log(`submitting form:`); 7 + console.log(element) 8 + console.log(`destination: ${element.action}`); 9 + const formdata = new FormData(element); 10 + console.log(`data:`); 11 + console.log(formdata); 12 + 13 + try 14 + { 15 + await fetch(element.action, { 16 + method: "POST", 17 + headers: { 18 + "Content-Type": "application/x-www-form-urlencoded", 19 + }, 20 + body: new URLSearchParams(new FormData(element)), 21 + }).then(async response => { 22 + console.log(response); 23 + const ok = response.status == 200; 24 + const text = await response.text(); 25 + notify(text, ok ? 'ok' : 'error'); /* /static/js/notify.js */ 26 + if (ok) 27 + { 28 + if (element.hasAttribute("beep-redirect")) 29 + window.location.href = element.getAttribute("beep-redirect"); 30 + else if (element.hasAttribute('beep-redirect-js')) 31 + window.location.href = eval(element.getAttribute("beep-redirect-js"))( 32 + response, 33 + text 34 + ); 35 + } 36 + }); 37 + } 38 + catch (error) 39 + { 40 + console.error(error.message); 41 + } 42 + } 43 + 44 + const e = document.getElementsByTagName('form'); 45 + for (let i = 0 ; i < e.length ; i++) 46 + { 47 + const element = e.item(i); 48 + if (element.method == 'post') 49 + { 50 + element.onsubmit = event => _submit(event, element); 51 + } 52 + }
+18
src/static/js/notify.js
···
··· 1 + const errors = document.getElementById('errors') 2 + 3 + const notify = (msg, level = 'ok') => { 4 + const p = document.createElement('p'); 5 + p.classList.add(level); 6 + 7 + const button = document.createElement('button'); 8 + button.innerText = 'X'; 9 + button.style.display = 'inline'; 10 + button.onclick = () => errors.removeChild(p); 11 + 12 + const span = document.createElement('span'); 13 + span.innerText = `${level != 'ok' ? `${level}: ` : ''}${msg}`; 14 + 15 + p.appendChild(button); 16 + p.appendChild(span); 17 + errors.appendChild(p); 18 + }
+31
src/static/js/password.js
···
··· 1 + const add_password_checkers = (password_id, confirm_id, match_id) => { 2 + const password = document.getElementById(password_id); 3 + const confirm_password = document.getElementById(confirm_id); 4 + const matches = document.getElementById(match_id); 5 + 6 + const a = () => { 7 + matches.innerText = password.value==confirm_password.value ? "yes" : "no"; 8 + }; 9 + password.addEventListener('input', a); 10 + confirm_password.addEventListener('input', a); 11 + 12 + const view_password = document.getElementById(`view-${password_id}`); 13 + const view_confirm_password = document.getElementById(`view-${confirm_id}`); 14 + 15 + const b = (elm, btn) => { 16 + return _ => { 17 + if (elm.getAttribute('type') == 'password') 18 + { 19 + elm.setAttribute('type', 'text'); 20 + btn.value = 'hide'; 21 + } 22 + else 23 + { 24 + elm.setAttribute('type', 'password') 25 + btn.value = 'show'; 26 + } 27 + }; 28 + }; 29 + view_password.addEventListener('click', b(password, view_password)); 30 + view_confirm_password.addEventListener('click', b(confirm_password, view_confirm_password)); 31 + }
+11 -1
src/static/style.css
··· 1 .post, 2 .notification { 3 border: 2px solid; ··· 22 23 pre { 24 white-space: pre-wrap; 25 } 26 27 span.nsfw-indicator { 28 - border: 2px solid red; 29 border-radius: 2px; 30 padding-left: 4px; 31 padding-right: 4px; 32 margin-left: 6px; 33 } 34 35 /* 36 * some themes make input fields display: block, which overrides my hidden 37 * attribute. to resolve that, i will just override the override. 38 */ 39 input[hidden] { 40 display: none !important; 41 }
··· 1 + :root { 2 + --c-nsfw-border: red; 3 + } 4 + 5 .post, 6 .notification { 7 border: 2px solid; ··· 26 27 pre { 28 white-space: pre-wrap; 29 + word-wrap: break-word; 30 } 31 32 span.nsfw-indicator { 33 + border: 2px solid var(--c-nsfw-border); 34 border-radius: 2px; 35 padding-left: 4px; 36 padding-right: 4px; 37 margin-left: 6px; 38 } 39 40 + details>summary:hover { 41 + cursor: pointer; 42 + } 43 + 44 /* 45 * some themes make input fields display: block, which overrides my hidden 46 * attribute. to resolve that, i will just override the override. 47 */ 48 input[hidden] { 49 display: none !important; 50 + visibility: none !important; 51 }
+239
src/static/themes/default.css
···
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Onest:wght@100..900&family=Oxygen+Mono&display=swap'); 2 + 3 + :root { 4 + /* palette */ 5 + /* greys */ 6 + --p-black: #333333; 7 + --p-grey0: #414141; 8 + --p-grey1: #4a4a4a; 9 + --p-grey2: #4f4f4f; 10 + --p-grey3: #5c5c5c; 11 + --p-grey4: #5f5f5f; 12 + --p-white: #e7e7e7; 13 + /* rainbow */ 14 + --p-red: #faa; /* == light red */ 15 + --p-orange: #fa7; 16 + --p-yellow: #ffa; /* == light-orange */ 17 + --p-teal: #7fa; 18 + --p-green: #af7; 19 + --p-blue: #7af; 20 + --p-purple: #a7f; 21 + --p-pink: #f7a; 22 + /* light rainbow */ 23 + --p-light-red: #faa; 24 + --p-light-blue: #aaf; 25 + --p-light-green: #afa; 26 + --p-light-orange: #ffa; /* == yellow */ 27 + --p-light-purple: #faf; 28 + --p-light-blue: #aff; 29 + 30 + /* colours */ 31 + --c-bg: var(--p-black); 32 + --c-panel-bg: var(--p-grey0); 33 + --c-panel-border: var(--p-grey2); 34 + --c-panel2-bg: var(--p-grey1); 35 + --c-panel2-border: var(--p-grey3); 36 + --c-panel3-bg: var(--p-grey2); 37 + --c-panel3-border: var(--p-grey4); 38 + --c-fg: var(--p-white); 39 + --c-nsfw-border: var(--p-orange); 40 + --c-link: var(--p-blue); 41 + --c-link-hover: var(--p-light-blue); 42 + --c-accent: var(--p-light-green); 43 + --c-notify-ok: var(--p-light-green); 44 + --c-notify-error: var(--p-light-red); 45 + 46 + /* text */ 47 + --t-font: 'Onest', Arial, serif; 48 + --t-post-font: Garamond, 'Times New Roman', var(--t-font); 49 + --t-mono-font: 'Oxygen Mono', monospace; 50 + --t-h-font: 'Oxygen Mono', var(--t-post-font); 51 + --t-font-weight: 400; 52 + --t-font-style: normal; 53 + --t-font-size: 20px; 54 + 55 + /* layout */ 56 + --l-body-padding: 16px; 57 + --l-body-gap: 12px; 58 + --l-body-width: 75vw; 59 + --l-border-width: 2px; 60 + --l-border-style: solid; 61 + --l-border-radius: 0px; 62 + } 63 + 64 + html { 65 + padding: 0; 66 + offset: 0; 67 + margin: 0; 68 + 69 + width: 100vw; 70 + overflow-x: hidden; 71 + 72 + display: flex; 73 + flex-direction: column; 74 + align-items: center; 75 + 76 + background-color: var(--c-bg); 77 + color: var(--c-fg); 78 + 79 + font-family: var(--t-font); 80 + font-weight: var(--t-font-weight); 81 + font-style: var(--t-font-style); 82 + font-size: var(--t-font-size); 83 + } 84 + 85 + body { 86 + padding: var(--l-body-padding) 0 var(--l-body-padding) 0; 87 + offset: 0; 88 + margin: 0; 89 + width: var(--l-body-width); 90 + } 91 + 92 + header { 93 + padding-bottom: var(--l-body-padding); 94 + } 95 + 96 + footer { 97 + padding-top: var(--l-body-padding); 98 + } 99 + 100 + main { 101 + padding: var(--l-body-padding); 102 + background-color: var(--c-panel-bg); 103 + border: var(--l-border-width) var(--l-border-style) var(--c-panel-border); 104 + border-radius: var(--l-border-radius); 105 + 106 + display: flex; 107 + flex-direction: column; 108 + gap: var(--l-body-gap); 109 + } 110 + 111 + form { 112 + display: flex; 113 + flex-direction: column; 114 + gap: var(--l-body-gap); 115 + } 116 + 117 + button:hover { 118 + cursor: pointer; 119 + } 120 + 121 + input, 122 + textarea, 123 + button { 124 + background-color: var(--c-panel-bg); 125 + color: var(--c-fg); 126 + 127 + border: var(--l-border-width) var(--l-border-style) var(--c-accent); 128 + border-radius: var(--l-border-radius); 129 + padding: 6px; 130 + 131 + font-family: var(--t-font); 132 + } 133 + 134 + input:hover, 135 + textarea:hover, 136 + button:hover { 137 + border-color: var(--c-fg); 138 + } 139 + 140 + input:focus, 141 + textarea:focus, 142 + button:focus { 143 + background-color: var(--c-accent); 144 + color: var(--c-bg); 145 + } 146 + 147 + h1, h2, h3, h4, h5, h6, p { 148 + margin: 0; 149 + } 150 + 151 + h1, header, footer { 152 + font-family: var(--t-h-font); 153 + } 154 + 155 + a { 156 + color: var(--c-link); 157 + transition: 0.15s linear color; 158 + } 159 + 160 + a:hover { 161 + color: var(--c-link-hover); 162 + } 163 + 164 + hr { 165 + width: 100%; 166 + } 167 + 168 + pre { 169 + font-family: var(--t-mono-font); 170 + } 171 + 172 + .post { 173 + border: none; 174 + border-left: var(--l-border-width) var(--l-border-style) var(--c-fg); 175 + } 176 + 177 + .post>pre { 178 + font-family: var(--t-post-font); 179 + } 180 + 181 + .post + .post, 182 + .notification + .notification { 183 + margin-top: 18px; 184 + } 185 + 186 + form:not(.form-inline), 187 + #recent-posts, 188 + #pinned-posts { 189 + padding: 16px 24px 16px 24px; 190 + background-color: var(--c-panel2-bg); 191 + border: var(--l-border-width) var(--l-border-style) var(--c-panel2-border); 192 + border-radius: var(--l-border-radius); 193 + } 194 + 195 + #errors:empty { 196 + display: none; 197 + visibility: hidden; 198 + } 199 + 200 + #errors { 201 + display: flex; 202 + flex-direction: column; 203 + gap: var(--l-body-gap); 204 + } 205 + 206 + #errors>p { 207 + background-color: var(--c-panel3-bg); 208 + border: var(--l-border-width) var(--l-border-style) var(--c-panel3-border); 209 + border-radius: var(--l-border-radius); 210 + 211 + padding: 8px; 212 + width: calc(100% - 16px); 213 + 214 + display: inline-flex; 215 + align-items: center; 216 + justify-content: center; 217 + gap: 12px; 218 + } 219 + 220 + #errors>p>button { 221 + border-color: inherit; 222 + flex-grow: 0; 223 + } 224 + 225 + #errors>p>button:hover { 226 + border-color: var(--c-fg); 227 + } 228 + 229 + #errors>p>span { 230 + flex-grow: 1; 231 + } 232 + 233 + #errors>p.ok { 234 + border-color: var(--c-notify-ok); 235 + } 236 + 237 + #errors>p.error { 238 + border-color: var(--c-notify-error); 239 + }
+16 -7
src/templates/about.html
··· 3 <h1>about this instance</h1> 4 5 <div> 6 <p>name: @{app.config.instance.name}</p> 7 - <p>version: @{app.config.instance.version} (commit: @{app.commit})</p> 8 - <p>built at @{app.built_at} (<span id="built_at">date n/a</span>)</p> 9 - <p>built using @{app.v_hash}</p> 10 - 11 - @if app.config.instance.source != '' 12 - <p>source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p> 13 @end 14 15 <br> 16 - 17 <p>users: @{app.get_user_count()}</p> 18 <p>posts: @{app.get_post_count()}</p> 19 </div> 20 21 <script>
··· 3 <h1>about this instance</h1> 4 5 <div> 6 + <p><strong>general:</strong></p> 7 <p>name: @{app.config.instance.name}</p> 8 + <p>version: @{app.config.instance.version}</p> 9 + <p>public: @{app.config.instance.public_data}</p> 10 + @if app.config.instance.owner_username != '' 11 + <p>owner: <a href="/user/@{app.config.instance.owner_username}">@{app.config.instance.owner_username}</a></p> 12 @end 13 14 <br> 15 + <p><strong>stats:</strong></p> 16 <p>users: @{app.get_user_count()}</p> 17 <p>posts: @{app.get_post_count()}</p> 18 + 19 + @if app.config.instance.source != '' 20 + <br> 21 + <p><strong>nerd info:</strong></p> 22 + <p>beep source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p> 23 + <p>beep commit: <code><a href="@{app.config.instance.source}/commit/@{app.buildinfo.commit}">@{app.buildinfo.commit}</a></code></p> 24 + <p>V source: <a href="@{app.config.instance.v_source}">@{app.config.instance.v_source}</a></p> 25 + <p>V commit: <code><a href="@{app.config.instance.v_source}/commit/@{app.v_hash}">@{app.v_hash}</a></code></p> 26 + <p>built at <span id="built_at">date n/a</span> (unix: <code>@{app.built_at}</code>)</p> 27 + @end 28 </div> 29 30 <script>
+16 -5
src/templates/components/new_post.html
··· 1 <script src="/static/js/text_area_counter.js"></script> 2 <div> 3 - <form action="/api/post/new_post" method="post"> 4 <h2>new post:</h2> 5 6 @if replying ··· 15 > 16 @end 17 18 - <p id="title_chars">0/@{app.config.post.title_max_len}</p> 19 @if replying 20 <input 21 type="text" ··· 27 hidden aria-hidden 28 > 29 @else 30 <input 31 type="text" 32 name="title" ··· 40 @end 41 <br> 42 43 - <p id="body_chars">0/@{app.config.post.body_max_len}</p> 44 <textarea 45 name="body" 46 id="body" ··· 55 <br> 56 57 @if app.config.post.allow_nsfw 58 - <label for="nsfw">is nsfw:</label> 59 - <input type="checkbox" name="nsfw" id="nsfw" /> 60 @else 61 <input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden /> 62 @end
··· 1 <script src="/static/js/text_area_counter.js"></script> 2 <div> 3 + <form action="/api/post/new_post" method="post" 4 + beep-redirect-js="(_,t)=>{return'/post/'+t.split('=')[1];}"> 5 + <!-- 6 + the above JS snippet will redirect the user to the new post. It's a liiiitle convoluted but whatever. 7 + A successful new_post response will always respond with `posted. id=<id>`. I could just return JSON but honestly I don't care lmao. 8 + TODO: return json because it's definitely better practice. also it would be useful for custom clients :p 9 + --> 10 <h2>new post:</h2> 11 12 @if replying ··· 21 > 22 @end 23 24 @if replying 25 <input 26 type="text" ··· 32 hidden aria-hidden 33 > 34 @else 35 + <label for="title" id="title_chars">0/@{app.config.post.title_max_len}</label> 36 + <br> 37 <input 38 type="text" 39 name="title" ··· 47 @end 48 <br> 49 50 + <label for="body" id="body_chars">0/@{app.config.post.body_max_len}</label> 51 + <br> 52 <textarea 53 name="body" 54 id="body" ··· 63 <br> 64 65 @if app.config.post.allow_nsfw 66 + <div> 67 + <label for="nsfw">is nsfw:</label> 68 + <input type="checkbox" name="nsfw" id="nsfw" /> 69 + </div> 70 + <br> 71 @else 72 <input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden /> 73 @end
+19 -2
src/templates/edit.html
··· 7 <h1>edit post</h1> 8 9 <div class="post post-full"> 10 - <form action="/api/post/edit" method="post"> 11 <input 12 type="number" 13 name="id" ··· 47 >@post.body</textarea> 48 <br> 49 50 <input type="submit" value="save"> 51 </form> 52 </div> ··· 55 56 <div> 57 <h2>danger zone:</h2> 58 - <form action="/api/post/delete" method="post"> 59 <input 60 type="number" 61 name="id"
··· 7 <h1>edit post</h1> 8 9 <div class="post post-full"> 10 + <form action="/api/post/edit" method="post" beep-redirect="/post/@post.id"> 11 <input 12 type="number" 13 name="id" ··· 47 >@post.body</textarea> 48 <br> 49 50 + @if app.config.post.allow_nsfw 51 + <div> 52 + <label for="nsfw">is nsfw:</label> 53 + <input 54 + type="checkbox" 55 + name="nsfw" 56 + id="nsfw" 57 + @if post.nsfw 58 + checked aria-checked 59 + @end 60 + /> 61 + </div> 62 + <br> 63 + @else 64 + <input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden /> 65 + @end 66 + 67 <input type="submit" value="save"> 68 </form> 69 </div> ··· 72 73 <div> 74 <h2>danger zone:</h2> 75 + <form action="/api/post/delete" method="post" beep-redirect="/"> 76 <input 77 type="number" 78 name="id"
+10 -3
src/templates/inbox.html
··· 9 @if notifications.len == 0 10 <p>your inbox is empty!</p> 11 @else 12 - <a href="/api/user/notification/clear_all">clear all</a> 13 <hr> 14 @for notification in notifications.reverse() 15 <div class="notification"> 16 - <p><strong>@notification.summary</strong></p> 17 <pre id="notif-@{notification.id}">@notification.body</pre> 18 - <a href="/api/user/notification/clear?id=@{notification.id}">clear</a> 19 <script> 20 render_body('notif-@{notification.id}') 21 </script>
··· 9 @if notifications.len == 0 10 <p>your inbox is empty!</p> 11 @else 12 + <form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox"> 13 + <button>clear all</button> 14 + </form> 15 <hr> 16 @for notification in notifications.reverse() 17 <div class="notification"> 18 + <div style="display: flex; flex-direction: row; align-items: center; gap: 12px;"> 19 + <p><strong>@notification.summary</strong></p> 20 + <form action="/api/user/notification/clear" method="post" beep-redirect="/inbox" class="form-inline" style="display: inline;"> 21 + <input type="number" value="@{notification.id}" name="id" required aria-required hidden aria-hidden readonly aria-readonly /> 22 + <button style="display: inline;">clear</button> 23 + </form> 24 + </div> 25 <pre id="notif-@{notification.id}">@notification.body</pre> 26 <script> 27 render_body('notif-@{notification.id}') 28 </script>
+4 -4
src/templates/index.html
··· 8 9 <div> 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 13 @for post in pinned_posts 14 @include 'components/post_small.html' 15 @end ··· 17 <br> 18 @end 19 20 - <h2>recent posts:</h2> 21 - <div> 22 @if recent_posts.len > 0 23 @for post in recent_posts 24 @include 'components/post_small.html'
··· 8 9 <div> 10 @if pinned_posts.len > 0 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 @for post in pinned_posts 14 @include 'components/post_small.html' 15 @end ··· 17 <br> 18 @end 19 20 + <div id="recent-posts"> 21 + <h2>recent posts:</h2> 22 @if recent_posts.len > 0 23 @for post in recent_posts 24 @include 'components/post_small.html'
+2 -2
src/templates/login.html
··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 - <form action="/api/user/login" method="post"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 39 @end 40 </div> 41 42 - @include 'partial/footer.html'
··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 + <form action="/api/user/login" method="post" beep-redirect="/me"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 39 @end 40 </div> 41 42 + @include 'partial/footer.html'
+1 -1
src/templates/partial/footer.html
··· 15 <a href="/about">about</a> 16 </p> 17 18 - <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 19 </footer> 20 21 </body>
··· 15 <a href="/about">about</a> 16 </p> 17 18 + <p>powered by <a href="https://tangled.org/emmeline.girlkisser.top/beep">beep</a></p> 19 </footer> 20 21 </body>
+10 -7
src/templates/partial/header.html
··· 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 <meta name="description" content="" /> 8 9 - <link rel="icon" href="/favicon.png" /> 10 <title>@ctx.title</title> 11 12 @include 'assets/style.html' ··· 18 @endif 19 20 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 21 </head> 22 23 <body> ··· 48 </header> 49 50 <main> 51 - <!-- TODO: fix this lol --> 52 - @if ctx.form_error != '' 53 - <div> 54 - <p><strong>error:</strong> @ctx.form_error</p> 55 - </div> 56 - @end
··· 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 <meta name="description" content="" /> 8 9 <title>@ctx.title</title> 10 11 @include 'assets/style.html' ··· 17 @endif 18 19 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 20 + 21 + @if ctx.is_logged_in() && user.css != '' 22 + <style>@{user.css}</style> 23 + @else 24 + <style>@{app.config.instance.default_css}</style> 25 + @end 26 + 27 + <script src="/static/js/notify.js" defer></script> 28 + <script src="/static/js/form.js" defer></script> 29 </head> 30 31 <body> ··· 56 </header> 57 58 <main> 59 + <div id="errors"></div>
+7 -4
src/templates/post.html
··· 5 6 <div class="post post-full"> 7 <h2> 8 - <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"> 9 - <strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong> 10 - </a> 11 - 12 @if replying_to_post.id == 0 13 @post.title ··· 19 @end 20 </h2> 21 22 @if post.nsfw 23 <details> 24 <summary>click to show post (nsfw)</summary> ··· 28 <pre id="post-@{post.id}">@post.body</pre> 29 @end 30 31 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 32 <p><em>posted at: @post.posted_at</em></p> 33 34 @if ctx.is_logged_in() && !user.automated 35 <p><a href="/post/@{post.id}/reply">reply</a></p> 36 <br> 37 <div> ··· 94 <input type="submit" value="pin"> 95 </form> 96 97 - <form action="/api/post/delete" method="post"> 98 <input 99 type="number" 100 name="id"
··· 5 6 <div class="post post-full"> 7 <h2> 8 + <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a> 9 - 10 @if replying_to_post.id == 0 11 @post.title ··· 17 @end 18 </h2> 19 20 + <hr> 21 + 22 @if post.nsfw 23 <details> 24 <summary>click to show post (nsfw)</summary> ··· 28 <pre id="post-@{post.id}">@post.body</pre> 29 @end 30 31 + <hr> 32 + 33 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 34 <p><em>posted at: @post.posted_at</em></p> 35 36 @if ctx.is_logged_in() && !user.automated 37 + <br> 38 <p><a href="/post/@{post.id}/reply">reply</a></p> 39 <br> 40 <div> ··· 97 <input type="submit" value="pin"> 98 </form> 99 100 + <form action="/api/post/delete" method="post" beep-redirect="/"> 101 <input 102 type="number" 103 name="id"
+4 -30
src/templates/register.html
··· 1 @include 'partial/header.html' 2 3 <h1>register</h1> 4 5 <div> ··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 - <form action="/api/user/register" method="post"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 63 </div> 64 65 <script> 66 - const password = document.getElementById('password'); 67 - const confirm_password = document.getElementById('confirm-password'); 68 - const matches = document.getElementById('passwords-match'); 69 - 70 - const a = () => { 71 - matches.innerText = password.value==confirm_password.value ? "yes" : "no"; 72 - }; 73 - password.addEventListener('input', a); 74 - confirm_password.addEventListener('input', a); 75 - 76 - const view_password = document.getElementById('view-password'); 77 - const view_confirm_password = document.getElementById('view-confirm-password'); 78 - 79 - const b = (elm, btn) => { 80 - return event => { 81 - if (elm.getAttribute('type') == 'password') 82 - { 83 - elm.setAttribute('type', 'text'); 84 - btn.innerText = 'hide'; 85 - } 86 - else 87 - { 88 - elm.setAttribute('type', 'password') 89 - btn.innerText = 'show'; 90 - } 91 - }; 92 - }; 93 - view_password.addEventListener('click', b(password, view_password)); 94 - view_confirm_password.addEventListener('click', b(confirm_password, view_confirm_password)); 95 </script> 96 97 @include 'partial/footer.html'
··· 1 @include 'partial/header.html' 2 3 + <script src="/static/js/password.js"></script> 4 + 5 <h1>register</h1> 6 7 <div> ··· 13 <p>you are already logged in as @{user.get_name()}!</p> 14 <a href="/api/user/logout">log out</a> 15 @else 16 + <form action="/api/user/register" method="post" beep-redirect="/me"> 17 <label for="username">username:</label> 18 <input 19 type="text" ··· 65 </div> 66 67 <script> 68 + add_password_checkers('password', 'confirm-password', 'passwords-match'); 69 </script> 70 71 @include 'partial/footer.html'
+44 -14
src/templates/settings.html
··· 2 3 @if ctx.is_logged_in() 4 <script src="/static/js/text_area_counter.js"></script> 5 6 <h1>user settings:</h1> 7 ··· 67 68 <form action="/api/user/set_theme" method="post"> 69 <label for="url">theme:</label> 70 - <input type="url" name="url" id="url" value="@user.theme"> 71 <input type="submit" value="save"> 72 </form> 73 @end ··· 92 <hr> 93 94 <form action="/api/user/set_automated" method="post"> 95 - <label for="is_automated">is automated:</label> 96 - <input 97 - type="checkbox" 98 - name="is_automated" 99 - id="is_automated" 100 - value="true" 101 - @if user.automated 102 - checked aria-checked 103 - @end 104 - > 105 <input type="submit" value="save"> 106 <p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p> 107 <p>it will also hide most front-end interactions since the user of this account likely will not be using those very often.</p> ··· 116 117 <details> 118 <summary>change password (click to reveal)</summary> 119 - <form action="/api/user/set_password" method="post"> 120 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 121 <label for="current_password">current password:</label> 122 <input ··· 130 autocomplete="off" aria-autocomplete="off" 131 > 132 <br> 133 - <label for="new_password">new password:</label> 134 <input 135 type="password" 136 name="new_password" ··· 141 required aria-required 142 autocomplete="off" aria-autocomplete="off" 143 > 144 <input type="submit" value="save"> 145 </form> 146 </details> ··· 149 150 <details> 151 <summary>account deletion (click to reveal)</summary> 152 - <form action="/api/user/delete" autocomplete="off"> 153 <input 154 type="number" 155 name="id" ··· 183 </form> 184 </details> 185 </details> 186 187 @else 188 <p>uh oh, you need to be logged in to view this page!</p>
··· 2 3 @if ctx.is_logged_in() 4 <script src="/static/js/text_area_counter.js"></script> 5 + <script src="/static/js/password.js"></script> 6 7 <h1>user settings:</h1> 8 ··· 68 69 <form action="/api/user/set_theme" method="post"> 70 <label for="url">theme:</label> 71 + <input type="text" name="url" id="url" value="@user.theme"> 72 + <input type="submit" value="save"> 73 + </form> 74 + 75 + <hr> 76 + 77 + <form action="/api/user/set_css" method="post"> 78 + <label for="css">custom css:</label> 79 + <br> 80 + <textarea type="text" name="css" id="css" style="font: monospace;">@user.css</textarea> 81 <input type="submit" value="save"> 82 </form> 83 @end ··· 102 <hr> 103 104 <form action="/api/user/set_automated" method="post"> 105 + <div> 106 + <label for="is_automated">is automated:</label> 107 + <input 108 + type="checkbox" 109 + name="is_automated" 110 + id="is_automated" 111 + value="true" 112 + @if user.automated 113 + checked aria-checked 114 + @end 115 + > 116 + </div> 117 <input type="submit" value="save"> 118 <p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p> 119 <p>it will also hide most front-end interactions since the user of this account likely will not be using those very often.</p> ··· 128 129 <details> 130 <summary>change password (click to reveal)</summary> 131 + <form action="/api/user/set_password" method="post" beep-redirect="/login"> 132 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 133 <label for="current_password">current password:</label> 134 <input ··· 142 autocomplete="off" aria-autocomplete="off" 143 > 144 <br> 145 + <label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label> 146 <input 147 type="password" 148 name="new_password" ··· 153 required aria-required 154 autocomplete="off" aria-autocomplete="off" 155 > 156 + <label for="confirm_password">confirm password: <input type="button" id="view-confirm_password" style="display: inline;" value="view"></input></label> 157 + <input 158 + type="password" 159 + name="confirm_password" 160 + id="confirm_password" 161 + pattern="@app.config.user.password_pattern" 162 + minlength="@app.config.user.password_min_len" 163 + maxlength="@app.config.user.password_max_len" 164 + required aria-required 165 + autocomplete="off" aria-autocomplete="off" 166 + > 167 + <br> 168 + <p>passwords match: <span id="passwords-match">yes</span></p> 169 + <br> 170 <input type="submit" value="save"> 171 </form> 172 </details> ··· 175 176 <details> 177 <summary>account deletion (click to reveal)</summary> 178 + <form action="/api/user/delete" autocomplete="off" beep-redirect="/"> 179 <input 180 type="number" 181 name="id" ··· 209 </form> 210 </details> 211 </details> 212 + 213 + <script> 214 + add_password_checkers('new_password', 'confirm_password', 'passwords-match'); 215 + </script> 216 217 @else 218 <p>uh oh, you need to be logged in to view this page!</p>
+178 -189
src/webapp/api.v
··· 10 // search_hard_limit is the maximum limit for a search query, used to prevent 11 // people from requesting searches with huge limits and straining the SQL server 12 pub const search_hard_limit = 50 13 14 ////// user ////// 15 ··· 29 'remoteip': ctx.ip() 30 'response': token 31 }) or { 32 - ctx.error('failed to post hcaptcha response: ${err}') 33 - return ctx.redirect('/register') 34 } 35 data := json.decode(HcaptchaResponse, response.body) or { 36 - ctx.error('failed to decode hcaptcha response: ${err}') 37 - return ctx.redirect('/register') 38 } 39 if !data.success { 40 - ctx.error('failed to verify hcaptcha: ${data}') 41 - return ctx.redirect('/register') 42 } 43 } 44 45 if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { 46 - ctx.error('invalid invite code') 47 - return ctx.redirect('/register') 48 } 49 50 if app.get_user_by_name(username) != none { 51 - ctx.error('username taken') 52 - return ctx.redirect('/register') 53 } 54 55 // validate username 56 if !app.validators.username.validate(username) { 57 - ctx.error('invalid username') 58 - return ctx.redirect('/register') 59 } 60 61 // validate password 62 if !app.validators.password.validate(password) { 63 - ctx.error('invalid password') 64 - return ctx.redirect('/register') 65 } 66 67 if password != ctx.form['confirm-password'] { 68 - ctx.error('passwords do not match') 69 - return ctx.redirect('/register') 70 } 71 72 salt := auth.generate_salt() ··· 84 app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 85 app.config.welcome.body.replace('%s', x.get_name())) 86 token := app.auth.add_token(x.id) or { 87 - eprintln(err) 88 - ctx.error('api_user_register: could not create token for user with id ${x.id}') 89 - return ctx.redirect('/') 90 } 91 ctx.set_cookie( 92 name: 'token' ··· 97 ) 98 } else { 99 eprintln('api_user_register: could not log into newly-created user: ${user}') 100 - ctx.error('could not log into newly-created user.') 101 } 102 103 - return ctx.redirect('/') 104 } 105 106 @['/api/user/set_username'; post] 107 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 108 user := app.whoami(mut ctx) or { 109 - ctx.error('you are not logged in!') 110 - return ctx.redirect('/login') 111 } 112 113 if app.get_user_by_name(new_username) != none { 114 - ctx.error('username taken') 115 - return ctx.redirect('/settings') 116 } 117 118 // validate username 119 if !app.validators.username.validate(new_username) { 120 - ctx.error('invalid username') 121 - return ctx.redirect('/settings') 122 } 123 124 if !app.set_username(user.id, new_username) { 125 - ctx.error('failed to update username') 126 } 127 128 - return ctx.redirect('/settings') 129 } 130 131 @['/api/user/set_password'; post] 132 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 133 user := app.whoami(mut ctx) or { 134 - ctx.error('you are not logged in!') 135 - return ctx.redirect('/login') 136 } 137 138 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 139 - ctx.error('current_password is incorrect') 140 - return ctx.redirect('/settings') 141 } 142 143 // validate password 144 if !app.validators.password.validate(new_password) { 145 - ctx.error('invalid password') 146 - return ctx.redirect('/settings') 147 } 148 149 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 150 if !app.set_password(user.id, hashed_new_password) { 151 - ctx.error('failed to update password') 152 - return ctx.redirect('/settings') 153 } 154 155 // invalidate tokens and log out 156 app.auth.delete_tokens_for_user(user.id) or { 157 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 158 - return ctx.redirect('/settings') 159 } 160 ctx.set_cookie( 161 name: 'token' ··· 165 path: '/' 166 ) 167 168 - return ctx.redirect('/login') 169 } 170 171 @['/api/user/login'; post] 172 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 173 user := app.get_user_by_name(username) or { 174 - ctx.error('invalid credentials') 175 - return ctx.redirect('/login') 176 } 177 178 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 179 - ctx.error('invalid credentials') 180 - return ctx.redirect('/login') 181 } 182 183 token := app.auth.add_token(user.id) or { 184 eprintln('failed to add token on log in: ${err}') 185 - ctx.error('could not create token for user with id ${user.id}') 186 - return ctx.redirect('/login') 187 } 188 189 ctx.set_cookie( ··· 194 path: '/' 195 ) 196 197 - return ctx.redirect('/') 198 } 199 200 - @['/api/user/logout'] 201 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 202 if token := ctx.get_cookie('token') { 203 if user := app.get_user_by_token(token) { ··· 207 // } 208 app.auth.delete_tokens_for_value(token) or { 209 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 210 - return ctx.redirect('/login') 211 } 212 } else { 213 eprintln('failed to get user for token for logout') ··· 224 path: '/' 225 ) 226 227 - return ctx.redirect('/login') 228 } 229 230 - @['/api/user/full_logout'] 231 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 232 if token := ctx.get_cookie('token') { 233 if user := app.get_user_by_token(token) { 234 app.auth.delete_tokens_for_user(user.id) or { 235 eprintln('failed to yeet tokens for ${user.id}') 236 - return ctx.redirect('/login') 237 } 238 } else { 239 eprintln('failed to get user for token for full_logout') ··· 250 path: '/' 251 ) 252 253 - return ctx.redirect('/login') 254 } 255 256 @['/api/user/set_nickname'; post] 257 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 258 user := app.whoami(mut ctx) or { 259 - ctx.error('you are not logged in!') 260 - return ctx.redirect('/login') 261 } 262 263 mut clean_nickname := ?string(nickname.trim_space()) ··· 267 268 // validate 269 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 270 - ctx.error('invalid nickname') 271 - return ctx.redirect('/settings') 272 } 273 274 if !app.set_nickname(user.id, clean_nickname) { 275 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 276 - return ctx.redirect('/settings') 277 } 278 279 - return ctx.redirect('/settings') 280 } 281 282 @['/api/user/set_muted'; post] 283 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 284 user := app.whoami(mut ctx) or { 285 - ctx.error('you are not logged in!') 286 - return ctx.redirect('/login') 287 } 288 289 to_mute := app.get_user_by_id(id) or { 290 - ctx.error('no such user') 291 - return ctx.redirect('/') 292 } 293 294 if user.admin { 295 if !app.set_muted(to_mute.id, muted) { 296 - ctx.error('failed to change mute status') 297 - return ctx.redirect('/user/${to_mute.username}') 298 } 299 - return ctx.redirect('/user/${to_mute.username}') 300 } else { 301 - ctx.error('insufficient permissions!') 302 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 303 - return ctx.redirect('/user/${to_mute.username}') 304 } 305 } 306 307 @['/api/user/set_automated'; post] 308 fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 309 user := app.whoami(mut ctx) or { 310 - ctx.error('you are not logged in!') 311 - return ctx.redirect('/login') 312 } 313 314 if !app.set_automated(user.id, is_automated) { 315 - ctx.error('failed to set automated status.') 316 } 317 318 - return ctx.redirect('/settings') 319 } 320 321 @['/api/user/set_theme'; post] 322 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 323 if !app.config.instance.allow_changing_theme { 324 - ctx.error('this instance disallows changing themes :(') 325 - return ctx.redirect('/settings') 326 } 327 328 user := app.whoami(mut ctx) or { 329 - ctx.error('you are not logged in!') 330 - return ctx.redirect('/login') 331 } 332 333 mut theme := ?string(none) 334 - if url.trim_space() != '' { 335 theme = url.trim_space() 336 } 337 338 if !app.set_theme(user.id, theme) { 339 - ctx.error('failed to change theme') 340 - return ctx.redirect('/settings') 341 } 342 343 - return ctx.redirect('/settings') 344 } 345 346 @['/api/user/set_pronouns'; post] 347 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 348 user := app.whoami(mut ctx) or { 349 - ctx.error('you are not logged in!') 350 - return ctx.redirect('/login') 351 } 352 353 clean_pronouns := pronouns.trim_space() 354 if !app.validators.pronouns.validate(clean_pronouns) { 355 - ctx.error('invalid pronouns') 356 - return ctx.redirect('/settings') 357 } 358 359 if !app.set_pronouns(user.id, clean_pronouns) { 360 - ctx.error('failed to change pronouns') 361 - return ctx.redirect('/settings') 362 } 363 364 - return ctx.redirect('/settings') 365 } 366 367 @['/api/user/set_bio'; post] 368 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 369 user := app.whoami(mut ctx) or { 370 - ctx.error('you are not logged in!') 371 - return ctx.redirect('/login') 372 } 373 374 clean_bio := bio.trim_space() 375 if !app.validators.user_bio.validate(clean_bio) { 376 - ctx.error('invalid bio') 377 - return ctx.redirect('/settings') 378 } 379 380 if !app.set_bio(user.id, clean_bio) { 381 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 382 - return ctx.redirect('/settings') 383 } 384 385 - return ctx.redirect('/settings') 386 } 387 388 - @['/api/user/get_name'] 389 fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 390 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 391 return ctx.text(user.get_name()) 392 } 393 394 - @['/api/user/delete'] 395 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 396 user := app.whoami(mut ctx) or { 397 - ctx.error('you are not logged in!') 398 - return ctx.redirect('/login') 399 } 400 401 - println('attempting to delete ${id} as ${user.id}') 402 - 403 if user.admin || user.id == id { 404 // yeet 405 if !app.delete_user(user.id) { 406 - ctx.error('failed to delete user: ${id}') 407 - return ctx.redirect('/') 408 } 409 410 app.auth.delete_tokens_for_user(id) or { ··· 421 ) 422 } 423 println('deleted user ${id}') 424 } else { 425 - ctx.error('be nice. deleting other users is off-limits.') 426 } 427 - 428 - return ctx.redirect('/') 429 } 430 431 @['/api/user/search'; get] 432 fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 433 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 434 if limit >= search_hard_limit { 435 - return ctx.text('limit exceeds hard limit (${search_hard_limit})') 436 } 437 users := app.search_for_users(query, limit, offset) 438 return ctx.json[[]User](users) ··· 440 441 @['/api/user/whoami'; get] 442 fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 443 - user := app.whoami(mut ctx) or { return ctx.text('not logged in') } 444 return ctx.text(user.username) 445 } 446 447 /// user/notification /// 448 449 - @['/api/user/notification/clear'] 450 fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 451 user := app.whoami(mut ctx) or { 452 - ctx.error('you are not logged in!') 453 - return ctx.redirect('/login') 454 } 455 456 if notification := app.get_notification_by_id(id) { 457 if notification.user_id != user.id { 458 - ctx.error('no such notification for user') 459 - return ctx.redirect('/inbox') 460 - } else { 461 - if !app.delete_notification(id) { 462 - ctx.error('failed to delete notification') 463 - return ctx.redirect('/inbox') 464 - } 465 } 466 } else { 467 - ctx.error('no such notification for user') 468 } 469 470 - return ctx.redirect('/inbox') 471 } 472 473 - @['/api/user/notification/clear_all'] 474 fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 475 user := app.whoami(mut ctx) or { 476 - ctx.error('you are not logged in!') 477 - return ctx.redirect('/login') 478 } 479 if !app.delete_notifications_for_user(user.id) { 480 - ctx.error('failed to delete notifications') 481 - return ctx.redirect('/inbox') 482 } 483 - return ctx.redirect('/inbox') 484 } 485 486 ////// post ////// ··· 488 @['/api/post/new_post'; post] 489 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 490 user := app.whoami(mut ctx) or { 491 - ctx.error('not logged in!') 492 - return ctx.redirect('/login') 493 } 494 495 if user.muted { 496 - ctx.error('you are muted!') 497 - return ctx.redirect('/post/new') 498 } 499 500 // validate title 501 if !app.validators.post_title.validate(title) { 502 - ctx.error('invalid title') 503 - return ctx.redirect('/post/new') 504 } 505 506 // validate body 507 if !app.validators.post_body.validate(body) { 508 - ctx.error('invalid body') 509 - return ctx.redirect('/post/new') 510 } 511 512 nsfw := 'nsfw' in ctx.form 513 if nsfw && !app.config.post.allow_nsfw { 514 - ctx.error('nsfw posts are not allowed on this instance') 515 - return ctx.redirect('/post/new') 516 } 517 518 mut post := Post{ ··· 525 if replying_to != 0 { 526 // check if replying post exists 527 app.get_post_by_id(replying_to) or { 528 - ctx.error('the post you are trying to reply to does not exist') 529 - return ctx.redirect('/post/new') 530 } 531 post.replying_to = replying_to 532 } 533 534 if !app.add_post(post) { 535 - ctx.error('failed to post!') 536 println('failed to post: ${post} from user ${user.id}') 537 - return ctx.redirect('/post/new') 538 } 539 540 // find the post's id to process mentions with 541 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 542 app.process_post_mentions(x) 543 - return ctx.redirect('/post/${x.id}') 544 } else { 545 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 546 - return ctx.redirect('/me') 547 } 548 } 549 550 @['/api/post/delete'; post] 551 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 552 user := app.whoami(mut ctx) or { 553 - ctx.error('not logged in!') 554 - return ctx.redirect('/login') 555 } 556 557 post := app.get_post_by_id(id) or { 558 - ctx.error('post does not exist') 559 - return ctx.redirect('/') 560 } 561 562 if user.admin || user.id == post.author_id { 563 if !app.delete_post(post.id) { 564 - ctx.error('failed to delete post') 565 - eprintln('failed to delete post: ${id}') 566 - return ctx.redirect('/') 567 } 568 println('deleted post: ${id}') 569 - return ctx.redirect('/') 570 } else { 571 - ctx.error('insufficient permissions!') 572 eprintln('insufficient perms to delete post: ${id} (${user.id})') 573 - return ctx.redirect('/') 574 } 575 } 576 577 - @['/api/post/like'] 578 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 579 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 580 581 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 582 583 if app.does_user_like_post(user.id, post.id) { 584 if !app.unlike_post(post.id, user.id) { ··· 607 } 608 } 609 610 - @['/api/post/dislike'] 611 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 612 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 613 614 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 615 616 if app.does_user_dislike_post(user.id, post.id) { 617 if !app.unlike_post(post.id, user.id) { ··· 640 } 641 } 642 643 - @['/api/post/save'] 644 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 645 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 646 647 if app.get_post_by_id(id) != none { 648 if app.toggle_save_post(user.id, id) { ··· 655 } 656 } 657 658 - @['/api/post/save_for_later'] 659 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 660 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 661 662 if app.get_post_by_id(id) != none { 663 if app.toggle_save_for_later_post(user.id, id) { ··· 670 } 671 } 672 673 - @['/api/post/get_title'] 674 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 675 if !app.config.instance.public_data { 676 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 677 } 678 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 679 return ctx.text(post.title) ··· 682 @['/api/post/edit'; post] 683 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 684 user := app.whoami(mut ctx) or { 685 - ctx.error('not logged in!') 686 - return ctx.redirect('/login') 687 } 688 post := app.get_post_by_id(id) or { 689 - ctx.error('no such post') 690 - return ctx.redirect('/') 691 } 692 if post.author_id != user.id { 693 - ctx.error('insufficient permissions') 694 - return ctx.redirect('/') 695 } 696 697 - if !app.update_post(id, title, body) { 698 eprintln('failed to update post') 699 - ctx.error('failed to update post') 700 - return ctx.redirect('/') 701 } 702 703 - return ctx.redirect('/post/${id}') 704 } 705 706 @['/api/post/pin'; post] 707 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 708 user := app.whoami(mut ctx) or { 709 - ctx.error('not logged in!') 710 - return ctx.redirect('/login') 711 } 712 713 if user.admin { 714 if !app.pin_post(id) { 715 eprintln('failed to pin post: ${id}') 716 - ctx.error('failed to pin post') 717 - return ctx.redirect('/post/${id}') 718 } 719 - return ctx.redirect('/post/${id}') 720 } else { 721 - ctx.error('insufficient permissions!') 722 eprintln('insufficient perms to pin post: ${id} (${user.id})') 723 - return ctx.redirect('/') 724 } 725 } 726 727 @['/api/post/get/<id>'; get] 728 fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 729 if !app.config.instance.public_data { 730 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 731 } 732 post := app.get_post_by_id(id) or { return ctx.text('no such post') } 733 return ctx.json[Post](post) ··· 735 736 @['/api/post/search'; get] 737 fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 738 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 739 if limit >= search_hard_limit { 740 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 741 } ··· 748 @['/api/site/set_motd'; post] 749 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 750 user := app.whoami(mut ctx) or { 751 - ctx.error('not logged in!') 752 - return ctx.redirect('/login') 753 } 754 755 if user.admin { 756 if !app.set_motd(motd) { 757 - ctx.error('failed to set motd') 758 eprintln('failed to set motd: ${motd}') 759 - return ctx.redirect('/') 760 } 761 println('set motd to: ${motd}') 762 - return ctx.redirect('/') 763 } else { 764 - ctx.error('insufficient permissions!') 765 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 766 - return ctx.redirect('/') 767 } 768 }
··· 10 // search_hard_limit is the maximum limit for a search query, used to prevent 11 // people from requesting searches with huge limits and straining the SQL server 12 pub const search_hard_limit = 50 13 + pub const not_logged_in_msg = 'you are not logged in!' 14 15 ////// user ////// 16 ··· 30 'remoteip': ctx.ip() 31 'response': token 32 }) or { 33 + return ctx.server_error('failed to post hcaptcha response: ${err}') 34 } 35 data := json.decode(HcaptchaResponse, response.body) or { 36 + return ctx.server_error('failed to decode hcaptcha response: ${err}') 37 } 38 if !data.success { 39 + return ctx.server_error('failed to verify hcaptcha: ${data}') 40 } 41 } 42 43 if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { 44 + return ctx.server_error('invalid invite code') 45 } 46 47 if app.get_user_by_name(username) != none { 48 + return ctx.server_error('username taken') 49 } 50 51 // validate username 52 if !app.validators.username.validate(username) { 53 + return ctx.server_error('invalid username') 54 } 55 56 // validate password 57 if !app.validators.password.validate(password) { 58 + return ctx.server_error('invalid password') 59 } 60 61 if password != ctx.form['confirm-password'] { 62 + return ctx.server_error('passwords do not match') 63 } 64 65 salt := auth.generate_salt() ··· 77 app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 78 app.config.welcome.body.replace('%s', x.get_name())) 79 token := app.auth.add_token(x.id) or { 80 + eprintln('api_user_register: could not create token for user with id ${x.id}: ${err}') 81 + return ctx.server_error('could not create token for user') 82 } 83 ctx.set_cookie( 84 name: 'token' ··· 89 ) 90 } else { 91 eprintln('api_user_register: could not log into newly-created user: ${user}') 92 + return ctx.server_error('could not log into newly-created user.') 93 } 94 95 + return ctx.ok('user registered') 96 } 97 98 @['/api/user/set_username'; post] 99 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 100 user := app.whoami(mut ctx) or { 101 + return ctx.unauthorized(not_logged_in_msg) 102 } 103 104 if app.get_user_by_name(new_username) != none { 105 + return ctx.server_error('username taken') 106 } 107 108 // validate username 109 if !app.validators.username.validate(new_username) { 110 + return ctx.server_error('invalid username') 111 } 112 113 if !app.set_username(user.id, new_username) { 114 + return ctx.server_error('failed to update username') 115 } 116 117 + return ctx.ok('username updated') 118 } 119 120 @['/api/user/set_password'; post] 121 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 122 user := app.whoami(mut ctx) or { 123 + return ctx.unauthorized(not_logged_in_msg) 124 } 125 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 127 + return ctx.server_error('current_password is incorrect') 128 } 129 130 // validate password 131 if !app.validators.password.validate(new_password) { 132 + return ctx.server_error('invalid password') 133 + } 134 + 135 + if new_password != ctx.form['confirm_password'] { 136 + return ctx.server_error('passwords do not match') 137 } 138 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 140 if !app.set_password(user.id, hashed_new_password) { 141 + return ctx.server_error('failed to update password') 142 } 143 144 // invalidate tokens and log out 145 app.auth.delete_tokens_for_user(user.id) or { 146 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 147 + return ctx.server_error('failed to delete tokens during password deletion') 148 } 149 ctx.set_cookie( 150 name: 'token' ··· 154 path: '/' 155 ) 156 157 + return ctx.ok('password updated') 158 } 159 160 @['/api/user/login'; post] 161 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 162 user := app.get_user_by_name(username) or { 163 + return ctx.server_error('invalid credentials') 164 } 165 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 167 + return ctx.server_error('invalid credentials') 168 } 169 170 token := app.auth.add_token(user.id) or { 171 eprintln('failed to add token on log in: ${err}') 172 + return ctx.server_error('could not create token for user with id ${user.id}') 173 } 174 175 ctx.set_cookie( ··· 180 path: '/' 181 ) 182 183 + return ctx.ok('logged in') 184 } 185 186 + @['/api/user/logout'; post] 187 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 188 if token := ctx.get_cookie('token') { 189 if user := app.get_user_by_token(token) { ··· 193 // } 194 app.auth.delete_tokens_for_value(token) or { 195 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 196 } 197 } else { 198 eprintln('failed to get user for token for logout') ··· 209 path: '/' 210 ) 211 212 + return ctx.ok('logged out') 213 } 214 215 + @['/api/user/full_logout'; post] 216 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 217 if token := ctx.get_cookie('token') { 218 if user := app.get_user_by_token(token) { 219 app.auth.delete_tokens_for_user(user.id) or { 220 eprintln('failed to yeet tokens for ${user.id}') 221 } 222 } else { 223 eprintln('failed to get user for token for full_logout') ··· 234 path: '/' 235 ) 236 237 + return ctx.ok('logged out') 238 } 239 240 @['/api/user/set_nickname'; post] 241 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 242 user := app.whoami(mut ctx) or { 243 + return ctx.unauthorized(not_logged_in_msg) 244 } 245 246 mut clean_nickname := ?string(nickname.trim_space()) ··· 250 251 // validate 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 253 + return ctx.server_error('invalid nickname') 254 } 255 256 if !app.set_nickname(user.id, clean_nickname) { 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 258 + return ctx.server_error('failed to update nickname') 259 } 260 261 + return ctx.ok('updated nickname') 262 } 263 264 @['/api/user/set_muted'; post] 265 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 266 user := app.whoami(mut ctx) or { 267 + return ctx.unauthorized(not_logged_in_msg) 268 } 269 270 to_mute := app.get_user_by_id(id) or { 271 + return ctx.server_error('no such user') 272 } 273 274 if user.admin { 275 if !app.set_muted(to_mute.id, muted) { 276 + return ctx.server_error('failed to change mute status') 277 } 278 + return ctx.ok('muted user') 279 } else { 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 281 + return ctx.unauthorized('insufficient permissions') 282 } 283 } 284 285 @['/api/user/set_automated'; post] 286 fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 287 user := app.whoami(mut ctx) or { 288 + return ctx.unauthorized(not_logged_in_msg) 289 } 290 291 if !app.set_automated(user.id, is_automated) { 292 + return ctx.server_error('failed to set automated status.') 293 } 294 295 + if is_automated { 296 + return ctx.ok('you\'re now a bot! :D') 297 + } else { 298 + return ctx.ok('you\'re no longer a bot :(') 299 + } 300 } 301 302 @['/api/user/set_theme'; post] 303 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 304 if !app.config.instance.allow_changing_theme { 305 + return ctx.server_error('this instance disallows changing themes :(') 306 } 307 308 user := app.whoami(mut ctx) or { 309 + return ctx.unauthorized(not_logged_in_msg) 310 } 311 312 mut theme := ?string(none) 313 + if url.trim_space() == '' { 314 + theme = app.config.instance.default_theme 315 + } else { 316 theme = url.trim_space() 317 } 318 319 if !app.set_theme(user.id, theme) { 320 + return ctx.server_error('failed to change theme') 321 } 322 323 + return ctx.ok('theme updated') 324 + } 325 + 326 + @['/api/user/set_css'; post] 327 + fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { 328 + if !app.config.instance.allow_changing_theme { 329 + return ctx.server_error('this instance disallows changing themes :(') 330 + } 331 + 332 + user := app.whoami(mut ctx) or { 333 + return ctx.unauthorized(not_logged_in_msg) 334 + } 335 + 336 + c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() } 337 + 338 + if !app.set_css(user.id, c) { 339 + return ctx.server_error('failed to change css') 340 + } 341 + 342 + return ctx.ok('css updated') 343 } 344 345 @['/api/user/set_pronouns'; post] 346 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 347 user := app.whoami(mut ctx) or { 348 + return ctx.unauthorized(not_logged_in_msg) 349 } 350 351 clean_pronouns := pronouns.trim_space() 352 if !app.validators.pronouns.validate(clean_pronouns) { 353 + return ctx.server_error('invalid pronouns') 354 } 355 356 if !app.set_pronouns(user.id, clean_pronouns) { 357 + return ctx.server_error('failed to change pronouns') 358 } 359 360 + return ctx.ok('pronouns updated') 361 } 362 363 @['/api/user/set_bio'; post] 364 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 365 user := app.whoami(mut ctx) or { 366 + return ctx.unauthorized(not_logged_in_msg) 367 } 368 369 clean_bio := bio.trim_space() 370 if !app.validators.user_bio.validate(clean_bio) { 371 + return ctx.server_error('invalid bio') 372 } 373 374 if !app.set_bio(user.id, clean_bio) { 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 376 + return ctx.server_error('failed to update bio') 377 } 378 379 + return ctx.ok('bio updated') 380 } 381 382 + @['/api/user/get_name'; get] 383 fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 384 + if !app.config.instance.public_data { 385 + _ := app.whoami(mut ctx) or { 386 + return ctx.unauthorized('no such user') 387 + } 388 + } 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 390 return ctx.text(user.get_name()) 391 } 392 393 + @['/api/user/delete'; post] 394 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 395 user := app.whoami(mut ctx) or { 396 + return ctx.unauthorized(not_logged_in_msg) 397 } 398 399 if user.admin || user.id == id { 400 + println('attempting to delete ${id} as ${user.id}') 401 + 402 // yeet 403 if !app.delete_user(user.id) { 404 + return ctx.server_error('failed to delete user: ${id}') 405 } 406 407 app.auth.delete_tokens_for_user(id) or { ··· 418 ) 419 } 420 println('deleted user ${id}') 421 + return ctx.ok('user deleted') 422 } else { 423 + return ctx.unauthorized('be nice. deleting other users is off-limits.') 424 } 425 } 426 427 @['/api/user/search'; get] 428 fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 429 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 430 if limit >= search_hard_limit { 431 + return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') 432 } 433 users := app.search_for_users(query, limit, offset) 434 return ctx.json[[]User](users) ··· 436 437 @['/api/user/whoami'; get] 438 fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 439 + user := app.whoami(mut ctx) or { 440 + return ctx.unauthorized(not_logged_in_msg) 441 + } 442 return ctx.text(user.username) 443 } 444 445 /// user/notification /// 446 447 + @['/api/user/notification/clear'; post] 448 fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 449 user := app.whoami(mut ctx) or { 450 + return ctx.unauthorized(not_logged_in_msg) 451 } 452 453 if notification := app.get_notification_by_id(id) { 454 if notification.user_id != user.id { 455 + return ctx.server_error('no such notification for user') 456 + } else if !app.delete_notification(id) { 457 + return ctx.server_error('failed to delete notification') 458 } 459 } else { 460 + return ctx.server_error('no such notification for user') 461 } 462 463 + return ctx.ok('cleared notification') 464 } 465 466 + @['/api/user/notification/clear_all'; post] 467 fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 468 user := app.whoami(mut ctx) or { 469 + return ctx.unauthorized(not_logged_in_msg) 470 } 471 if !app.delete_notifications_for_user(user.id) { 472 + return ctx.server_error('failed to delete notifications') 473 } 474 + return ctx.ok('cleared notifications') 475 } 476 477 ////// post ////// ··· 479 @['/api/post/new_post'; post] 480 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 481 user := app.whoami(mut ctx) or { 482 + return ctx.unauthorized(not_logged_in_msg) 483 } 484 485 if user.muted { 486 + return ctx.server_error('you are muted!') 487 } 488 489 // validate title 490 if !app.validators.post_title.validate(title) { 491 + return ctx.server_error('invalid title') 492 } 493 494 // validate body 495 if !app.validators.post_body.validate(body) { 496 + return ctx.server_error('invalid body') 497 } 498 499 nsfw := 'nsfw' in ctx.form 500 if nsfw && !app.config.post.allow_nsfw { 501 + return ctx.server_error('nsfw posts are not allowed on this instance') 502 } 503 504 mut post := Post{ ··· 511 if replying_to != 0 { 512 // check if replying post exists 513 app.get_post_by_id(replying_to) or { 514 + return ctx.server_error('the post you are trying to reply to does not exist') 515 } 516 post.replying_to = replying_to 517 } 518 519 if !app.add_post(post) { 520 println('failed to post: ${post} from user ${user.id}') 521 + return ctx.server_error('failed to post') 522 } 523 524 + //TODO: Can I not just get the ID directly?? This method feels dicey at best. 525 // find the post's id to process mentions with 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 527 app.process_post_mentions(x) 528 + return ctx.ok('posted. id=${x.id}') 529 } else { 530 + eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}') 531 + return ctx.server_error('failed to get post ID, this error should never happen') 532 } 533 } 534 535 @['/api/post/delete'; post] 536 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 537 user := app.whoami(mut ctx) or { 538 + return ctx.unauthorized(not_logged_in_msg) 539 } 540 541 post := app.get_post_by_id(id) or { 542 + return ctx.server_error('post does not exist') 543 } 544 545 if user.admin || user.id == post.author_id { 546 if !app.delete_post(post.id) { 547 + eprintln('api_post_delete: failed to delete post: ${id}') 548 + return ctx.server_error('failed to delete post') 549 } 550 println('deleted post: ${id}') 551 + return ctx.ok('post deleted') 552 } else { 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 554 + return ctx.unauthorized('insufficient permissions') 555 } 556 } 557 558 + @['/api/post/like'; post] 559 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 560 + user := app.whoami(mut ctx) or { 561 + return ctx.unauthorized(not_logged_in_msg) 562 + } 563 564 + post := app.get_post_by_id(id) or { 565 + return ctx.server_error('post does not exist') 566 + } 567 568 if app.does_user_like_post(user.id, post.id) { 569 if !app.unlike_post(post.id, user.id) { ··· 592 } 593 } 594 595 + @['/api/post/dislike'; post] 596 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 597 + user := app.whoami(mut ctx) or { 598 + return ctx.unauthorized(not_logged_in_msg) 599 + } 600 601 + post := app.get_post_by_id(id) or { 602 + return ctx.server_error('post does not exist') 603 + } 604 605 if app.does_user_dislike_post(user.id, post.id) { 606 if !app.unlike_post(post.id, user.id) { ··· 629 } 630 } 631 632 + @['/api/post/save'; post] 633 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 634 + user := app.whoami(mut ctx) or { 635 + return ctx.unauthorized(not_logged_in_msg) 636 + } 637 638 if app.get_post_by_id(id) != none { 639 if app.toggle_save_post(user.id, id) { ··· 646 } 647 } 648 649 + @['/api/post/save_for_later'; post] 650 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 651 + user := app.whoami(mut ctx) or { 652 + return ctx.unauthorized(not_logged_in_msg) 653 + } 654 655 if app.get_post_by_id(id) != none { 656 if app.toggle_save_for_later_post(user.id, id) { ··· 663 } 664 } 665 666 + @['/api/post/get_title'; get] 667 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 668 if !app.config.instance.public_data { 669 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 670 } 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 672 return ctx.text(post.title) ··· 675 @['/api/post/edit'; post] 676 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 677 user := app.whoami(mut ctx) or { 678 + return ctx.unauthorized(not_logged_in_msg) 679 } 680 post := app.get_post_by_id(id) or { 681 + return ctx.server_error('no such post') 682 } 683 if post.author_id != user.id { 684 + return ctx.unauthorized('insufficient permissions') 685 + } 686 + 687 + nsfw := if 'nsfw' in ctx.form { 688 + app.config.post.allow_nsfw 689 + } else { 690 + post.nsfw 691 } 692 693 + if !app.update_post(id, title, body, nsfw) { 694 eprintln('failed to update post') 695 + return ctx.server_error('failed to update post') 696 } 697 698 + return ctx.ok('posted edited') 699 } 700 701 @['/api/post/pin'; post] 702 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 703 user := app.whoami(mut ctx) or { 704 + return ctx.unauthorized(not_logged_in_msg) 705 } 706 707 if user.admin { 708 if !app.pin_post(id) { 709 eprintln('failed to pin post: ${id}') 710 + return ctx.server_error('failed to pin post') 711 } 712 + return ctx.ok('post pinned') 713 } else { 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 715 + return ctx.unauthorized('insufficient permissions') 716 } 717 } 718 719 @['/api/post/get/<id>'; get] 720 fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 721 if !app.config.instance.public_data { 722 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 723 } 724 post := app.get_post_by_id(id) or { return ctx.text('no such post') } 725 return ctx.json[Post](post) ··· 727 728 @['/api/post/search'; get] 729 fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 730 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 731 if limit >= search_hard_limit { 732 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 733 } ··· 740 @['/api/site/set_motd'; post] 741 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 742 user := app.whoami(mut ctx) or { 743 + return ctx.unauthorized(not_logged_in_msg) 744 } 745 746 if user.admin { 747 if !app.set_motd(motd) { 748 eprintln('failed to set motd: ${motd}') 749 + return ctx.server_error('failed to set motd') 750 } 751 println('set motd to: ${motd}') 752 + return ctx.ok('motd updated') 753 } else { 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 755 + return ctx.unauthorized('insufficient permissions') 756 } 757 }
+4 -4
src/webapp/app.v
··· 11 veb.StaticHandler 12 DatabaseAccess 13 pub: 14 - config Config 15 - commit string = @VMODHASH 16 - built_at string = @BUILD_TIMESTAMP 17 - v_hash string = @VHASH 18 pub mut: 19 auth auth.Auth[pg.DB] 20 validators struct {
··· 11 veb.StaticHandler 12 DatabaseAccess 13 pub: 14 + config Config 15 + buildinfo BuildInfo 16 + built_at string = @BUILD_TIMESTAMP 17 + v_hash string = @VHASH 18 pub mut: 19 auth auth.Auth[pg.DB] 20 validators struct {
+20
src/webapp/config.v
··· 12 name string 13 welcome string 14 default_theme string 15 allow_changing_theme bool 16 version string 17 source string 18 invite_only bool 19 invite_code string 20 public_data bool 21 } 22 http struct { 23 pub mut: ··· 83 config.instance.name = loaded_instance.get('name').to_str() 84 config.instance.welcome = loaded_instance.get('welcome').to_str() 85 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 86 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 87 config.instance.version = loaded_instance.get('version').to_str() 88 config.instance.source = loaded_instance.get('source').to_str() 89 config.instance.invite_only = loaded_instance.get('invite_only').to_bool() 90 config.instance.invite_code = loaded_instance.get('invite_code').to_str() 91 config.instance.public_data = loaded_instance.get('public_data').to_bool() 92 93 loaded_http := loaded.get('http') 94 config.http.port = loaded_http.get('port').to_int() ··· 137 138 return config 139 }
··· 12 name string 13 welcome string 14 default_theme string 15 + default_css string 16 allow_changing_theme bool 17 version string 18 source string 19 + v_source string 20 invite_only bool 21 invite_code string 22 public_data bool 23 + owner_username string 24 } 25 http struct { 26 pub mut: ··· 86 config.instance.name = loaded_instance.get('name').to_str() 87 config.instance.welcome = loaded_instance.get('welcome').to_str() 88 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 89 + config.instance.default_css = loaded_instance.get('default_css').to_str() 90 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 91 config.instance.version = loaded_instance.get('version').to_str() 92 config.instance.source = loaded_instance.get('source').to_str() 93 + config.instance.v_source = loaded_instance.get('v_source').to_str() 94 config.instance.invite_only = loaded_instance.get('invite_only').to_bool() 95 config.instance.invite_code = loaded_instance.get('invite_code').to_str() 96 config.instance.public_data = loaded_instance.get('public_data').to_bool() 97 + config.instance.owner_username = loaded_instance.get('owner_username').to_str() 98 99 loaded_http := loaded.get('http') 100 config.http.port = loaded_http.get('port').to_int() ··· 143 144 return config 145 } 146 + 147 + pub struct BuildInfo { 148 + pub mut: 149 + commit string 150 + } 151 + 152 + pub fn load_buildinfo_from(file_path string) BuildInfo { 153 + loaded := maple.load_file(file_path) or { panic(err) } 154 + mut buildinfo := BuildInfo{} 155 + 156 + buildinfo.commit = loaded.get('commit').to_str() 157 + 158 + return buildinfo 159 + }
+7 -1
src/webapp/pages.v
··· 234 235 @['/about'] 236 fn (mut app App) about(mut ctx Context) veb.Result { 237 - user := app.whoami(mut ctx) or { User{} } 238 ctx.title = '${app.config.instance.name} - about' 239 return $veb.html('../templates/about.html') 240 }
··· 234 235 @['/about'] 236 fn (mut app App) about(mut ctx Context) veb.Result { 237 + user := app.whoami(mut ctx) or { 238 + if !app.config.instance.public_data { 239 + ctx.error('not logged in') 240 + return ctx.redirect('/login') 241 + } 242 + User{} 243 + } 244 ctx.title = '${app.config.instance.name} - about' 245 return $veb.html('../templates/about.html') 246 }