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
+12 -17
.gitignore
··· 1 - # binaries 2 - main 3 - clockwork 4 - beep 5 - *.exe 6 - *.exe~ 7 - *.so 8 - *.dylib 9 - *.dll 10 - bin/ 1 + # Binaries 2 + /beep 3 + /build/ 4 + /scripts/fetchbuildinfo 11 5 12 - # editor/system specific metadata 6 + # Editor/system specific metadata 13 7 .DS_Store 14 - .idea/ 15 8 .vscode/ 16 - *.iml 17 9 18 - # secrets 10 + # Secrets 19 11 /config.real.maple 20 12 .env 21 13 22 - # local v and clockwork install (from gitpod stuffs) 14 + # Build data 15 + /buildinfo.maple 16 + 17 + # Local V and Clockwork install (Gitpod) 18 + /clockwork 23 19 /v/ 24 - /clockwork/ 25 20 26 - # quick notes i keep while developing 21 + # Quick notes I keep while developing 27 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
+43 -8
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' 30 + // Toggle to require that users have the invite code to register. 31 + invite_only = false 32 + // Invite code. You can change this at any time. 33 + invite_code = '' 34 + 35 + // Toggle to allow any non-logged-in user to view data (posts, users, etc) 36 + public_data = false 13 37 14 - // set this to '' if your instance is closed source (twt) 15 - source = 'https://github.com/emmathemartian/beep' 38 + // Owner's username. This is linked on the about page. Leave empty to disable. 39 + owner_username = '' 16 40 } 17 41 18 42 http = { 19 43 port = 8008 20 44 } 21 45 46 + // Database settings. 22 47 postgres = { 23 - host = 'localhost' 48 + // Name of database container in compose.yml 49 + host = 'beep-database' 24 50 port = 5432 25 51 user = 'beep' 26 - password = 'beep' 52 + password = 'beep' // TODO: Read from .env 27 53 db = 'beep' 28 54 } 29 55 30 56 hcaptcha = { 57 + // Toggles if hcaptcha is enabled. 31 58 enabled = false 32 - secret = '' 59 + secret = '' // TODO: Read from .env 33 60 site_key = '' 34 61 } 35 62 63 + // Post settings. 36 64 post = { 37 65 title_min_len = 1 38 66 title_max_len = 50 ··· 41 69 body_min_len = 1 42 70 body_max_len = 1000 43 71 body_pattern = '.*' 72 + 73 + // Whether or not posts can be marked as NSFW. 74 + allow_nsfw = true 44 75 } 45 76 77 + // User settings. 46 78 user = { 47 79 username_min_len = 3 48 80 username_max_len = 20 ··· 65 97 bio_pattern = '.*' 66 98 } 67 99 100 + // Welcome notification settings. 68 101 welcome = { 102 + // Title of the notification. 69 103 summary = 'welcome!' 104 + // Notification body text. %s is replaced with the user's name. 70 105 body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 71 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
+26
license
··· 1 + Copyright 2025 Emmeline Coats 2 + 3 + Redistribution and use in source and binary forms, with or without 4 + modification, are permitted provided that the following conditions are met: 5 + 6 + 1. Redistributions of source code must retain the above copyright notice, this 7 + list of conditions and the following disclaimer. 8 + 9 + 2. Redistributions in binary form must reproduce the above copyright notice, 10 + this list of conditions and the following disclaimer in the documentation 11 + and/or other materials provided with the distribution. 12 + 13 + 3. Neither the name of the copyright holder nor the names of its contributors 14 + may be used to endorse or promote products derived from this software 15 + without specific prior written permission. 16 + 17 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS โ€œAS ISโ€ AND 18 + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-7
license.txt
··· 1 - Copyright 2024 EmmaTheMartian 2 - 3 - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the โ€œSoftwareโ€), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 - 5 - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 - 7 - THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+49
readme
··· 1 + 2 + beep 3 + ==== 4 + 5 + > *a legendary land of lowercase lovers.* 6 + 7 + A self-hosted "social-media-oriented" mini-blogger. 8 + 9 + Technically made because I wanted to mess around with RSS, 10 + but I also wanted a teensy little blog/slow-paced-chat-app 11 + for myself and my friends. 12 + 13 + hosting 14 + ------- 15 + 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"` 20 + 21 + $ git clone https://tangled.org/emmeline.girlkisser.top/beep 22 + $ cd beep 23 + $ cp config.maple config.real.maple 24 + 25 + Edit config.real.maple to set ports, auth, etc. 26 + 27 + `config.real.maple` also has settings to configure the 28 + default theme, post length, username length, welcome 29 + messages, etc etc. 30 + 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. 36 + 37 + With Docker: 38 + $ docker compose up 39 + 40 + Without Docker: 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: 48 + $ mkdir -p ~/.vmodules/emmathemartian/maple 49 + $ git clone https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple
-39
readme.md
··· 1 - # beep 2 - 3 - > *a legendary land of lowercase lovers.* 4 - 5 - a self-hosted "social-media-oriented" mini-blogger. 6 - 7 - technically made because i wanted to mess around with rss, but i also wanted a 8 - teensy little blog/slow-paced-chat-app for myself and my friends. 9 - 10 - ## hosting 11 - 12 - you will need a postgresql database somewhere, along with v to compile beep: 13 - 14 - copy the `config.maple` as `config.real.maple` 15 - 16 - edit `config.real.maple` to set the url, port, username, password, and database 17 - name. 18 - 19 - > `config.real.maple` also has settings to configure the feel of your beep 20 - > instance, post length, username length, welcome messages, etc etc. 21 - 22 - > **do not put your secrets in `config.maple`**. it is intended to be pushed to 23 - > git as a "template config." instead, use `config.real.maple` if you plan to 24 - > push anywhere. it is gitignored already, meaning you do not have to fear about 25 - > your secrets not being kept a secret. 26 - 27 - ```sh 28 - git clone https://github.com/emmathemartian/beep 29 - cd beep 30 - v -prod . 31 - ./beep config.real.maple 32 - ``` 33 - 34 - then go to the configured url to view (default is `http://localhost:8008`). 35 - 36 - if you do not have a database, you can either self-host a postgresql database on 37 - your machine, or you can find a free one online. i use and like 38 - [neon.tech](https://neon.tech), their free plan is pretty comfortable for a 39 - small beep instance!
+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>
+13 -9
src/templates/partial/header.html
··· 1 1 <!DOCTYPE html> 2 - <html> 2 + <html lang="en"> 3 3 4 4 <head> 5 5 <meta charset="utf-8" /> 6 - <meta name="description" content="" /> 7 - <link rel="icon" href="/favicon.png" /> 8 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="description" content="" /> 8 + 9 9 <title>@ctx.title</title> 10 10 11 11 @include 'assets/style.html' ··· 17 17 @endif 18 18 19 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> 20 29 </head> 21 30 22 31 <body> ··· 47 56 </header> 48 57 49 58 <main> 50 - <!-- TODO: fix this lol --> 51 - @if ctx.form_error != '' 52 - <div> 53 - <p><strong>error:</strong> @ctx.form_error</p> 54 - </div> 55 - @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"
+26 -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> 52 + @if app.config.instance.invite_only 53 + <label for="invite-code">invite code:</label> 54 + <input type="text" name="invite-code" id="invite-code" required> 55 + <br> 56 + @end 37 57 @if app.config.hcaptcha.enabled 38 58 <div class="h-captcha" data-sitekey="@{app.config.hcaptcha.site_key}"></div> 39 59 <script src="https://js.hcaptcha.com/1/api.js" async defer></script> ··· 43 63 </form> 44 64 @end 45 65 </div> 66 + 67 + <script> 68 + add_password_checkers('password', 'confirm-password', 'passwords-match'); 69 + </script> 46 70 47 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 + }
+207 -189
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 + pub const not_logged_in_msg = 'you are not logged in!' 13 14 14 15 ////// user ////// 15 16 ··· 24 25 // before doing *anything*, check the captchas 25 26 if app.config.hcaptcha.enabled { 26 27 token := ctx.form['h-captcha-response'] 27 - response := http.post_json('https://api.hcaptcha.com/siteverify', '{ 28 - "secret": "${app.config.hcaptcha.secret}", 29 - // "remoteip": "${ctx.ip()}", 30 - "response": "${token}" 31 - }') or { 32 - ctx.error('failed to post hcaptcha response: ${err}') 33 - return ctx.redirect('/register') 28 + response := http.post_form('https://api.hcaptcha.com/siteverify', { 29 + 'secret': app.config.hcaptcha.secret 30 + 'remoteip': ctx.ip() 31 + 'response': token 32 + }) or { 33 + return ctx.server_error('failed to post hcaptcha response: ${err}') 34 34 } 35 35 data := json.decode(HcaptchaResponse, response.body) or { 36 - ctx.error('failed to decode hcaptcha response: ${err}') 37 - return ctx.redirect('/register') 36 + return ctx.server_error('failed to decode hcaptcha response: ${err}') 38 37 } 39 38 if !data.success { 40 - ctx.error('failed to verify hcaptcha: ${data}') 41 - return ctx.redirect('/register') 39 + return ctx.server_error('failed to verify hcaptcha: ${data}') 42 40 } 43 41 } 44 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 + 45 47 if app.get_user_by_name(username) != none { 46 - ctx.error('username taken') 47 - return ctx.redirect('/register') 48 + return ctx.server_error('username taken') 48 49 } 49 50 50 51 // validate username 51 52 if !app.validators.username.validate(username) { 52 - ctx.error('invalid username') 53 - return ctx.redirect('/register') 53 + return ctx.server_error('invalid username') 54 54 } 55 55 56 56 // validate password 57 57 if !app.validators.password.validate(password) { 58 - ctx.error('invalid password') 59 - 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') 60 63 } 61 64 62 65 salt := auth.generate_salt() ··· 73 76 if x := app.new_user(user) { 74 77 app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 75 78 app.config.welcome.body.replace('%s', x.get_name())) 76 - token := app.auth.add_token(x.id, ctx.ip()) or { 77 - eprintln(err) 78 - ctx.error('api_user_register: could not create token for user with id ${x.id}') 79 - 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') 80 82 } 81 83 ctx.set_cookie( 82 84 name: 'token' ··· 87 89 ) 88 90 } else { 89 91 eprintln('api_user_register: could not log into newly-created user: ${user}') 90 - ctx.error('could not log into newly-created user.') 92 + return ctx.server_error('could not log into newly-created user.') 91 93 } 92 94 93 - return ctx.redirect('/') 95 + return ctx.ok('user registered') 94 96 } 95 97 96 98 @['/api/user/set_username'; post] 97 99 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 98 100 user := app.whoami(mut ctx) or { 99 - ctx.error('you are not logged in!') 100 - return ctx.redirect('/login') 101 + return ctx.unauthorized(not_logged_in_msg) 101 102 } 102 103 103 104 if app.get_user_by_name(new_username) != none { 104 - ctx.error('username taken') 105 - return ctx.redirect('/settings') 105 + return ctx.server_error('username taken') 106 106 } 107 107 108 108 // validate username 109 109 if !app.validators.username.validate(new_username) { 110 - ctx.error('invalid username') 111 - return ctx.redirect('/settings') 110 + return ctx.server_error('invalid username') 112 111 } 113 112 114 113 if !app.set_username(user.id, new_username) { 115 - ctx.error('failed to update username') 114 + return ctx.server_error('failed to update username') 116 115 } 117 116 118 - return ctx.redirect('/settings') 117 + return ctx.ok('username updated') 119 118 } 120 119 121 120 @['/api/user/set_password'; post] 122 121 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 123 122 user := app.whoami(mut ctx) or { 124 - ctx.error('you are not logged in!') 125 - return ctx.redirect('/login') 123 + return ctx.unauthorized(not_logged_in_msg) 126 124 } 127 125 128 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 129 - ctx.error('current_password is incorrect') 130 - return ctx.redirect('/settings') 127 + return ctx.server_error('current_password is incorrect') 131 128 } 132 129 133 130 // validate password 134 131 if !app.validators.password.validate(new_password) { 135 - ctx.error('invalid password') 136 - 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') 137 137 } 138 138 139 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 140 140 if !app.set_password(user.id, hashed_new_password) { 141 - ctx.error('failed to update password') 142 - return ctx.redirect('/settings') 141 + return ctx.server_error('failed to update password') 143 142 } 144 143 145 144 // invalidate tokens and log out 146 145 app.auth.delete_tokens_for_user(user.id) or { 147 146 eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 148 - return ctx.redirect('/settings') 147 + return ctx.server_error('failed to delete tokens during password deletion') 149 148 } 150 149 ctx.set_cookie( 151 150 name: 'token' ··· 155 154 path: '/' 156 155 ) 157 156 158 - return ctx.redirect('/login') 157 + return ctx.ok('password updated') 159 158 } 160 159 161 160 @['/api/user/login'; post] 162 161 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 163 162 user := app.get_user_by_name(username) or { 164 - ctx.error('invalid credentials') 165 - return ctx.redirect('/login') 163 + return ctx.server_error('invalid credentials') 166 164 } 167 165 168 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 169 - ctx.error('invalid credentials') 170 - return ctx.redirect('/login') 167 + return ctx.server_error('invalid credentials') 171 168 } 172 169 173 - token := app.auth.add_token(user.id, ctx.ip()) or { 170 + token := app.auth.add_token(user.id) or { 174 171 eprintln('failed to add token on log in: ${err}') 175 - ctx.error('could not create token for user with id ${user.id}') 176 - return ctx.redirect('/login') 172 + return ctx.server_error('could not create token for user with id ${user.id}') 177 173 } 178 174 179 175 ctx.set_cookie( ··· 184 180 path: '/' 185 181 ) 186 182 187 - return ctx.redirect('/') 183 + return ctx.ok('logged in') 188 184 } 189 185 190 - @['/api/user/logout'] 186 + @['/api/user/logout'; post] 191 187 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 192 188 if token := ctx.get_cookie('token') { 193 - if user := app.get_user_by_token(ctx, token) { 194 - 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 { 195 195 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 196 - return ctx.redirect('/login') 197 196 } 198 197 } else { 199 198 eprintln('failed to get user for token for logout') ··· 210 209 path: '/' 211 210 ) 212 211 213 - return ctx.redirect('/login') 212 + return ctx.ok('logged out') 214 213 } 215 214 216 - @['/api/user/full_logout'] 215 + @['/api/user/full_logout'; post] 217 216 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 218 217 if token := ctx.get_cookie('token') { 219 - if user := app.get_user_by_token(ctx, token) { 218 + if user := app.get_user_by_token(token) { 220 219 app.auth.delete_tokens_for_user(user.id) or { 221 220 eprintln('failed to yeet tokens for ${user.id}') 222 - return ctx.redirect('/login') 223 221 } 224 222 } else { 225 223 eprintln('failed to get user for token for full_logout') ··· 236 234 path: '/' 237 235 ) 238 236 239 - return ctx.redirect('/login') 237 + return ctx.ok('logged out') 240 238 } 241 239 242 240 @['/api/user/set_nickname'; post] 243 241 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 244 242 user := app.whoami(mut ctx) or { 245 - ctx.error('you are not logged in!') 246 - return ctx.redirect('/login') 243 + return ctx.unauthorized(not_logged_in_msg) 247 244 } 248 245 249 246 mut clean_nickname := ?string(nickname.trim_space()) ··· 253 250 254 251 // validate 255 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 256 - ctx.error('invalid nickname') 257 - return ctx.redirect('/me') 253 + return ctx.server_error('invalid nickname') 258 254 } 259 255 260 256 if !app.set_nickname(user.id, clean_nickname) { 261 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 262 - return ctx.redirect('/me') 258 + return ctx.server_error('failed to update nickname') 263 259 } 264 260 265 - return ctx.redirect('/me') 261 + return ctx.ok('updated nickname') 266 262 } 267 263 268 264 @['/api/user/set_muted'; post] 269 265 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 270 266 user := app.whoami(mut ctx) or { 271 - ctx.error('you are not logged in!') 272 - return ctx.redirect('/login') 267 + return ctx.unauthorized(not_logged_in_msg) 273 268 } 274 269 275 270 to_mute := app.get_user_by_id(id) or { 276 - ctx.error('no such user') 277 - return ctx.redirect('/') 271 + return ctx.server_error('no such user') 278 272 } 279 273 280 274 if user.admin { 281 275 if !app.set_muted(to_mute.id, muted) { 282 - ctx.error('failed to change mute status') 283 - return ctx.redirect('/user/${to_mute.username}') 276 + return ctx.server_error('failed to change mute status') 284 277 } 285 - return ctx.redirect('/user/${to_mute.username}') 278 + return ctx.ok('muted user') 286 279 } else { 287 - ctx.error('insufficient permissions!') 288 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 289 - return ctx.redirect('/user/${to_mute.username}') 281 + return ctx.unauthorized('insufficient permissions') 290 282 } 291 283 } 292 284 293 285 @['/api/user/set_automated'; post] 294 286 fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 295 287 user := app.whoami(mut ctx) or { 296 - ctx.error('you are not logged in!') 297 - return ctx.redirect('/login') 288 + return ctx.unauthorized(not_logged_in_msg) 298 289 } 299 290 300 291 if !app.set_automated(user.id, is_automated) { 301 - ctx.error('failed to set automated status.') 292 + return ctx.server_error('failed to set automated status.') 302 293 } 303 294 304 - return ctx.redirect('/me') 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 + } 305 300 } 306 301 307 302 @['/api/user/set_theme'; post] 308 303 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 309 304 if !app.config.instance.allow_changing_theme { 310 - ctx.error('this instance disallows changing themes :(') 311 - return ctx.redirect('/me') 305 + return ctx.server_error('this instance disallows changing themes :(') 312 306 } 313 307 314 308 user := app.whoami(mut ctx) or { 315 - ctx.error('you are not logged in!') 316 - return ctx.redirect('/login') 309 + return ctx.unauthorized(not_logged_in_msg) 317 310 } 318 311 319 312 mut theme := ?string(none) 320 - if url.trim_space() != '' { 313 + if url.trim_space() == '' { 314 + theme = app.config.instance.default_theme 315 + } else { 321 316 theme = url.trim_space() 322 317 } 323 318 324 319 if !app.set_theme(user.id, theme) { 325 - ctx.error('failed to change theme') 326 - return ctx.redirect('/me') 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') 327 340 } 328 341 329 - return ctx.redirect('/me') 342 + return ctx.ok('css updated') 330 343 } 331 344 332 345 @['/api/user/set_pronouns'; post] 333 346 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 334 347 user := app.whoami(mut ctx) or { 335 - ctx.error('you are not logged in!') 336 - return ctx.redirect('/login') 348 + return ctx.unauthorized(not_logged_in_msg) 337 349 } 338 350 339 351 clean_pronouns := pronouns.trim_space() 340 352 if !app.validators.pronouns.validate(clean_pronouns) { 341 - ctx.error('invalid pronouns') 342 - return ctx.redirect('/me') 353 + return ctx.server_error('invalid pronouns') 343 354 } 344 355 345 356 if !app.set_pronouns(user.id, clean_pronouns) { 346 - ctx.error('failed to change pronouns') 347 - return ctx.redirect('/me') 357 + return ctx.server_error('failed to change pronouns') 348 358 } 349 359 350 - return ctx.redirect('/me') 360 + return ctx.ok('pronouns updated') 351 361 } 352 362 353 363 @['/api/user/set_bio'; post] 354 364 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 355 365 user := app.whoami(mut ctx) or { 356 - ctx.error('you are not logged in!') 357 - return ctx.redirect('/login') 366 + return ctx.unauthorized(not_logged_in_msg) 358 367 } 359 368 360 369 clean_bio := bio.trim_space() 361 370 if !app.validators.user_bio.validate(clean_bio) { 362 - ctx.error('invalid bio') 363 - return ctx.redirect('/me') 371 + return ctx.server_error('invalid bio') 364 372 } 365 373 366 374 if !app.set_bio(user.id, clean_bio) { 367 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 368 - return ctx.redirect('/me') 376 + return ctx.server_error('failed to update bio') 369 377 } 370 378 371 - return ctx.redirect('/me') 379 + return ctx.ok('bio updated') 372 380 } 373 381 374 - @['/api/user/get_name'] 382 + @['/api/user/get_name'; get] 375 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 + } 376 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 377 390 return ctx.text(user.get_name()) 378 391 } 379 392 380 - @['/api/user/delete'] 393 + @['/api/user/delete'; post] 381 394 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 382 395 user := app.whoami(mut ctx) or { 383 - ctx.error('you are not logged in!') 384 - return ctx.redirect('/login') 396 + return ctx.unauthorized(not_logged_in_msg) 385 397 } 386 398 387 - println('attempting to delete ${id} as ${user.id}') 399 + if user.admin || user.id == id { 400 + println('attempting to delete ${id} as ${user.id}') 388 401 389 - if user.admin || user.id == id { 390 402 // yeet 391 403 if !app.delete_user(user.id) { 392 - ctx.error('failed to delete user: ${id}') 393 - return ctx.redirect('/') 404 + return ctx.server_error('failed to delete user: ${id}') 394 405 } 395 406 396 407 app.auth.delete_tokens_for_user(id) or { ··· 407 418 ) 408 419 } 409 420 println('deleted user ${id}') 421 + return ctx.ok('user deleted') 410 422 } else { 411 - ctx.error('be nice. deleting other users is off-limits.') 423 + return ctx.unauthorized('be nice. deleting other users is off-limits.') 412 424 } 413 - 414 - return ctx.redirect('/') 415 425 } 416 426 417 427 @['/api/user/search'; get] 418 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) } 419 430 if limit >= search_hard_limit { 420 - return ctx.text('limit exceeds hard limit (${search_hard_limit})') 431 + return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') 421 432 } 422 433 users := app.search_for_users(query, limit, offset) 423 434 return ctx.json[[]User](users) ··· 425 436 426 437 @['/api/user/whoami'; get] 427 438 fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 428 - 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 + } 429 442 return ctx.text(user.username) 430 443 } 431 444 432 445 /// user/notification /// 433 446 434 - @['/api/user/notification/clear'] 447 + @['/api/user/notification/clear'; post] 435 448 fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 436 449 user := app.whoami(mut ctx) or { 437 - ctx.error('you are not logged in!') 438 - return ctx.redirect('/login') 450 + return ctx.unauthorized(not_logged_in_msg) 439 451 } 440 452 441 453 if notification := app.get_notification_by_id(id) { 442 454 if notification.user_id != user.id { 443 - ctx.error('no such notification for user') 444 - return ctx.redirect('/inbox') 445 - } else { 446 - if !app.delete_notification(id) { 447 - ctx.error('failed to delete notification') 448 - return ctx.redirect('/inbox') 449 - } 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') 450 458 } 451 459 } else { 452 - ctx.error('no such notification for user') 460 + return ctx.server_error('no such notification for user') 453 461 } 454 462 455 - return ctx.redirect('/inbox') 463 + return ctx.ok('cleared notification') 456 464 } 457 465 458 - @['/api/user/notification/clear_all'] 466 + @['/api/user/notification/clear_all'; post] 459 467 fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 460 468 user := app.whoami(mut ctx) or { 461 - ctx.error('you are not logged in!') 462 - return ctx.redirect('/login') 469 + return ctx.unauthorized(not_logged_in_msg) 463 470 } 464 471 if !app.delete_notifications_for_user(user.id) { 465 - ctx.error('failed to delete notifications') 466 - return ctx.redirect('/inbox') 472 + return ctx.server_error('failed to delete notifications') 467 473 } 468 - return ctx.redirect('/inbox') 474 + return ctx.ok('cleared notifications') 469 475 } 470 476 471 477 ////// post ////// ··· 473 479 @['/api/post/new_post'; post] 474 480 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 475 481 user := app.whoami(mut ctx) or { 476 - ctx.error('not logged in!') 477 - return ctx.redirect('/login') 482 + return ctx.unauthorized(not_logged_in_msg) 478 483 } 479 484 480 485 if user.muted { 481 - ctx.error('you are muted!') 482 - return ctx.redirect('/post/new') 486 + return ctx.server_error('you are muted!') 483 487 } 484 488 485 489 // validate title 486 490 if !app.validators.post_title.validate(title) { 487 - ctx.error('invalid title') 488 - return ctx.redirect('/post/new') 491 + return ctx.server_error('invalid title') 489 492 } 490 493 491 494 // validate body 492 495 if !app.validators.post_body.validate(body) { 493 - ctx.error('invalid body') 494 - 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') 495 502 } 496 503 497 504 mut post := Post{ 498 505 author_id: user.id 499 506 title: title 500 507 body: body 508 + nsfw: nsfw 501 509 } 502 510 503 511 if replying_to != 0 { 504 512 // check if replying post exists 505 513 app.get_post_by_id(replying_to) or { 506 - ctx.error('the post you are trying to reply to does not exist') 507 - return ctx.redirect('/post/new') 514 + return ctx.server_error('the post you are trying to reply to does not exist') 508 515 } 509 516 post.replying_to = replying_to 510 517 } 511 518 512 519 if !app.add_post(post) { 513 - ctx.error('failed to post!') 514 520 println('failed to post: ${post} from user ${user.id}') 515 - return ctx.redirect('/post/new') 521 + return ctx.server_error('failed to post') 516 522 } 517 523 524 + //TODO: Can I not just get the ID directly?? This method feels dicey at best. 518 525 // find the post's id to process mentions with 519 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 520 527 app.process_post_mentions(x) 521 - return ctx.redirect('/post/${x.id}') 528 + return ctx.ok('posted. id=${x.id}') 522 529 } else { 523 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 524 - 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') 525 532 } 526 533 } 527 534 528 535 @['/api/post/delete'; post] 529 536 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 530 537 user := app.whoami(mut ctx) or { 531 - ctx.error('not logged in!') 532 - return ctx.redirect('/login') 538 + return ctx.unauthorized(not_logged_in_msg) 533 539 } 534 540 535 541 post := app.get_post_by_id(id) or { 536 - ctx.error('post does not exist') 537 - return ctx.redirect('/') 542 + return ctx.server_error('post does not exist') 538 543 } 539 544 540 545 if user.admin || user.id == post.author_id { 541 546 if !app.delete_post(post.id) { 542 - ctx.error('failed to delete post') 543 - eprintln('failed to delete post: ${id}') 544 - return ctx.redirect('/') 547 + eprintln('api_post_delete: failed to delete post: ${id}') 548 + return ctx.server_error('failed to delete post') 545 549 } 546 550 println('deleted post: ${id}') 547 - return ctx.redirect('/') 551 + return ctx.ok('post deleted') 548 552 } else { 549 - ctx.error('insufficient permissions!') 550 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 551 - return ctx.redirect('/') 554 + return ctx.unauthorized('insufficient permissions') 552 555 } 553 556 } 554 557 555 - @['/api/post/like'] 558 + @['/api/post/like'; post] 556 559 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 557 - 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 + } 558 563 559 - 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 + } 560 567 561 568 if app.does_user_like_post(user.id, post.id) { 562 569 if !app.unlike_post(post.id, user.id) { ··· 585 592 } 586 593 } 587 594 588 - @['/api/post/dislike'] 595 + @['/api/post/dislike'; post] 589 596 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 590 - 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 + } 591 600 592 - 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 + } 593 604 594 605 if app.does_user_dislike_post(user.id, post.id) { 595 606 if !app.unlike_post(post.id, user.id) { ··· 618 629 } 619 630 } 620 631 621 - @['/api/post/save'] 632 + @['/api/post/save'; post] 622 633 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 623 - 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 + } 624 637 625 638 if app.get_post_by_id(id) != none { 626 639 if app.toggle_save_post(user.id, id) { ··· 633 646 } 634 647 } 635 648 636 - @['/api/post/save_for_later'] 649 + @['/api/post/save_for_later'; post] 637 650 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 638 - 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 + } 639 654 640 655 if app.get_post_by_id(id) != none { 641 656 if app.toggle_save_for_later_post(user.id, id) { ··· 648 663 } 649 664 } 650 665 651 - @['/api/post/get_title'] 666 + @['/api/post/get_title'; get] 652 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 + } 653 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 654 672 return ctx.text(post.title) 655 673 } ··· 657 675 @['/api/post/edit'; post] 658 676 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 659 677 user := app.whoami(mut ctx) or { 660 - ctx.error('not logged in!') 661 - return ctx.redirect('/login') 678 + return ctx.unauthorized(not_logged_in_msg) 662 679 } 663 680 post := app.get_post_by_id(id) or { 664 - ctx.error('no such post') 665 - return ctx.redirect('/') 681 + return ctx.server_error('no such post') 666 682 } 667 683 if post.author_id != user.id { 668 - ctx.error('insufficient permissions') 669 - 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 670 691 } 671 692 672 - if !app.update_post(id, title, body) { 693 + if !app.update_post(id, title, body, nsfw) { 673 694 eprintln('failed to update post') 674 - ctx.error('failed to update post') 675 - return ctx.redirect('/') 695 + return ctx.server_error('failed to update post') 676 696 } 677 697 678 - return ctx.redirect('/post/${id}') 698 + return ctx.ok('posted edited') 679 699 } 680 700 681 701 @['/api/post/pin'; post] 682 702 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 683 703 user := app.whoami(mut ctx) or { 684 - ctx.error('not logged in!') 685 - return ctx.redirect('/login') 704 + return ctx.unauthorized(not_logged_in_msg) 686 705 } 687 706 688 707 if user.admin { 689 708 if !app.pin_post(id) { 690 709 eprintln('failed to pin post: ${id}') 691 - ctx.error('failed to pin post') 692 - return ctx.redirect('/post/${id}') 710 + return ctx.server_error('failed to pin post') 693 711 } 694 - return ctx.redirect('/post/${id}') 712 + return ctx.ok('post pinned') 695 713 } else { 696 - ctx.error('insufficient permissions!') 697 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 698 - return ctx.redirect('/') 715 + return ctx.unauthorized('insufficient permissions') 699 716 } 700 717 } 701 718 702 719 @['/api/post/get/<id>'; get] 703 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 + } 704 724 post := app.get_post_by_id(id) or { return ctx.text('no such post') } 705 725 return ctx.json[Post](post) 706 726 } 707 727 708 728 @['/api/post/search'; get] 709 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) } 710 731 if limit >= search_hard_limit { 711 732 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 712 733 } ··· 719 740 @['/api/site/set_motd'; post] 720 741 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 721 742 user := app.whoami(mut ctx) or { 722 - ctx.error('not logged in!') 723 - return ctx.redirect('/login') 743 + return ctx.unauthorized(not_logged_in_msg) 724 744 } 725 745 726 746 if user.admin { 727 747 if !app.set_motd(motd) { 728 - ctx.error('failed to set motd') 729 748 eprintln('failed to set motd: ${motd}') 730 - return ctx.redirect('/') 749 + return ctx.server_error('failed to set motd') 731 750 } 732 751 println('set motd to: ${motd}') 733 - return ctx.redirect('/') 752 + return ctx.ok('motd updated') 734 753 } else { 735 - ctx.error('insufficient permissions!') 736 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 737 - return ctx.redirect('/') 755 + return ctx.unauthorized('insufficient permissions') 738 756 } 739 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 {
+28
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 20 + invite_only bool 21 + invite_code string 22 + public_data bool 23 + owner_username string 18 24 } 19 25 http struct { 20 26 pub mut: ··· 42 48 body_min_len int 43 49 body_max_len int 44 50 body_pattern string 51 + allow_nsfw bool 45 52 } 46 53 user struct { 47 54 pub mut: ··· 79 86 config.instance.name = loaded_instance.get('name').to_str() 80 87 config.instance.welcome = loaded_instance.get('welcome').to_str() 81 88 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 89 + config.instance.default_css = loaded_instance.get('default_css').to_str() 82 90 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 83 91 config.instance.version = loaded_instance.get('version').to_str() 84 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() 85 98 86 99 loaded_http := loaded.get('http') 87 100 config.http.port = loaded_http.get('port').to_int() ··· 105 118 config.post.body_min_len = loaded_post.get('body_min_len').to_int() 106 119 config.post.body_max_len = loaded_post.get('body_max_len').to_int() 107 120 config.post.body_pattern = loaded_post.get('body_pattern').to_str() 121 + config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool() 108 122 109 123 loaded_user := loaded.get('user') 110 124 config.user.username_min_len = loaded_user.get('username_min_len').to_int() ··· 129 143 130 144 return config 131 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 + }
+35 -4
src/webapp/pages.v
··· 4 4 import entity { User } 5 5 6 6 fn (mut app App) index(mut ctx Context) veb.Result { 7 + if !app.config.instance.public_data { 8 + _ := app.whoami(mut ctx) or { 9 + ctx.error('not logged in') 10 + return ctx.redirect('/login') 11 + } 12 + } 13 + 7 14 ctx.title = app.config.instance.name 8 15 user := app.whoami(mut ctx) or { User{} } 9 16 recent_posts := app.get_recent_posts() ··· 91 98 92 99 @['/user/:username'] 93 100 fn (mut app App) user(mut ctx Context, username string) veb.Result { 101 + if !app.config.instance.public_data { 102 + _ := app.whoami(mut ctx) or { 103 + ctx.error('not logged in') 104 + return ctx.redirect('/login') 105 + } 106 + } 107 + 94 108 user := app.whoami(mut ctx) or { User{} } 95 109 viewing := app.get_user_by_name(username) or { 96 110 ctx.error('user not found') ··· 98 112 } 99 113 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 100 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 + 101 121 return $veb.html('../templates/user.html') 102 122 } 103 123 104 124 @['/post/:post_id'] 105 125 fn (mut app App) post(mut ctx Context, post_id int) veb.Result { 126 + if !app.config.instance.public_data { 127 + _ := app.whoami(mut ctx) or { 128 + ctx.error('not logged in') 129 + return ctx.redirect('/login') 130 + } 131 + } 132 + 106 133 post := app.get_post_by_id(post_id) or { 107 134 ctx.error('no such post') 108 135 return ctx.redirect('/') ··· 114 141 mut replying_to_user := app.get_unknown_user() 115 142 116 143 if post.replying_to != none { 117 - replying_to_post = app.get_post_by_id(post.replying_to) or { 118 - app.get_unknown_post() 119 - } 144 + replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() } 120 145 replying_to_user = app.get_user_by_id(replying_to_post.author_id) or { 121 146 app.get_unknown_user() 122 147 } ··· 209 234 210 235 @['/about'] 211 236 fn (mut app App) about(mut ctx Context) veb.Result { 212 - 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 + } 213 244 ctx.title = '${app.config.instance.name} - about' 214 245 return $veb.html('../templates/about.html') 215 246 }