a mini social media app for small communities

Compare changes

Choose any two refs to compare.

+18
.dockerignore
··· 1 + # Binaries 2 + /beep 3 + /build/ 4 + 5 + # Editor/system specific metadata 6 + .DS_Store 7 + .vscode/ 8 + 9 + # Secrets 10 + /config.real.maple 11 + .env 12 + 13 + # Local V and Clockwork install (Gitpod) 14 + /clockwork 15 + /v/ 16 + 17 + # Quick notes I keep while developing 18 + /stickynote.md
+4 -1
.gitignore
··· 1 1 # Binaries 2 2 /beep 3 3 /build/ 4 + /scripts/fetchbuildinfo 4 5 5 6 # Editor/system specific metadata 6 7 .DS_Store ··· 10 11 /config.real.maple 11 12 .env 12 13 14 + # Build data 15 + /buildinfo.maple 16 + 13 17 # Local V and Clockwork install (Gitpod) 14 18 /clockwork 15 19 /v/ 16 - /clockwork/ 17 20 18 21 # Quick notes I keep while developing 19 22 /stickynote.md
+43
Dockerfile
··· 1 + FROM debian:trixie-slim 2 + 3 + # Create beep group and user 4 + RUN <<EOF 5 + set -eux 6 + groupadd -r beep 7 + useradd -r -g beep beep -d /beep -s /bin/sh 8 + install -vd -o beep -g beep -m 1777 /beep 9 + EOF 10 + 11 + # Install base packages. These might already be installed by the image. 12 + RUN <<EOF 13 + set -eux 14 + apt update 15 + apt install -y --no-install-recommends \ 16 + ca-certificates build-essential git libpq-dev 17 + EOF 18 + 19 + # Install V 20 + RUN <<EOF 21 + set -eux 22 + git clone --depth=1 https://github.com/vlang/v /opt/v 23 + cd /opt/v 24 + make 25 + ln -s /opt/v/v /usr/local/bin/v 26 + EOF 27 + 28 + USER beep 29 + WORKDIR /beep 30 + COPY . . 31 + 32 + # Install beep 33 + RUN <<EOF 34 + set -eux 35 + # git clone --depth=1 https://tangled.org/emmeline.girlkisser.top/beep . 36 + mkdir -p ~/.vmodules/emmathemartian/maple 37 + git clone --depth=1 https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple 38 + v -cflags "-O3 -flto" . # compiling with -prod causes ORM errors. 39 + EOF 40 + 41 + STOPSIGNAL SIGINT 42 + EXPOSE 8008 43 + CMD ["./beep"]
-12
beep.service
··· 1 - [Unit] 2 - Description=beep server 3 - After=network.target 4 - 5 - [Service] 6 - ExecStart=/usr/local/bin/v run . config.real.maple 7 - Restart=always 8 - User=beep 9 - WorkingDirectory=/home/beep/beep 10 - 11 - [Install] 12 - WantedBy=multi-user.target
+31 -14
build.maple
··· 1 1 plugins = [ 'v' ] 2 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 + 3 10 task:db.init = { 4 11 description = 'Initialize and start a local Postgres database via Docker' 5 12 category = 'db' ··· 9 16 -e POSTGRES_USER=beep \ 10 17 -e POSTGRES_PASSWORD=beep \ 11 18 --mount source=beep-data,target=/var/lib/postgresql/data \ 12 - -p 5432:5432 \ 13 - postgres:15' 19 + -p 127.0.0.1:5432:5432 \ 20 + postgres:17' 14 21 } 15 22 16 23 task:db.start = { ··· 43 50 run = 'docker rm beep-database && docker volume rm beep-data' 44 51 } 45 52 53 + // Ngrok 54 + 46 55 task:ngrok = { 47 56 description = 'Open an ngrok tunnel for testing.' 48 57 category = 'misc' ··· 55 64 run = 'ngrok http --url=${args} http://localhost:8008' 56 65 } 57 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 + 58 83 task:run.watch = { 59 84 description = 'Watch/run beep' 60 85 category = 'run' 86 + depends = [':fetch-build-info'] 61 87 run = '${v} -d veb_livereload watch run ${v_main} config.maple' 62 88 } 63 89 64 90 task:run.watch.real = { 65 91 description = 'Watch/run beep using config.real.maple' 66 92 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' 93 + depends = [':fetch-build-info'] 94 + run = '${v} watch run ${v_main}' 74 95 } 75 96 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 - } 97 + // Misc 81 98 82 99 task:cloc = { 83 100 description = 'Get the lines of code for beep!'
+25 -6
compose.yml
··· 1 - version: "3" 2 1 volumes: 3 2 beep-data: 4 - beep-data-export: 3 + 5 4 services: 6 5 beep-database: 7 - image: docker.io/postgres:15-alpine 6 + image: postgres:17 8 7 container_name: beep-database 9 8 ports: 10 - - 5432:5432 9 + - 127.0.0.1:5432:5432 11 10 environment: 12 11 - POSTGRES_DB=beep 13 12 - POSTGRES_USER=beep 14 - - POSTGRES_PASSWORD=beep 13 + - POSTGRES_PASSWORD=beep # CHANGE THIS 15 14 volumes: 16 15 - beep-data:/var/lib/postgresql/data 17 - - beep-data-export:/export 16 + restart: on-failure:3 17 + healthcheck: 18 + test: ["CMD", "pg_isready", "-d", "postgresql://localhost:5432", "-U", "beep"] 19 + interval: 30s 20 + timeout: 10s 21 + retries: 5 22 + 23 + beep: 24 + build: . 25 + container_name: beep 26 + depends_on: 27 + beep-database: 28 + condition: service_healthy 29 + restart: true 30 + ports: 31 + - 8008:8008 32 + volumes: 33 + - type: bind 34 + source: ${PWD}/config.real.maple 35 + target: /beep/config.real.maple 36 + restart: on-failure:3
+40 -12
config.maple
··· 1 + // Toggles developer mode; when true, allows access to the admin panel for all users. 1 2 dev_mode = false 3 + // Path to the static directory. You shouldn't ever need to change this. 2 4 static_path = 'src/static' 3 5 6 + // General instance settings 4 7 instance = { 8 + // Instance version. This is shown on the about page. 9 + version = '2025.12' 10 + 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. 5 18 name = 'beep' 19 + // The welcome message to show on the homepage. 6 20 welcome = 'welcome to beep!' 7 21 8 - default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css' 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. 9 28 allow_changing_theme = true 10 29 11 - // instance version 12 - version = '2025.01' 13 - 14 - // set this to '' if your instance is closed source (twt) 15 - source = 'https://github.com/emmathemartian/beep' 16 - 17 - // toggle to `true` to require that users have an invite code to register 30 + // Toggle to require that users have the invite code to register. 18 31 invite_only = false 32 + // Invite code. You can change this at any time. 19 33 invite_code = '' 20 34 21 - // toggle to `true` to allow any non-logged-in user to view data (posts, users, etc) 35 + // Toggle to allow any non-logged-in user to view data (posts, users, etc) 22 36 public_data = false 37 + 38 + // Owner's username. This is linked on the about page. Leave empty to disable. 39 + owner_username = '' 23 40 } 24 41 25 42 http = { 26 43 port = 8008 27 44 } 28 45 46 + // Database settings. 29 47 postgres = { 30 - host = 'localhost' 48 + // Name of database container in compose.yml 49 + host = 'beep-database' 31 50 port = 5432 32 51 user = 'beep' 33 - password = 'beep' 52 + password = 'beep' // TODO: Read from .env 34 53 db = 'beep' 35 54 } 36 55 37 56 hcaptcha = { 57 + // Toggles if hcaptcha is enabled. 38 58 enabled = false 39 - secret = '' 59 + secret = '' // TODO: Read from .env 40 60 site_key = '' 41 61 } 42 62 63 + // Post settings. 43 64 post = { 44 65 title_min_len = 1 45 66 title_max_len = 50 ··· 48 69 body_min_len = 1 49 70 body_max_len = 1000 50 71 body_pattern = '.*' 72 + 73 + // Whether or not posts can be marked as NSFW. 74 + allow_nsfw = true 51 75 } 52 76 77 + // User settings. 53 78 user = { 54 79 username_min_len = 3 55 80 username_max_len = 20 ··· 72 97 bio_pattern = '.*' 73 98 } 74 99 100 + // Welcome notification settings. 75 101 welcome = { 102 + // Title of the notification. 76 103 summary = 'welcome!' 104 + // Notification body text. %s is replaced with the user's name. 77 105 body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 78 106 }
+3
doc/database_spec.md
··· 20 20 | `admin` | bool | controls whether or not this user is an admin | 21 21 | `automated` | bool | controls whether or not this user is automated | 22 22 | `theme` | ?string | controls per-user css themes | 23 + | `css` | ?string | controls per-user css | 23 24 | `bio` | string | bio for this user | 24 25 | `pronouns` | string | pronouns for this user | 25 26 | `created_at` | time.Time | a timestamp of when this user was made | ··· 35 36 | `replying_to` | ?int | id of the post that this post is replying to | 36 37 | `title` | string | the title of this post | 37 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 | 38 41 | `posted_at` | time.Time | a timestamp of when this post was made | 39 42 40 43 ## `Like`
+2
doc/resources.md
··· 21 21 - https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like 22 22 - helped me develop the initial search system, which is subject to be 23 23 overhauled, but for now, this helped a lot. 24 + - https://stackoverflow.com/questions/1237725/copying-postgresql-database-to-another-server 25 + - database migrations 24 26 25 27 ## sql security 26 28
+5 -6
doc/themes.md
··· 30 30 31 31 ## beep-specific 32 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? 33 + | name | source | css theme url | 34 + |---------|----------------------------------------------------|----------------------------| 35 + | default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css | 38 36 39 37 ## built-in 40 38 41 39 | name | based on (if applicable) | css theme url | 42 40 |-----------------------------|---------------------------------|---------------------------------| 41 + | default | n/a | default.css | 43 42 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 44 43 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 45 44 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 48 47 > beep also features some built-in themes, some of which are based on the themes 49 48 > present in the "it just works" list! 50 49 51 - > make sure to prefix the url with `<instance url>/static/themes/` 50 + > make sure to prefix the url with `/static/themes/`
+14
etc/beep-db.service
··· 1 + [Unit] 2 + Description=beep database 3 + Requires=docker.service 4 + After=docker.service 5 + 6 + [Service] 7 + Type=oneshot 8 + ExecStart=/usr/bin/docker start beep-database 9 + ExecStop=/usr/bin/docker stop beep-database 10 + RemainAfterExit=yes 11 + WorkingDirectory=/home/beep/beep 12 + 13 + [Install] 14 + WantedBy=multi-user.target
+16
etc/beep.service
··· 1 + [Unit] 2 + Description=beep server 3 + Requires=beep-db.service 4 + After=beep-db.service 5 + 6 + [Service] 7 + ExecStart=/usr/local/bin/v run . config.real.maple 8 + Restart=always 9 + User=beep 10 + WorkingDirectory=/home/beep/beep 11 + StandardOutput=journal 12 + StandardError=journal 13 + LimitNOFILE=65536 14 + 15 + [Install] 16 + WantedBy=multi-user.target
+22 -22
readme
··· 13 13 hosting 14 14 ------- 15 15 16 - You'll need a PostgreSQL database somewhere, along with V 17 - to compile beep: 16 + [WARNING] 17 + Do not compile with -prod. V's AST optimizations break 18 + something in the ORM and cause assorted errors. Instead, 19 + use `-cflags "-O3 -flto"` 18 20 19 - $ git clone https://github.com/emmathemartian/beep 21 + $ git clone https://tangled.org/emmeline.girlkisser.top/beep 20 22 $ cd beep 21 - $ v -prod . 22 - 23 - Copy the `config.maple` as `config.real.maple` 24 - 25 23 $ cp config.maple config.real.maple 26 24 27 - Edit `config.real.maple` to set the URL, port, DB username, 28 - password, and name. 25 + Edit config.real.maple to set ports, auth, etc. 29 26 30 27 `config.real.maple` also has settings to configure the 31 28 default theme, post length, username length, welcome 32 29 messages, etc etc. 33 30 34 - WARNING: DO NOT PUT SECRETS IN `config.maple`. 35 - `config.maple` is intended to be pushed to Git as a 36 - "template config." Instead, use `config.real.maple` if you 37 - plan to push anywhere. It's gitignored by default, i.e, an 38 - accidental exclusion in the gitignore and push won't expose 39 - your keys. 31 + [WARNING] DO NOT PUT SECRETS IN config.maple 32 + config.maple is intended to be pushed to Git as a template 33 + config for your instance. Instead, put your secrets in 34 + config.real.maple, which is gitignored. 35 + TODO: Read secrets from .env automatically. 40 36 41 - $ ./beep config.real.maple 37 + With Docker: 38 + $ docker compose up 42 39 43 - Then go to the configured port to view (default is 44 - `http://localhost:8008`). 40 + Without Docker: 41 + (assumes you already have a database somewhere) 42 + $ v install EmmaTheMartian.Maple 43 + $ v -cflags "-O3 -flto" . 44 + $ ./beep 45 45 46 - If you don't have a database, you can either self-host a 47 - psql database on your machine, or you can find a free one 48 - online. I like [neon.tech](https://neon.tech), their free 49 - plan is pretty comfortable for a small beep instance! 46 + If `v install ...` fails then you can install Maple 47 + manually: 48 + $ mkdir -p ~/.vmodules/emmathemartian/maple 49 + $ git clone https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple
+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 + })!
+12 -7
src/auth/auth.v
··· 1 1 // From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v 2 2 // The original file's source is licensed under MIT. 3 3 4 + // ~~ 4 5 // This fork re-introduces the `ip` field of each token for additional security, 5 6 // along with delete_tokens_for_ip 7 + // ~~ 8 + // IP has been removed since IPs can change randomly and it causes you to need 9 + // to relog wayyyy more often. I'm keeping this fork just in case I do need to 10 + // change the auth system in the future. 6 11 7 12 module auth 8 13 ··· 22 27 id int @[primary; sql: serial] 23 28 user_id int 24 29 value string 25 - ip string 26 30 } 27 31 28 32 pub fn new[T](db T) Auth[T] { ··· 35 39 } 36 40 } 37 41 38 - pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string { 42 + pub fn (mut app Auth[T]) add_token(user_id int) !string { 39 43 mut uuid := rand.uuid_v4() 40 44 token := Token{ 41 45 user_id: user_id 42 46 value: uuid 43 - ip: ip 44 47 } 45 48 sql app.db { 46 49 insert token into Token ··· 48 51 return uuid 49 52 } 50 53 51 - pub fn (app &Auth[T]) find_token(value string, ip string) ?Token { 54 + pub fn (app &Auth[T]) find_token(value string) ?Token { 52 55 tokens := sql app.db { 53 - select from Token where value == value && ip == ip limit 1 56 + select from Token where value == value limit 1 54 57 } or { []Token{} } 55 58 if tokens.len == 0 { 56 59 return none ··· 58 61 return tokens.first() 59 62 } 60 63 64 + // logs out of all devices 61 65 pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! { 62 66 sql app.db { 63 67 delete from Token where user_id == user_id 64 68 }! 65 69 } 66 70 67 - pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! { 71 + // logs out of one device 72 + pub fn (mut app Auth[T]) delete_tokens_for_value(value string) ! { 68 73 sql app.db { 69 - delete from Token where ip == ip 74 + delete from Token where value == value 70 75 }! 71 76 } 72 77
+16 -5
src/database/post.v
··· 109 109 110 110 // update_post updates the given post's title and body with the given title and 111 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 { 112 + pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string, new_nsfw bool) bool { 113 113 sql app.db { 114 - update Post set body = new_body, title = new_title where id == post_id 114 + update Post set body = new_body, title = new_title, nsfw = new_nsfw where id == post_id 115 115 } or { 116 116 return false 117 117 } ··· 164 164 // todo: levenshtein distance, query options/filters (user:beep, !excluded-text, 165 165 // etc) 166 166 pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult { 167 - queried_posts := app.db.exec_param_many('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or { 167 + queried_posts := app.db.exec_param_many_result('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or { 168 168 eprintln('search_for_posts error in app.db.error: ${err}') 169 + pg.Result{} 170 + } 171 + posts := queried_posts.rows.map(fn [queried_posts] (it pg.Row) Post { 172 + return Post.from_row(queried_posts, it) 173 + }) 174 + return PostSearchResult.from_post_list(app, posts) 175 + } 176 + 177 + // get_post_count gets the number of posts in the database. 178 + pub fn (app &DatabaseAccess) get_post_count() int { 179 + n := app.db.exec('SELECT COUNT(id) FROM "Post"') or { 180 + eprintln('get_post_count error in app.db.error: ${err}') 169 181 [] 170 182 } 171 - posts := queried_posts.map(|it| Post.from_row(it)) 172 - return PostSearchResult.from_post_list(app, posts) 183 + return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() } 173 184 }
+27 -3
src/database/user.v
··· 2 2 3 3 import entity { User, Notification, Like, LikeCache, Post } 4 4 import util 5 + import db.pg 5 6 6 7 // new_user creates a new user and returns their struct after creation. 7 8 pub fn (app &DatabaseAccess) new_user(user User) ?User { ··· 84 85 update User set theme = theme where id == user_id 85 86 } or { 86 87 eprintln('failed to update theme url for ${user_id}') 88 + return false 89 + } 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}') 87 100 return false 88 101 } 89 102 return true ··· 216 229 // search_for_users searches for posts matching the given query. 217 230 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 218 231 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 219 - queried_users := app.db.exec_param_many('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or { 232 + queried_users := app.db.exec_param_many_result('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or { 220 233 eprintln('search_for_users error in app.db.error: ${err}') 234 + pg.Result{} 235 + } 236 + users := queried_users.rows.map(fn [queried_users] (it pg.Row) User { 237 + return User.from_row(queried_users, it) 238 + }) 239 + return users 240 + } 241 + 242 + // get_user_count gets the number of registered users in the database. 243 + pub fn (app &DatabaseAccess) get_user_count() int { 244 + n := app.db.exec('SELECT COUNT(id) FROM "User"') or { 245 + eprintln('get_user_count error in app.db.error: ${err}') 221 246 [] 222 247 } 223 - users := queried_users.map(|it| User.from_row(it)) 224 - return users 248 + return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() } 225 249 }
+20 -9
src/entity/post.v
··· 14 14 body string 15 15 16 16 pinned bool 17 + nsfw bool 17 18 18 19 posted_at time.Time = time.now() 19 20 } ··· 21 22 // Post.from_row creates a post object from the given database row. 22 23 // see src/database/post.v#search_for_posts for usage. 23 24 @[inline] 24 - pub fn Post.from_row(row pg.Row) Post { 25 + pub fn Post.from_row(res pg.Result, row pg.Row) Post { 26 + // curry some arguments for cleanliness 27 + c := fn [res, row] (key string) ?string { 28 + return util.get_row_col(res, row, key) 29 + } 30 + ct := fn [res, row] (key string) string { 31 + return util.get_row_col_or_throw(res, row, key) 32 + } 33 + 25 34 // this throws a cgen error when put in Post{} 26 35 //todo: report this 27 - posted_at := time.parse(util.or_throw[string](row.vals[6])) or { panic(err) } 36 + posted_at := time.parse(ct('posted_at')) or { panic(err) } 37 + nsfw := util.map_or_throw[string, bool](ct('nsfw'), |it| it.bool()) 28 38 29 39 return Post{ 30 - id: util.or_throw[string](row.vals[0]).int() 31 - author_id: util.or_throw[string](row.vals[1]).int() 32 - replying_to: if row.vals[2] == none { ?int(none) } else { 33 - util.map_or_throw[string, int](row.vals[2], |it| it.int()) 40 + id: ct('id').int() 41 + author_id: ct('author_id').int() 42 + replying_to: if c('replying_to') == none { none } else { 43 + util.map_or_throw[string, int](ct('replying_to'), |it| it.int()) 34 44 } 35 - title: util.or_throw[string](row.vals[3]) 36 - body: util.or_throw[string](row.vals[4]) 37 - pinned: util.map_or_throw[string, bool](row.vals[5], |it| it.bool()) 45 + title: ct('title') 46 + body: ct('body') 47 + pinned: util.map_or_throw[string, bool](ct('pinned'), |it| it.bool()) 48 + nsfw: nsfw 38 49 posted_at: posted_at 39 50 } 40 51 }
+20 -11
src/entity/user.v
··· 18 18 automated bool 19 19 20 20 theme string 21 + css string 21 22 22 23 bio string 23 24 pronouns string ··· 44 45 // User.from_row creates a user object from the given database row. 45 46 // see src/database/user.v#search_for_users for usage. 46 47 @[inline] 47 - pub fn User.from_row(row pg.Row) User { 48 + pub fn User.from_row(res pg.Result, row pg.Row) User { 49 + // curry some arguments for cleanliness 50 + c := fn [res, row] (key string) ?string { 51 + return util.get_row_col(res, row, key) 52 + } 53 + ct := fn [res, row] (key string) string { 54 + return util.get_row_col_or_throw(res, row, key) 55 + } 56 + 48 57 // this throws a cgen error when put in User{} 49 58 //todo: report this 50 - created_at := time.parse(util.or_throw[string](row.vals[10])) or { panic(err) } 59 + created_at := time.parse(ct('created_at')) or { panic(err) } 51 60 52 61 return User{ 53 - id: util.or_throw[string](row.vals[0]).int() 54 - username: util.or_throw[string](row.vals[1]) 55 - nickname: if row.vals[2] == none { ?string(none) } else { 56 - util.or_throw[string](row.vals[2]) 62 + id: ct('id').int() 63 + username: ct('username') 64 + nickname: if c('nickname') == none { none } else { 65 + ct('nickname') 57 66 } 58 67 password: 'haha lol, nope' 59 68 password_salt: 'haha lol, nope' 60 - muted: util.map_or_throw[string, bool](row.vals[5], |it| it.bool()) 61 - admin: util.map_or_throw[string, bool](row.vals[6], |it| it.bool()) 62 - theme: util.or_throw[string](row.vals[7]) 63 - bio: util.or_throw[string](row.vals[8]) 64 - pronouns: util.or_throw[string](row.vals[9]) 69 + muted: util.map_or_throw[string, bool](util.get_row_col(res, row, 'muted'), |it| it.bool()) 70 + admin: util.map_or_throw[string, bool](util.get_row_col(res, row, 'admin'), |it| it.bool()) 71 + theme: ct('theme') 72 + bio: ct('bio') 73 + pronouns: ct('pronouns') 65 74 created_at: created_at 66 75 } 67 76 }
+15 -5
src/main.v
··· 9 9 import beep_sql 10 10 import util 11 11 12 - pub const version = '25.01.0' 13 - 14 12 @[inline] 15 13 fn connect(mut app App) { 16 14 println('-> connecting to database...') ··· 59 57 fn main() { 60 58 mut stopwatch := util.Stopwatch.new() 61 59 62 - config := webapp.load_config_from(os.args[1]) 63 - mut app := &App{ config: config } 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 + } 64 74 65 75 // connect to database 66 76 util.time_it( ··· 98 108 // make the website config, if it does not exist 99 109 app.get_or_create_site_config() 100 110 101 - if config.dev_mode { 111 + if app.config.dev_mode { 102 112 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 103 113 } 104 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 + }
+6 -2
src/static/js/render_body.js
··· 61 61 // give the body a loading """animation""" while we let the fetches cook 62 62 element.innerText = 'loading...' 63 63 64 - const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g) 64 + const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g) 65 65 const cache = {} 66 66 for (const match of matches) { 67 + // escaped 68 + if (match[0][0] == '\\') { 69 + html = html.replace(match[0], match[0].replace('\\', '')) 70 + } 67 71 // mention 68 - if (match[0][0] == '@') { 72 + else if (match[0][0] == '@') { 69 73 if (cache.hasOwnProperty(match[0])) { 70 74 html = html.replace(match[0], cache[match[0]]) 71 75 continue
+26 -2
src/static/style.css
··· 1 + :root { 2 + --c-nsfw-border: red; 3 + } 4 + 1 5 .post, 2 6 .notification { 3 7 border: 2px solid; 4 8 padding: 8px; 5 9 } 6 10 7 - .post p, 8 - .notification p { 11 + .post > p, 12 + .notification > p { 9 13 margin: 0; 14 + } 15 + 16 + .post > pre, 17 + .notification > pre { 18 + margin: 0; 19 + display: inline; 10 20 } 11 21 12 22 .post + .post, ··· 16 26 17 27 pre { 18 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; 19 42 } 20 43 21 44 /* ··· 24 47 */ 25 48 input[hidden] { 26 49 display: none !important; 50 + visibility: none !important; 27 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 + }
+19 -5
src/templates/about.html
··· 3 3 <h1>about this instance</h1> 4 4 5 5 <div> 6 + <p><strong>general:</strong></p> 6 7 <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> 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> 10 18 11 19 @if app.config.instance.source != '' 12 - <p>source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p> 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> 13 27 @end 14 28 </div> 15 29 ··· 17 31 document.getElementById('built_at').innerText = new Date(@{app.built_at} * 1000).toLocaleString() 18 32 </script> 19 33 20 - @include 'partial/footer.html' 34 + @include 'partial/footer.html'
+82
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 + 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 13 + <input 14 + type="number" 15 + name="replying_to" 16 + id="replying_to" 17 + required aria-required 18 + readonly aria-readonly 19 + hidden aria-hidden 20 + value="@replying_to" 21 + > 22 + @end 23 + 24 + @if replying 25 + <input 26 + type="text" 27 + name="title" 28 + id="title" 29 + value="reply to @{replying_to_user.get_name()}" 30 + required aria-required 31 + readonly aria-readonly 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" 40 + id="title" 41 + minlength="@app.config.post.title_min_len" 42 + maxlength="@app.config.post.title_max_len" 43 + pattern="@app.config.post.title_pattern" 44 + placeholder="title" 45 + required aria-required 46 + > 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" 55 + minlength="@app.config.post.body_min_len" 56 + maxlength="@app.config.post.body_max_len" 57 + rows="10" 58 + cols="30" 59 + placeholder="body" 60 + required aria-required 61 + autocomplete="off" aria-autocomplete="off" 62 + ></textarea> 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 74 + 75 + <input type="submit" value="post!"> 76 + </form> 77 + 78 + <script> 79 + add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 80 + add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 81 + </script> 82 + </div>
+3
src/templates/components/post_mini.html
··· 2 2 <p> 3 3 <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>: 4 4 <a href="/post/@post.id">@post.title</a> 5 + @if post.nsfw 6 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 7 + @end 5 8 </p> 6 9 </div>
+16 -4
src/templates/components/post_small.html
··· 1 1 <div class="post post-small"> 2 - <p><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>: <span class="post-title">@post.title</span></p> 3 - @if post.body.len > 50 4 - <p>@{post.body[..50]}...</p> 2 + <p> 3 + <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>: 4 + <span class="post-title">@post.title</span> 5 + @if post.nsfw 6 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 7 + @end 8 + </p> 9 + 10 + @if post.nsfw 11 + <p>view full to see post body</p> 5 12 @else 6 - <p>@post.body</p> 13 + @if post.body.len > 50 14 + <pre id="post-@{post.id}">@{post.body[..50]}...</pre> 15 + @else 16 + <pre id="post-@{post.id}">@post.body</pre> 17 + @end 7 18 @end 19 + 8 20 <p>likes: @{app.get_net_likes_for_post(post.id)} | posted at: @post.posted_at | <a href="/post/@post.id">view full post</a></p> 9 21 </div>
+24 -7
src/templates/edit.html
··· 7 7 <h1>edit post</h1> 8 8 9 9 <div class="post post-full"> 10 - <form action="/api/post/edit" method="post"> 10 + <form action="/api/post/edit" method="post" beep-redirect="/post/@post.id"> 11 11 <input 12 12 type="number" 13 13 name="id" ··· 47 47 >@post.body</textarea> 48 48 <br> 49 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 + 50 67 <input type="submit" value="save"> 51 68 </form> 52 - 53 - <script> 54 - add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 55 - add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 56 - </script> 57 69 </div> 58 70 59 71 <hr> 60 72 61 73 <div> 62 74 <h2>danger zone:</h2> 63 - <form action="/api/post/delete" method="post"> 75 + <form action="/api/post/delete" method="post" beep-redirect="/"> 64 76 <input 65 77 type="number" 66 78 name="id" ··· 74 86 <input type="submit" value="delete"> 75 87 </form> 76 88 </div> 89 + 90 + <script> 91 + add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 92 + add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 93 + </script> 77 94 78 95 @include 'partial/footer.html'
+10 -3
src/templates/inbox.html
··· 9 9 @if notifications.len == 0 10 10 <p>your inbox is empty!</p> 11 11 @else 12 - <a href="/api/user/notification/clear_all">clear all</a> 12 + <form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox"> 13 + <button>clear all</button> 14 + </form> 13 15 <hr> 14 16 @for notification in notifications.reverse() 15 17 <div class="notification"> 16 - <p><strong>@notification.summary</strong></p> 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> 17 25 <pre id="notif-@{notification.id}">@notification.body</pre> 18 - <a href="/api/user/notification/clear?id=@{notification.id}">clear</a> 19 26 <script> 20 27 render_body('notif-@{notification.id}') 21 28 </script>
+4 -4
src/templates/index.html
··· 8 8 9 9 <div> 10 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 13 @for post in pinned_posts 14 14 @include 'components/post_small.html' 15 15 @end ··· 17 17 <br> 18 18 @end 19 19 20 - <h2>recent posts:</h2> 21 - <div> 20 + <div id="recent-posts"> 21 + <h2>recent posts:</h2> 22 22 @if recent_posts.len > 0 23 23 @for post in recent_posts 24 24 @include 'components/post_small.html'
+2 -2
src/templates/login.html
··· 11 11 <p>you are already logged in as @{user.get_name()}!</p> 12 12 <a href="/api/user/logout">log out</a> 13 13 @else 14 - <form action="/api/user/login" method="post"> 14 + <form action="/api/user/login" method="post" beep-redirect="/me"> 15 15 <label for="username">username:</label> 16 16 <input 17 17 type="text" ··· 39 39 @end 40 40 </div> 41 41 42 - @include 'partial/footer.html' 42 + @include 'partial/footer.html'
+1 -52
src/templates/new_post.html
··· 8 8 @else 9 9 <h2>make a post...</h2> 10 10 @end 11 - 12 - <div> 13 - <form action="/api/post/new_post" method="post"> 14 - @if replying 15 - <input 16 - type="number" 17 - name="replying_to" 18 - id="replying_to" 19 - required aria-required 20 - readonly aria-readonly 21 - hidden aria-hidden 22 - value="@replying_to" 23 - > 24 - <input 25 - type="text" 26 - name="title" 27 - id="title" 28 - value="reply to @{replying_to_user.get_name()}" 29 - required aria-required 30 - readonly aria-readonly 31 - hidden aria-hidden 32 - > 33 - @else 34 - <input 35 - type="text" 36 - name="title" 37 - id="title" 38 - minlength="@app.config.post.title_min_len" 39 - maxlength="@app.config.post.title_max_len" 40 - pattern="@app.config.post.title_pattern" 41 - placeholder="title" 42 - required aria-required 43 - > 44 - @end 45 - 46 - <br> 47 - <textarea 48 - name="body" 49 - id="body" 50 - minlength="@app.config.post.body_min_len" 51 - maxlength="@app.config.post.body_max_len" 52 - rows="10" 53 - cols="30" 54 - placeholder="in reply to @{replying_to_user.get_name()}..." 55 - required 56 - ></textarea> 57 - 58 - <br> 59 - 60 - <input type="submit" value="post!"> 61 - </form> 62 - </div> 11 + @include 'components/new_post.html' 63 12 @else 64 13 <p>uh oh, you need to be logged in to see this page</p> 65 14 @end
+1 -1
src/templates/partial/footer.html
··· 15 15 <a href="/about">about</a> 16 16 </p> 17 17 18 - <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 18 + <p>powered by <a href="https://tangled.org/emmeline.girlkisser.top/beep">beep</a></p> 19 19 </footer> 20 20 21 21 </body>
+10 -7
src/templates/partial/header.html
··· 6 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 7 <meta name="description" content="" /> 8 8 9 - <link rel="icon" href="/favicon.png" /> 10 9 <title>@ctx.title</title> 11 10 12 11 @include 'assets/style.html' ··· 18 17 @endif 19 18 20 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> 21 29 </head> 22 30 23 31 <body> ··· 48 56 </header> 49 57 50 58 <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 59 + <div id="errors"></div>
+18 -1
src/templates/post.html
··· 12 12 @else 13 13 replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a> 14 14 @end 15 + @if post.nsfw 16 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 17 + @end 15 18 </h2> 19 + 20 + <hr> 21 + 22 + @if post.nsfw 23 + <details> 24 + <summary>click to show post (nsfw)</summary> 25 + <pre id="post-@{post.id}">@post.body</pre> 26 + </details> 27 + @else 16 28 <pre id="post-@{post.id}">@post.body</pre> 29 + @end 30 + 31 + <hr> 32 + 17 33 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 18 34 <p><em>posted at: @post.posted_at</em></p> 19 35 20 36 @if ctx.is_logged_in() && !user.automated 37 + <br> 21 38 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 39 <br> 23 40 <div> ··· 80 97 <input type="submit" value="pin"> 81 98 </form> 82 99 83 - <form action="/api/post/delete" method="post"> 100 + <form action="/api/post/delete" method="post" beep-redirect="/"> 84 101 <input 85 102 type="number" 86 103 name="id"
+21 -2
src/templates/register.html
··· 1 1 @include 'partial/header.html' 2 2 3 + <script src="/static/js/password.js"></script> 4 + 3 5 <h1>register</h1> 4 6 5 7 <div> ··· 11 13 <p>you are already logged in as @{user.get_name()}!</p> 12 14 <a href="/api/user/logout">log out</a> 13 15 @else 14 - <form action="/api/user/register" method="post"> 16 + <form action="/api/user/register" method="post" beep-redirect="/me"> 15 17 <label for="username">username:</label> 16 18 <input 17 19 type="text" ··· 23 25 required 24 26 > 25 27 <br> 26 - <label for="password">password:</label> 28 + <label for="password">password: <a href="#" id="view-password" style="display: inline;">view</a></label> 27 29 <input 28 30 type="password" 29 31 name="password" ··· 34 36 required 35 37 > 36 38 <br> 39 + <label for="confirm-password">confirm password: <a href="#" id="view-confirm-password" style="display: inline;">view</a></label> 40 + <input 41 + type="password" 42 + name="confirm-password" 43 + id="confirm-password" 44 + pattern="@app.config.user.password_pattern" 45 + minlength="@app.config.user.password_min_len" 46 + maxlength="@app.config.user.password_max_len" 47 + required 48 + > 49 + <br> 50 + <p>passwords match: <span id="passwords-match">yes</span></p> 51 + <br> 37 52 @if app.config.instance.invite_only 38 53 <label for="invite-code">invite code:</label> 39 54 <input type="text" name="invite-code" id="invite-code" required> ··· 48 63 </form> 49 64 @end 50 65 </div> 66 + 67 + <script> 68 + add_password_checkers('password', 'confirm-password', 'passwords-match'); 69 + </script> 51 70 52 71 @include 'partial/footer.html'
+3
src/templates/saved_posts.html
··· 16 16 <p> 17 17 <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>: 18 18 <a href="/post/@post.id">@post.title</a> 19 + @if post.nsfw 20 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 21 + @end 19 22 <button onclick="save(@post.id)" style="display: inline-block;">unsave</button> 20 23 </p> 21 24 </div>
+3
src/templates/saved_posts_for_later.html
··· 16 16 <p> 17 17 <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>: 18 18 <a href="/post/@post.id">@post.title</a> 19 + @if post.nsfw 20 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 21 + @end 19 22 <button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button> 20 23 </p> 21 24 </div>
+8
src/templates/search.html
··· 67 67 post_link.innerText = result.post.title 68 68 p.appendChild(post_link) 69 69 70 + if (result.post.nsfw) 71 + { 72 + const nsfw_indicator = document.createElement('span') 73 + nsfw_indicator.classList.add('nsfw-indicator') 74 + nsfw_indicator.innerHTML = '(<em>nsfw</em>)'; 75 + p.appendChild(nsfw_indicator) 76 + } 77 + 70 78 element.appendChild(p) 71 79 results.appendChild(element) 72 80 }
+44 -14
src/templates/settings.html
··· 2 2 3 3 @if ctx.is_logged_in() 4 4 <script src="/static/js/text_area_counter.js"></script> 5 + <script src="/static/js/password.js"></script> 5 6 6 7 <h1>user settings:</h1> 7 8 ··· 67 68 68 69 <form action="/api/user/set_theme" method="post"> 69 70 <label for="url">theme:</label> 70 - <input type="url" name="url" id="url" value="@user.theme"> 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> 71 81 <input type="submit" value="save"> 72 82 </form> 73 83 @end ··· 92 102 <hr> 93 103 94 104 <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 + <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> 105 117 <input type="submit" value="save"> 106 118 <p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p> 107 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> ··· 116 128 117 129 <details> 118 130 <summary>change password (click to reveal)</summary> 119 - <form action="/api/user/set_password" method="post"> 131 + <form action="/api/user/set_password" method="post" beep-redirect="/login"> 120 132 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 121 133 <label for="current_password">current password:</label> 122 134 <input ··· 130 142 autocomplete="off" aria-autocomplete="off" 131 143 > 132 144 <br> 133 - <label for="new_password">new password:</label> 145 + <label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label> 134 146 <input 135 147 type="password" 136 148 name="new_password" ··· 141 153 required aria-required 142 154 autocomplete="off" aria-autocomplete="off" 143 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> 144 170 <input type="submit" value="save"> 145 171 </form> 146 172 </details> ··· 149 175 150 176 <details> 151 177 <summary>account deletion (click to reveal)</summary> 152 - <form action="/api/user/delete" autocomplete="off"> 178 + <form action="/api/user/delete" autocomplete="off" beep-redirect="/"> 153 179 <input 154 180 type="number" 155 181 name="id" ··· 183 209 </form> 184 210 </details> 185 211 </details> 212 + 213 + <script> 214 + add_password_checkers('new_password', 'confirm_password', 'passwords-match'); 215 + </script> 186 216 187 217 @else 188 218 <p>uh oh, you need to be logged in to view this page!</p>
+1 -42
src/templates/user.html
··· 23 23 24 24 @if app.logged_in_as(mut ctx, viewing.id) 25 25 <p>this is you!</p> 26 - 27 26 @if !user.automated 28 - <script src="/static/js/text_area_counter.js"></script> 29 - <div> 30 - <form action="/api/post/new_post" method="post"> 31 - <h2>new post:</h2> 32 - 33 - <p id="title_chars">0/@{app.config.post.title_max_len}</p> 34 - <input 35 - type="text" 36 - name="title" 37 - id="title" 38 - minlength="@app.config.post.title_min_len" 39 - maxlength="@app.config.post.title_max_len" 40 - pattern="@app.config.post.title_pattern" 41 - placeholder="title" 42 - required aria-required 43 - autocomplete="off" aria-autocomplete="off" 44 - > 45 - <br> 46 - 47 - <p id="body_chars">0/@{app.config.post.body_max_len}</p> 48 - <textarea 49 - name="body" 50 - id="body" 51 - minlength="@app.config.post.body_min_len" 52 - maxlength="@app.config.post.body_max_len" 53 - rows="10" 54 - cols="30" 55 - placeholder="body" 56 - required aria-required 57 - autocomplete="off" aria-autocomplete="off" 58 - ></textarea> 59 - <br> 60 - 61 - <input type="submit" value="post!"> 62 - </form> 63 - 64 - <script> 65 - add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 66 - add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 67 - </script> 68 - </div> 27 + @include 'components/new_post.html' 69 28 <hr> 70 29 @end 71 30 @end
+13
src/util/row.v
··· 1 + module util 2 + 3 + import db.pg 4 + 5 + @[inline] 6 + pub fn get_row_col(res pg.Result, row pg.Row, key string) ?string { 7 + return row.vals[res.cols[key]] 8 + } 9 + 10 + @[inline] 11 + pub fn get_row_col_or_throw(res pg.Result, row pg.Row, key string) string { 12 + return util.or_throw(row.vals[res.cols[key]]) 13 + }
+195 -209
src/webapp/api.v
··· 10 10 // search_hard_limit is the maximum limit for a search query, used to prevent 11 11 // people from requesting searches with huge limits and straining the SQL server 12 12 pub const search_hard_limit = 50 13 - 14 - ////// util /////// 15 - 16 - const always_public_routes = [ 17 - '/api/user/register/', 18 - ] 19 - 20 - fn (mut app App) public_data_check(mut ctx Context) ?veb.Result { 21 - if !app.config.instance.public_data { 22 - println(ctx.req.url) 23 - if ctx.req.url in always_public_routes { 24 - return none 25 - } 26 - _ := app.whoami(mut ctx) or { 27 - ctx.error('not logged in') 28 - return ctx.redirect('/login') 29 - } 30 - } 31 - return none 32 - } 13 + pub const not_logged_in_msg = 'you are not logged in!' 33 14 34 15 ////// user ////// 35 16 ··· 49 30 'remoteip': ctx.ip() 50 31 'response': token 51 32 }) or { 52 - ctx.error('failed to post hcaptcha response: ${err}') 53 - return ctx.redirect('/register') 33 + return ctx.server_error('failed to post hcaptcha response: ${err}') 54 34 } 55 35 data := json.decode(HcaptchaResponse, response.body) or { 56 - ctx.error('failed to decode hcaptcha response: ${err}') 57 - return ctx.redirect('/register') 36 + return ctx.server_error('failed to decode hcaptcha response: ${err}') 58 37 } 59 38 if !data.success { 60 - ctx.error('failed to verify hcaptcha: ${data}') 61 - return ctx.redirect('/register') 39 + return ctx.server_error('failed to verify hcaptcha: ${data}') 62 40 } 63 41 } 64 42 65 43 if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { 66 - ctx.error('invalid invite code') 67 - return ctx.redirect('/register') 44 + return ctx.server_error('invalid invite code') 68 45 } 69 46 70 47 if app.get_user_by_name(username) != none { 71 - ctx.error('username taken') 72 - return ctx.redirect('/register') 48 + return ctx.server_error('username taken') 73 49 } 74 50 75 51 // validate username 76 52 if !app.validators.username.validate(username) { 77 - ctx.error('invalid username') 78 - return ctx.redirect('/register') 53 + return ctx.server_error('invalid username') 79 54 } 80 55 81 56 // validate password 82 57 if !app.validators.password.validate(password) { 83 - ctx.error('invalid password') 84 - return ctx.redirect('/register') 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') 85 63 } 86 64 87 65 salt := auth.generate_salt() ··· 98 76 if x := app.new_user(user) { 99 77 app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 100 78 app.config.welcome.body.replace('%s', x.get_name())) 101 - token := app.auth.add_token(x.id, ctx.ip()) or { 102 - eprintln(err) 103 - ctx.error('api_user_register: could not create token for user with id ${x.id}') 104 - return ctx.redirect('/') 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') 105 82 } 106 83 ctx.set_cookie( 107 84 name: 'token' ··· 112 89 ) 113 90 } else { 114 91 eprintln('api_user_register: could not log into newly-created user: ${user}') 115 - ctx.error('could not log into newly-created user.') 92 + return ctx.server_error('could not log into newly-created user.') 116 93 } 117 94 118 - return ctx.redirect('/') 95 + return ctx.ok('user registered') 119 96 } 120 97 121 98 @['/api/user/set_username'; post] 122 99 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 123 100 user := app.whoami(mut ctx) or { 124 - ctx.error('you are not logged in!') 125 - return ctx.redirect('/login') 101 + return ctx.unauthorized(not_logged_in_msg) 126 102 } 127 103 128 104 if app.get_user_by_name(new_username) != none { 129 - ctx.error('username taken') 130 - return ctx.redirect('/settings') 105 + return ctx.server_error('username taken') 131 106 } 132 107 133 108 // validate username 134 109 if !app.validators.username.validate(new_username) { 135 - ctx.error('invalid username') 136 - return ctx.redirect('/settings') 110 + return ctx.server_error('invalid username') 137 111 } 138 112 139 113 if !app.set_username(user.id, new_username) { 140 - ctx.error('failed to update username') 114 + return ctx.server_error('failed to update username') 141 115 } 142 116 143 - return ctx.redirect('/settings') 117 + return ctx.ok('username updated') 144 118 } 145 119 146 120 @['/api/user/set_password'; post] 147 121 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 148 122 user := app.whoami(mut ctx) or { 149 - ctx.error('you are not logged in!') 150 - return ctx.redirect('/login') 123 + return ctx.unauthorized(not_logged_in_msg) 151 124 } 152 125 153 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 154 - ctx.error('current_password is incorrect') 155 - return ctx.redirect('/settings') 127 + return ctx.server_error('current_password is incorrect') 156 128 } 157 129 158 130 // validate password 159 131 if !app.validators.password.validate(new_password) { 160 - ctx.error('invalid password') 161 - return ctx.redirect('/settings') 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') 162 137 } 163 138 164 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 165 140 if !app.set_password(user.id, hashed_new_password) { 166 - ctx.error('failed to update password') 167 - return ctx.redirect('/settings') 141 + return ctx.server_error('failed to update password') 168 142 } 169 143 170 144 // invalidate tokens and log out 171 145 app.auth.delete_tokens_for_user(user.id) or { 172 146 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 173 - return ctx.redirect('/settings') 147 + return ctx.server_error('failed to delete tokens during password deletion') 174 148 } 175 149 ctx.set_cookie( 176 150 name: 'token' ··· 180 154 path: '/' 181 155 ) 182 156 183 - return ctx.redirect('/login') 157 + return ctx.ok('password updated') 184 158 } 185 159 186 160 @['/api/user/login'; post] 187 161 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 188 162 user := app.get_user_by_name(username) or { 189 - ctx.error('invalid credentials') 190 - return ctx.redirect('/login') 163 + return ctx.server_error('invalid credentials') 191 164 } 192 165 193 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 194 - ctx.error('invalid credentials') 195 - return ctx.redirect('/login') 167 + return ctx.server_error('invalid credentials') 196 168 } 197 169 198 - token := app.auth.add_token(user.id, ctx.ip()) or { 170 + token := app.auth.add_token(user.id) or { 199 171 eprintln('failed to add token on log in: ${err}') 200 - ctx.error('could not create token for user with id ${user.id}') 201 - return ctx.redirect('/login') 172 + return ctx.server_error('could not create token for user with id ${user.id}') 202 173 } 203 174 204 175 ctx.set_cookie( ··· 209 180 path: '/' 210 181 ) 211 182 212 - return ctx.redirect('/') 183 + return ctx.ok('logged in') 213 184 } 214 185 215 - @['/api/user/logout'] 186 + @['/api/user/logout'; post] 216 187 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 217 188 if token := ctx.get_cookie('token') { 218 - if user := app.get_user_by_token(ctx, token) { 219 - app.auth.delete_tokens_for_ip(ctx.ip()) or { 189 + if user := app.get_user_by_token(token) { 190 + // app.auth.delete_tokens_for_ip(ctx.ip()) or { 191 + // eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 192 + // return ctx.redirect('/login') 193 + // } 194 + app.auth.delete_tokens_for_value(token) or { 220 195 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 221 - return ctx.redirect('/login') 222 196 } 223 197 } else { 224 198 eprintln('failed to get user for token for logout') ··· 235 209 path: '/' 236 210 ) 237 211 238 - return ctx.redirect('/login') 212 + return ctx.ok('logged out') 239 213 } 240 214 241 - @['/api/user/full_logout'] 215 + @['/api/user/full_logout'; post] 242 216 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 243 217 if token := ctx.get_cookie('token') { 244 - if user := app.get_user_by_token(ctx, token) { 218 + if user := app.get_user_by_token(token) { 245 219 app.auth.delete_tokens_for_user(user.id) or { 246 220 eprintln('failed to yeet tokens for ${user.id}') 247 - return ctx.redirect('/login') 248 221 } 249 222 } else { 250 223 eprintln('failed to get user for token for full_logout') ··· 261 234 path: '/' 262 235 ) 263 236 264 - return ctx.redirect('/login') 237 + return ctx.ok('logged out') 265 238 } 266 239 267 240 @['/api/user/set_nickname'; post] 268 241 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 269 242 user := app.whoami(mut ctx) or { 270 - ctx.error('you are not logged in!') 271 - return ctx.redirect('/login') 243 + return ctx.unauthorized(not_logged_in_msg) 272 244 } 273 245 274 246 mut clean_nickname := ?string(nickname.trim_space()) ··· 278 250 279 251 // validate 280 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 281 - ctx.error('invalid nickname') 282 - return ctx.redirect('/settings') 253 + return ctx.server_error('invalid nickname') 283 254 } 284 255 285 256 if !app.set_nickname(user.id, clean_nickname) { 286 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 287 - return ctx.redirect('/settings') 258 + return ctx.server_error('failed to update nickname') 288 259 } 289 260 290 - return ctx.redirect('/settings') 261 + return ctx.ok('updated nickname') 291 262 } 292 263 293 264 @['/api/user/set_muted'; post] 294 265 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 295 266 user := app.whoami(mut ctx) or { 296 - ctx.error('you are not logged in!') 297 - return ctx.redirect('/login') 267 + return ctx.unauthorized(not_logged_in_msg) 298 268 } 299 269 300 270 to_mute := app.get_user_by_id(id) or { 301 - ctx.error('no such user') 302 - return ctx.redirect('/') 271 + return ctx.server_error('no such user') 303 272 } 304 273 305 274 if user.admin { 306 275 if !app.set_muted(to_mute.id, muted) { 307 - ctx.error('failed to change mute status') 308 - return ctx.redirect('/user/${to_mute.username}') 276 + return ctx.server_error('failed to change mute status') 309 277 } 310 - return ctx.redirect('/user/${to_mute.username}') 278 + return ctx.ok('muted user') 311 279 } else { 312 - ctx.error('insufficient permissions!') 313 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 314 - return ctx.redirect('/user/${to_mute.username}') 281 + return ctx.unauthorized('insufficient permissions') 315 282 } 316 283 } 317 284 318 285 @['/api/user/set_automated'; post] 319 286 fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 320 287 user := app.whoami(mut ctx) or { 321 - ctx.error('you are not logged in!') 322 - return ctx.redirect('/login') 288 + return ctx.unauthorized(not_logged_in_msg) 323 289 } 324 290 325 291 if !app.set_automated(user.id, is_automated) { 326 - ctx.error('failed to set automated status.') 292 + return ctx.server_error('failed to set automated status.') 327 293 } 328 294 329 - return ctx.redirect('/settings') 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 + } 330 300 } 331 301 332 302 @['/api/user/set_theme'; post] 333 303 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 334 304 if !app.config.instance.allow_changing_theme { 335 - ctx.error('this instance disallows changing themes :(') 336 - return ctx.redirect('/settings') 305 + return ctx.server_error('this instance disallows changing themes :(') 337 306 } 338 307 339 308 user := app.whoami(mut ctx) or { 340 - ctx.error('you are not logged in!') 341 - return ctx.redirect('/login') 309 + return ctx.unauthorized(not_logged_in_msg) 342 310 } 343 311 344 312 mut theme := ?string(none) 345 - if url.trim_space() != '' { 313 + if url.trim_space() == '' { 314 + theme = app.config.instance.default_theme 315 + } else { 346 316 theme = url.trim_space() 347 317 } 348 318 349 319 if !app.set_theme(user.id, theme) { 350 - ctx.error('failed to change theme') 351 - return ctx.redirect('/settings') 320 + return ctx.server_error('failed to change theme') 352 321 } 353 322 354 - return ctx.redirect('/settings') 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') 355 343 } 356 344 357 345 @['/api/user/set_pronouns'; post] 358 346 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 359 347 user := app.whoami(mut ctx) or { 360 - ctx.error('you are not logged in!') 361 - return ctx.redirect('/login') 348 + return ctx.unauthorized(not_logged_in_msg) 362 349 } 363 350 364 351 clean_pronouns := pronouns.trim_space() 365 352 if !app.validators.pronouns.validate(clean_pronouns) { 366 - ctx.error('invalid pronouns') 367 - return ctx.redirect('/settings') 353 + return ctx.server_error('invalid pronouns') 368 354 } 369 355 370 356 if !app.set_pronouns(user.id, clean_pronouns) { 371 - ctx.error('failed to change pronouns') 372 - return ctx.redirect('/settings') 357 + return ctx.server_error('failed to change pronouns') 373 358 } 374 359 375 - return ctx.redirect('/settings') 360 + return ctx.ok('pronouns updated') 376 361 } 377 362 378 363 @['/api/user/set_bio'; post] 379 364 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 380 365 user := app.whoami(mut ctx) or { 381 - ctx.error('you are not logged in!') 382 - return ctx.redirect('/login') 366 + return ctx.unauthorized(not_logged_in_msg) 383 367 } 384 368 385 369 clean_bio := bio.trim_space() 386 370 if !app.validators.user_bio.validate(clean_bio) { 387 - ctx.error('invalid bio') 388 - return ctx.redirect('/settings') 371 + return ctx.server_error('invalid bio') 389 372 } 390 373 391 374 if !app.set_bio(user.id, clean_bio) { 392 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 393 - return ctx.redirect('/settings') 376 + return ctx.server_error('failed to update bio') 394 377 } 395 378 396 - return ctx.redirect('/settings') 379 + return ctx.ok('bio updated') 397 380 } 398 381 399 - @['/api/user/get_name'] 382 + @['/api/user/get_name'; get] 400 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 + } 401 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 402 390 return ctx.text(user.get_name()) 403 391 } 404 392 405 - @['/api/user/delete'] 393 + @['/api/user/delete'; post] 406 394 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 407 395 user := app.whoami(mut ctx) or { 408 - ctx.error('you are not logged in!') 409 - return ctx.redirect('/login') 396 + return ctx.unauthorized(not_logged_in_msg) 410 397 } 411 398 412 - println('attempting to delete ${id} as ${user.id}') 399 + if user.admin || user.id == id { 400 + println('attempting to delete ${id} as ${user.id}') 413 401 414 - if user.admin || user.id == id { 415 402 // yeet 416 403 if !app.delete_user(user.id) { 417 - ctx.error('failed to delete user: ${id}') 418 - return ctx.redirect('/') 404 + return ctx.server_error('failed to delete user: ${id}') 419 405 } 420 406 421 407 app.auth.delete_tokens_for_user(id) or { ··· 432 418 ) 433 419 } 434 420 println('deleted user ${id}') 421 + return ctx.ok('user deleted') 435 422 } else { 436 - ctx.error('be nice. deleting other users is off-limits.') 423 + return ctx.unauthorized('be nice. deleting other users is off-limits.') 437 424 } 438 - 439 - return ctx.redirect('/') 440 425 } 441 426 442 427 @['/api/user/search'; get] 443 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) } 444 430 if limit >= search_hard_limit { 445 - return ctx.text('limit exceeds hard limit (${search_hard_limit})') 431 + return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') 446 432 } 447 433 users := app.search_for_users(query, limit, offset) 448 434 return ctx.json[[]User](users) ··· 450 436 451 437 @['/api/user/whoami'; get] 452 438 fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 453 - user := app.whoami(mut ctx) or { return ctx.text('not logged in') } 439 + user := app.whoami(mut ctx) or { 440 + return ctx.unauthorized(not_logged_in_msg) 441 + } 454 442 return ctx.text(user.username) 455 443 } 456 444 457 445 /// user/notification /// 458 446 459 - @['/api/user/notification/clear'] 447 + @['/api/user/notification/clear'; post] 460 448 fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 461 449 user := app.whoami(mut ctx) or { 462 - ctx.error('you are not logged in!') 463 - return ctx.redirect('/login') 450 + return ctx.unauthorized(not_logged_in_msg) 464 451 } 465 452 466 453 if notification := app.get_notification_by_id(id) { 467 454 if notification.user_id != user.id { 468 - ctx.error('no such notification for user') 469 - return ctx.redirect('/inbox') 470 - } else { 471 - if !app.delete_notification(id) { 472 - ctx.error('failed to delete notification') 473 - return ctx.redirect('/inbox') 474 - } 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') 475 458 } 476 459 } else { 477 - ctx.error('no such notification for user') 460 + return ctx.server_error('no such notification for user') 478 461 } 479 462 480 - return ctx.redirect('/inbox') 463 + return ctx.ok('cleared notification') 481 464 } 482 465 483 - @['/api/user/notification/clear_all'] 466 + @['/api/user/notification/clear_all'; post] 484 467 fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 485 468 user := app.whoami(mut ctx) or { 486 - ctx.error('you are not logged in!') 487 - return ctx.redirect('/login') 469 + return ctx.unauthorized(not_logged_in_msg) 488 470 } 489 471 if !app.delete_notifications_for_user(user.id) { 490 - ctx.error('failed to delete notifications') 491 - return ctx.redirect('/inbox') 472 + return ctx.server_error('failed to delete notifications') 492 473 } 493 - return ctx.redirect('/inbox') 474 + return ctx.ok('cleared notifications') 494 475 } 495 476 496 477 ////// post ////// ··· 498 479 @['/api/post/new_post'; post] 499 480 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 500 481 user := app.whoami(mut ctx) or { 501 - ctx.error('not logged in!') 502 - return ctx.redirect('/login') 482 + return ctx.unauthorized(not_logged_in_msg) 503 483 } 504 484 505 485 if user.muted { 506 - ctx.error('you are muted!') 507 - return ctx.redirect('/post/new') 486 + return ctx.server_error('you are muted!') 508 487 } 509 488 510 489 // validate title 511 490 if !app.validators.post_title.validate(title) { 512 - ctx.error('invalid title') 513 - return ctx.redirect('/post/new') 491 + return ctx.server_error('invalid title') 514 492 } 515 493 516 494 // validate body 517 495 if !app.validators.post_body.validate(body) { 518 - ctx.error('invalid body') 519 - return ctx.redirect('/post/new') 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') 520 502 } 521 503 522 504 mut post := Post{ 523 505 author_id: user.id 524 506 title: title 525 507 body: body 508 + nsfw: nsfw 526 509 } 527 510 528 511 if replying_to != 0 { 529 512 // check if replying post exists 530 513 app.get_post_by_id(replying_to) or { 531 - ctx.error('the post you are trying to reply to does not exist') 532 - return ctx.redirect('/post/new') 514 + return ctx.server_error('the post you are trying to reply to does not exist') 533 515 } 534 516 post.replying_to = replying_to 535 517 } 536 518 537 519 if !app.add_post(post) { 538 - ctx.error('failed to post!') 539 520 println('failed to post: ${post} from user ${user.id}') 540 - return ctx.redirect('/post/new') 521 + return ctx.server_error('failed to post') 541 522 } 542 523 524 + //TODO: Can I not just get the ID directly?? This method feels dicey at best. 543 525 // find the post's id to process mentions with 544 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 545 527 app.process_post_mentions(x) 546 - return ctx.redirect('/post/${x.id}') 528 + return ctx.ok('posted. id=${x.id}') 547 529 } else { 548 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 549 - return ctx.redirect('/me') 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') 550 532 } 551 533 } 552 534 553 535 @['/api/post/delete'; post] 554 536 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 555 537 user := app.whoami(mut ctx) or { 556 - ctx.error('not logged in!') 557 - return ctx.redirect('/login') 538 + return ctx.unauthorized(not_logged_in_msg) 558 539 } 559 540 560 541 post := app.get_post_by_id(id) or { 561 - ctx.error('post does not exist') 562 - return ctx.redirect('/') 542 + return ctx.server_error('post does not exist') 563 543 } 564 544 565 545 if user.admin || user.id == post.author_id { 566 546 if !app.delete_post(post.id) { 567 - ctx.error('failed to delete post') 568 - eprintln('failed to delete post: ${id}') 569 - return ctx.redirect('/') 547 + eprintln('api_post_delete: failed to delete post: ${id}') 548 + return ctx.server_error('failed to delete post') 570 549 } 571 550 println('deleted post: ${id}') 572 - return ctx.redirect('/') 551 + return ctx.ok('post deleted') 573 552 } else { 574 - ctx.error('insufficient permissions!') 575 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 576 - return ctx.redirect('/') 554 + return ctx.unauthorized('insufficient permissions') 577 555 } 578 556 } 579 557 580 - @['/api/post/like'] 558 + @['/api/post/like'; post] 581 559 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 582 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 560 + user := app.whoami(mut ctx) or { 561 + return ctx.unauthorized(not_logged_in_msg) 562 + } 583 563 584 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 564 + post := app.get_post_by_id(id) or { 565 + return ctx.server_error('post does not exist') 566 + } 585 567 586 568 if app.does_user_like_post(user.id, post.id) { 587 569 if !app.unlike_post(post.id, user.id) { ··· 610 592 } 611 593 } 612 594 613 - @['/api/post/dislike'] 595 + @['/api/post/dislike'; post] 614 596 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 615 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 597 + user := app.whoami(mut ctx) or { 598 + return ctx.unauthorized(not_logged_in_msg) 599 + } 616 600 617 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 601 + post := app.get_post_by_id(id) or { 602 + return ctx.server_error('post does not exist') 603 + } 618 604 619 605 if app.does_user_dislike_post(user.id, post.id) { 620 606 if !app.unlike_post(post.id, user.id) { ··· 643 629 } 644 630 } 645 631 646 - @['/api/post/save'] 632 + @['/api/post/save'; post] 647 633 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 648 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 634 + user := app.whoami(mut ctx) or { 635 + return ctx.unauthorized(not_logged_in_msg) 636 + } 649 637 650 638 if app.get_post_by_id(id) != none { 651 639 if app.toggle_save_post(user.id, id) { ··· 658 646 } 659 647 } 660 648 661 - @['/api/post/save_for_later'] 649 + @['/api/post/save_for_later'; post] 662 650 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 663 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 651 + user := app.whoami(mut ctx) or { 652 + return ctx.unauthorized(not_logged_in_msg) 653 + } 664 654 665 655 if app.get_post_by_id(id) != none { 666 656 if app.toggle_save_for_later_post(user.id, id) { ··· 673 663 } 674 664 } 675 665 676 - @['/api/post/get_title'] 666 + @['/api/post/get_title'; get] 677 667 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 678 668 if !app.config.instance.public_data { 679 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 669 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 680 670 } 681 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 682 672 return ctx.text(post.title) ··· 685 675 @['/api/post/edit'; post] 686 676 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 687 677 user := app.whoami(mut ctx) or { 688 - ctx.error('not logged in!') 689 - return ctx.redirect('/login') 678 + return ctx.unauthorized(not_logged_in_msg) 690 679 } 691 680 post := app.get_post_by_id(id) or { 692 - ctx.error('no such post') 693 - return ctx.redirect('/') 681 + return ctx.server_error('no such post') 694 682 } 695 683 if post.author_id != user.id { 696 - ctx.error('insufficient permissions') 697 - return ctx.redirect('/') 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 698 691 } 699 692 700 - if !app.update_post(id, title, body) { 693 + if !app.update_post(id, title, body, nsfw) { 701 694 eprintln('failed to update post') 702 - ctx.error('failed to update post') 703 - return ctx.redirect('/') 695 + return ctx.server_error('failed to update post') 704 696 } 705 697 706 - return ctx.redirect('/post/${id}') 698 + return ctx.ok('posted edited') 707 699 } 708 700 709 701 @['/api/post/pin'; post] 710 702 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 711 703 user := app.whoami(mut ctx) or { 712 - ctx.error('not logged in!') 713 - return ctx.redirect('/login') 704 + return ctx.unauthorized(not_logged_in_msg) 714 705 } 715 706 716 707 if user.admin { 717 708 if !app.pin_post(id) { 718 709 eprintln('failed to pin post: ${id}') 719 - ctx.error('failed to pin post') 720 - return ctx.redirect('/post/${id}') 710 + return ctx.server_error('failed to pin post') 721 711 } 722 - return ctx.redirect('/post/${id}') 712 + return ctx.ok('post pinned') 723 713 } else { 724 - ctx.error('insufficient permissions!') 725 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 726 - return ctx.redirect('/') 715 + return ctx.unauthorized('insufficient permissions') 727 716 } 728 717 } 729 718 730 719 @['/api/post/get/<id>'; get] 731 720 fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 732 721 if !app.config.instance.public_data { 733 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 722 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 734 723 } 735 724 post := app.get_post_by_id(id) or { return ctx.text('no such post') } 736 725 return ctx.json[Post](post) ··· 738 727 739 728 @['/api/post/search'; get] 740 729 fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 741 - _ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 730 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 742 731 if limit >= search_hard_limit { 743 732 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 744 733 } ··· 751 740 @['/api/site/set_motd'; post] 752 741 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 753 742 user := app.whoami(mut ctx) or { 754 - ctx.error('not logged in!') 755 - return ctx.redirect('/login') 743 + return ctx.unauthorized(not_logged_in_msg) 756 744 } 757 745 758 746 if user.admin { 759 747 if !app.set_motd(motd) { 760 - ctx.error('failed to set motd') 761 748 eprintln('failed to set motd: ${motd}') 762 - return ctx.redirect('/') 749 + return ctx.server_error('failed to set motd') 763 750 } 764 751 println('set motd to: ${motd}') 765 - return ctx.redirect('/') 752 + return ctx.ok('motd updated') 766 753 } else { 767 - ctx.error('insufficient permissions!') 768 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 769 - return ctx.redirect('/') 755 + return ctx.unauthorized('insufficient permissions') 770 756 } 771 757 }
+15 -9
src/webapp/app.v
··· 11 11 veb.StaticHandler 12 12 DatabaseAccess 13 13 pub: 14 - config Config 15 - commit string = @VMODHASH 16 - built_at string = @BUILD_TIMESTAMP 17 - v_hash string = @VHASH 14 + config Config 15 + buildinfo BuildInfo 16 + built_at string = @BUILD_TIMESTAMP 17 + v_hash string = @VHASH 18 18 pub mut: 19 19 auth auth.Auth[pg.DB] 20 20 validators struct { ··· 31 31 32 32 // get_user_by_token returns a user by their token, returns none if the user was 33 33 // not found. 34 - pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 35 - user_token := app.auth.find_token(token, ctx.ip()) or { 34 + pub fn (app &App) get_user_by_token(token string) ?User { 35 + user_token := app.auth.find_token(token) or { 36 36 eprintln('no such user corresponding to token') 37 37 return none 38 38 } ··· 46 46 if token == '' { 47 47 return none 48 48 } 49 - if user := app.get_user_by_token(ctx, token) { 49 + if user := app.get_user_by_token(token) { 50 50 if user.username == '' || user.id == 0 { 51 51 eprintln('a user had a token for the blank user') 52 52 // Clear token ··· 126 126 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 127 127 return 128 128 } 129 - matches := re.find_all_str(post.body) 130 - for mat in matches { 129 + matches := re.find_all(post.body) 130 + for i := 0 ; i < matches.len ; i += 2 { 131 + mat := post.body[matches[i]..matches[i+1]] 132 + // skip escaped mentions 133 + if matches[i] != 0 && post.body[matches[i] - 1] == `\\` { 134 + continue 135 + } 136 + 131 137 println('found mentioned user: ${mat}') 132 138 username := mat#[2..-1] 133 139 user := app.get_user_by_name(username) or {
+22
src/webapp/config.v
··· 12 12 name string 13 13 welcome string 14 14 default_theme string 15 + default_css string 15 16 allow_changing_theme bool 16 17 version string 17 18 source string 19 + v_source string 18 20 invite_only bool 19 21 invite_code string 20 22 public_data bool 23 + owner_username string 21 24 } 22 25 http struct { 23 26 pub mut: ··· 45 48 body_min_len int 46 49 body_max_len int 47 50 body_pattern string 51 + allow_nsfw bool 48 52 } 49 53 user struct { 50 54 pub mut: ··· 82 86 config.instance.name = loaded_instance.get('name').to_str() 83 87 config.instance.welcome = loaded_instance.get('welcome').to_str() 84 88 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 89 + config.instance.default_css = loaded_instance.get('default_css').to_str() 85 90 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 86 91 config.instance.version = loaded_instance.get('version').to_str() 87 92 config.instance.source = loaded_instance.get('source').to_str() 93 + config.instance.v_source = loaded_instance.get('v_source').to_str() 88 94 config.instance.invite_only = loaded_instance.get('invite_only').to_bool() 89 95 config.instance.invite_code = loaded_instance.get('invite_code').to_str() 90 96 config.instance.public_data = loaded_instance.get('public_data').to_bool() 97 + config.instance.owner_username = loaded_instance.get('owner_username').to_str() 91 98 92 99 loaded_http := loaded.get('http') 93 100 config.http.port = loaded_http.get('port').to_int() ··· 111 118 config.post.body_min_len = loaded_post.get('body_min_len').to_int() 112 119 config.post.body_max_len = loaded_post.get('body_max_len').to_int() 113 120 config.post.body_pattern = loaded_post.get('body_pattern').to_str() 121 + config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool() 114 122 115 123 loaded_user := loaded.get('user') 116 124 config.user.username_min_len = loaded_user.get('username_min_len').to_int() ··· 135 143 136 144 return config 137 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 + }
+13 -1
src/webapp/pages.v
··· 112 112 } 113 113 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 114 114 posts := app.get_posts_from_user(viewing.id, 10) 115 + 116 + // needed for new_post component 117 + replying := false 118 + replying_to := 0 119 + replying_to_user := User{} 120 + 115 121 return $veb.html('../templates/user.html') 116 122 } 117 123 ··· 228 234 229 235 @['/about'] 230 236 fn (mut app App) about(mut ctx Context) veb.Result { 231 - user := app.whoami(mut ctx) or { User{} } 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 + } 232 244 ctx.title = '${app.config.instance.name} - about' 233 245 return $veb.html('../templates/about.html') 234 246 }