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
+3
.editorconfig
··· 6 7 [*.v] 8 indent_style = tab
··· 6 7 [*.v] 8 indent_style = tab 9 + 10 + [*.{html,css,js}] 11 + indent_style = tab
+12 -17
.gitignore
··· 1 - # binaries 2 - main 3 - clockwork 4 - beep 5 - *.exe 6 - *.exe~ 7 - *.so 8 - *.dylib 9 - *.dll 10 - bin/ 11 12 - # editor/system specific metadata 13 .DS_Store 14 - .idea/ 15 .vscode/ 16 - *.iml 17 18 - # secrets 19 /config.real.maple 20 .env 21 22 - # local v and clockwork install (from gitpod stuffs) 23 /v/ 24 - /clockwork/ 25 26 - # quick notes i keep while developing 27 /stickynote.md
··· 1 + # Binaries 2 + /beep 3 + /build/ 4 + /scripts/fetchbuildinfo 5 6 + # Editor/system specific metadata 7 .DS_Store 8 .vscode/ 9 10 + # Secrets 11 /config.real.maple 12 .env 13 14 + # Build data 15 + /buildinfo.maple 16 + 17 + # Local V and Clockwork install (Gitpod) 18 + /clockwork 19 /v/ 20 21 + # Quick notes I keep while developing 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"]
+40 -11
build.maple
··· 1 plugins = [ 'v' ] 2 3 task:db.init = { 4 description = 'Initialize and start a local Postgres database via Docker' 5 category = 'db' ··· 9 -e POSTGRES_USER=beep \ 10 -e POSTGRES_PASSWORD=beep \ 11 --mount source=beep-data,target=/var/lib/postgresql/data \ 12 - -p 5432:5432 \ 13 - postgres:15' 14 } 15 16 task:db.start = { ··· 43 run = 'docker rm beep-database && docker volume rm beep-data' 44 } 45 46 - task:run.watch = { 47 - description = 'Watch/run beep' 48 - category = 'run' 49 - run = '${v} -d veb_livereload watch run ${v_main} config.maple' 50 } 51 52 - task:run.watch.real = { 53 - description = 'Watch/run beep using config.real.maple' 54 - category = 'run' 55 - run = '${v} watch run ${v_main} config.real.maple' 56 } 57 58 task:run = { 59 description = 'Run beep' 60 category = 'run' 61 run = '${v} run ${v_main} config.maple' 62 } 63 64 task:run.real = { 65 description = 'Run beep using config.real.maple' 66 category = 'run' 67 - run = '${v} -d veb_livereload run ${v_main} config.real.maple' 68 } 69 70 task:cloc = { 71 description = 'Get the lines of code for beep!'
··· 1 plugins = [ 'v' ] 2 3 + task::fetch-build-info = { 4 + description = 'Fetch misc build information, mainly for the about page' 5 + run = 'v scripts/fetchbuildinfo.vsh' 6 + } 7 + 8 + // Database 9 + 10 task:db.init = { 11 description = 'Initialize and start a local Postgres database via Docker' 12 category = 'db' ··· 16 -e POSTGRES_USER=beep \ 17 -e POSTGRES_PASSWORD=beep \ 18 --mount source=beep-data,target=/var/lib/postgresql/data \ 19 + -p 127.0.0.1:5432:5432 \ 20 + postgres:17' 21 } 22 23 task:db.start = { ··· 50 run = 'docker rm beep-database && docker volume rm beep-data' 51 } 52 53 + // Ngrok 54 + 55 + task:ngrok = { 56 + description = 'Open an ngrok tunnel for testing.' 57 + category = 'misc' 58 + run = 'ngrok http http://localhost:8008' 59 } 60 61 + task:ngrok.url = { 62 + description = 'Open an ngrok tunnel for testing. Requires you to pass the ngrok URL as an argument.' 63 + category = 'misc' 64 + run = 'ngrok http --url=${args} http://localhost:8008' 65 } 66 67 + // Run 68 + 69 task:run = { 70 description = 'Run beep' 71 category = 'run' 72 + depends = [':fetch-build-info'] 73 run = '${v} run ${v_main} config.maple' 74 } 75 76 task:run.real = { 77 description = 'Run beep using config.real.maple' 78 category = 'run' 79 + depends = [':fetch-build-info'] 80 + run = '${v} run ${v_main}' 81 + } 82 + 83 + task:run.watch = { 84 + description = 'Watch/run beep' 85 + category = 'run' 86 + depends = [':fetch-build-info'] 87 + run = '${v} -d veb_livereload watch run ${v_main} config.maple' 88 } 89 + 90 + task:run.watch.real = { 91 + description = 'Watch/run beep using config.real.maple' 92 + category = 'run' 93 + depends = [':fetch-build-info'] 94 + run = '${v} watch run ${v_main}' 95 + } 96 + 97 + // Misc 98 99 task:cloc = { 100 description = 'Get the lines of code for beep!'
+36
compose.yml
···
··· 1 + volumes: 2 + beep-data: 3 + 4 + services: 5 + beep-database: 6 + image: postgres:17 7 + container_name: beep-database 8 + ports: 9 + - 127.0.0.1:5432:5432 10 + environment: 11 + - POSTGRES_DB=beep 12 + - POSTGRES_USER=beep 13 + - POSTGRES_PASSWORD=beep # CHANGE THIS 14 + volumes: 15 + - beep-data:/var/lib/postgresql/data 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
+56 -9
config.maple
··· 1 dev_mode = false 2 static_path = 'src/static' 3 4 instance = { 5 name = 'beep' 6 welcome = 'welcome to beep!' 7 8 - default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css' 9 allow_changing_theme = true 10 } 11 12 http = { 13 port = 8008 14 } 15 16 postgres = { 17 - host = 'localhost' 18 port = 5432 19 user = 'beep' 20 - password = 'beep' 21 db = 'beep' 22 } 23 24 post = { 25 title_min_len = 1 26 title_max_len = 50 27 - title_pattern = '(.|\s)*' 28 29 body_min_len = 1 30 body_max_len = 1000 31 - body_pattern = '(.|\s)*' 32 } 33 34 user = { 35 username_min_len = 3 36 username_max_len = 20 ··· 38 39 nickname_min_len = 1 40 nickname_max_len = 20 41 - nickname_pattern = '(.|\s).*' 42 43 password_min_len = 12 44 password_max_len = 72 45 - password_pattern = '(.|\s)+' 46 47 pronouns_min_len = 0 48 pronouns_max_len = 30 49 - pronouns_pattern = '(.|\s)*' 50 51 bio_min_len = 0 52 bio_max_len = 200 53 - bio_pattern = '(.|\s)*' 54 } 55 56 welcome = { 57 summary = 'welcome!' 58 body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 59 }
··· 1 + // Toggles developer mode; when true, allows access to the admin panel for all users. 2 dev_mode = false 3 + // Path to the static directory. You shouldn't ever need to change this. 4 static_path = 'src/static' 5 6 + // General instance settings 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. 18 name = 'beep' 19 + // The welcome message to show on the homepage. 20 welcome = 'welcome to beep!' 21 22 + // TODO: Move default_theme and allow_changing_theme to user settings 23 + // Default theme applied for all users. 24 + default_theme = '/static/themes/default.css' 25 + // Default custom CSS applied for all users. 26 + default_css = '' 27 + // Whether or not users should be able to change their theme. 28 allow_changing_theme = true 29 + 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 37 + 38 + // Owner's username. This is linked on the about page. Leave empty to disable. 39 + owner_username = '' 40 } 41 42 http = { 43 port = 8008 44 } 45 46 + // Database settings. 47 postgres = { 48 + // Name of database container in compose.yml 49 + host = 'beep-database' 50 port = 5432 51 user = 'beep' 52 + password = 'beep' // TODO: Read from .env 53 db = 'beep' 54 } 55 56 + hcaptcha = { 57 + // Toggles if hcaptcha is enabled. 58 + enabled = false 59 + secret = '' // TODO: Read from .env 60 + site_key = '' 61 + } 62 + 63 + // Post settings. 64 post = { 65 title_min_len = 1 66 title_max_len = 50 67 + title_pattern = '.*' 68 69 body_min_len = 1 70 body_max_len = 1000 71 + body_pattern = '.*' 72 + 73 + // Whether or not posts can be marked as NSFW. 74 + allow_nsfw = true 75 } 76 77 + // User settings. 78 user = { 79 username_min_len = 3 80 username_max_len = 20 ··· 82 83 nickname_min_len = 1 84 nickname_max_len = 20 85 + nickname_pattern = '.*' 86 87 password_min_len = 12 88 password_max_len = 72 89 + password_pattern = '.+' 90 91 pronouns_min_len = 0 92 pronouns_max_len = 30 93 + pronouns_pattern = '.*' 94 95 bio_min_len = 0 96 bio_max_len = 200 97 + bio_pattern = '.*' 98 } 99 100 + // Welcome notification settings. 101 welcome = { 102 + // Title of the notification. 103 summary = 'welcome!' 104 + // Notification body text. %s is replaced with the user's name. 105 body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 106 }
+4
doc/database_spec.md
··· 18 | `password_salt` | string | salt for this user's password | 19 | `muted` | bool | controls whether or not this user can make posts | 20 | `admin` | bool | controls whether or not this user is an admin | 21 | `theme` | ?string | controls per-user css themes | 22 | `bio` | string | bio for this user | 23 | `pronouns` | string | pronouns for this user | 24 | `created_at` | time.Time | a timestamp of when this user was made | ··· 34 | `replying_to` | ?int | id of the post that this post is replying to | 35 | `title` | string | the title of this post | 36 | `body` | string | the body of this post | 37 | `posted_at` | time.Time | a timestamp of when this post was made | 38 39 ## `Like`
··· 18 | `password_salt` | string | salt for this user's password | 19 | `muted` | bool | controls whether or not this user can make posts | 20 | `admin` | bool | controls whether or not this user is an admin | 21 + | `automated` | bool | controls whether or not this user is automated | 22 | `theme` | ?string | controls per-user css themes | 23 + | `css` | ?string | controls per-user css | 24 | `bio` | string | bio for this user | 25 | `pronouns` | string | pronouns for this user | 26 | `created_at` | time.Time | a timestamp of when this user was made | ··· 36 | `replying_to` | ?int | id of the post that this post is replying to | 37 | `title` | string | the title of this post | 38 | `body` | string | the body of this post | 39 + | `pinned` | bool | if this post in globally pinned | 40 + | `nsfw` | bool | if this post in marked as nsfw | 41 | `posted_at` | time.Time | a timestamp of when this post was made | 42 43 ## `Like`
+31
doc/resources.md
··· 9 ## database design 10 11 - https://stackoverflow.com/questions/59505855/liked-posts-design-specifics 12 13 ## sql 14 15 - https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like
··· 9 ## database design 10 11 - https://stackoverflow.com/questions/59505855/liked-posts-design-specifics 12 + - my programmer brain automatically assumed "oh i can just store a list 13 + in the user table!" turns out, that is a bad implementation. 14 + - i do have scalability concerns with the current implementation, but i 15 + can address those in the near future. 16 17 ## sql 18 19 + postgresql documentation: https://www.postgresql.org/docs/ 20 + 21 - https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like 22 + - helped me develop the initial search system, which is subject to be 23 + overhauled, but for now, this helped a lot. 24 + - https://stackoverflow.com/questions/1237725/copying-postgresql-database-to-another-server 25 + - database migrations 26 + 27 + ## sql security 28 + 29 + ![xkcd comic #327](https://imgs.xkcd.com/comics/exploits_of_a_mom.png) 30 + 31 + source: xkcd, <https://xkcd.com/327/> 32 + 33 + - sql injections 34 + - https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html#other-examples-of-safe-prepared-statements 35 + - https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html#using-net-built-in-feature 36 + - https://www.slideshare.net/slideshow/sql-injection-myths-and-fallacies/3729931#3 37 + 38 + ## misc 39 + 40 + - https://stackoverflow.blog/2021/12/28/what-i-wish-i-had-known-about-single-page-applications/ 41 + - i thought about turning beep into a single page application (spa), 42 + then done a bit of research. this blog post pointed out a variety of 43 + problems that the author had with their spa, and many of those problems 44 + would be problems for beep too. 45 + - tl;dr: this blog post gave me the warnings about an spa before i 46 + wasted my time implementing it on beep.
+5 -6
doc/themes.md
··· 30 31 ## beep-specific 32 33 - | name | source | css theme url | 34 - |------|--------|---------------| 35 - | | | | 36 - 37 - > there is nothing here yet! do you want to be the one to change that? 38 39 ## built-in 40 41 | name | based on (if applicable) | css theme url | 42 |-----------------------------|---------------------------------|---------------------------------| 43 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 44 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 45 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 48 > beep also features some built-in themes, some of which are based on the themes 49 > present in the "it just works" list! 50 51 - > make sure to prefix the url with `<instance url>/static/themes/`
··· 30 31 ## beep-specific 32 33 + | name | source | css theme url | 34 + |---------|----------------------------------------------------|----------------------------| 35 + | default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css | 36 37 ## built-in 38 39 | name | based on (if applicable) | css theme url | 40 |-----------------------------|---------------------------------|---------------------------------| 41 + | default | n/a | default.css | 42 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 43 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 44 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 47 > beep also features some built-in themes, some of which are based on the themes 48 > present in the "it just works" list! 49 50 + > make sure to prefix the url with `/static/themes/`
+10 -3
doc/todo.md
··· 23 created-before:<date> 24 is:admin 25 ``` 26 - - [ ] misc:replace `SEARCH *` with `SEARCH <column>` 27 28 ## planing 29 ··· 32 33 - [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc) 34 - [ ] user:follow other users (send notifications on new posts) 35 - - [ ] site:webhooks 36 - - could be used so that a github webhook can send a message when a new commit is pushed to beep! 37 - [ ] site:log new accounts, account deletions, etc etc in an admin-accessible site log 38 - this should be set up to only log things when an admin enables it in the site config, so as to only log when necessary 39 40 ## ideas 41 ··· 64 discord, and other common links. we want those ones to look fancy! 65 - [x] post:saving (add the post to a list of saved posts that a user can view later) 66 - [x] site:message of the day (admins can add a welcome message displayed on index.html) 67 68 ## graveyard 69
··· 23 created-before:<date> 24 is:admin 25 ``` 26 27 ## planing 28 ··· 31 32 - [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc) 33 - [ ] user:follow other users (send notifications on new posts) 34 - [ ] site:log new accounts, account deletions, etc etc in an admin-accessible site log 35 - this should be set up to only log things when an admin enables it in the site config, so as to only log when necessary 36 + - [ ] site:implement a database keep-alive system 37 + - i may also just need to change a setting in the database server to keep it alive, i am not sure yet. 38 + - [ ] site:overhaul security 39 + - i am not a database security specialist, and some of the methods below may be bad. 40 + before approaching which security features i want implemented, i will be consulting some other people to be sure of which ones need to be and do not need to be implemented. 41 + - [ ] row level security? 42 + - [ ] roles/user groups? 43 + - [ ] whitelist maps where possible? 44 + - [ ] database firewall? 45 46 ## ideas 47 ··· 70 discord, and other common links. we want those ones to look fancy! 71 - [x] post:saving (add the post to a list of saved posts that a user can view later) 72 - [x] site:message of the day (admins can add a welcome message displayed on index.html) 73 + - [x] misc:replace `SELECT *` with `SELECT <column>` 74 75 ## graveyard 76
+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 // From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v 2 // The original file's source is licensed under MIT. 3 4 // This fork re-introduces the `ip` field of each token for additional security, 5 // along with delete_tokens_for_ip 6 7 module auth 8 ··· 22 id int @[primary; sql: serial] 23 user_id int 24 value string 25 - ip string 26 } 27 28 pub fn new[T](db T) Auth[T] { ··· 35 } 36 } 37 38 - pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string { 39 mut uuid := rand.uuid_v4() 40 token := Token{ 41 user_id: user_id 42 value: uuid 43 - ip: ip 44 } 45 sql app.db { 46 insert token into Token ··· 48 return uuid 49 } 50 51 - pub fn (app &Auth[T]) find_token(value string, ip string) ?Token { 52 tokens := sql app.db { 53 - select from Token where value == value && ip == ip limit 1 54 } or { []Token{} } 55 if tokens.len == 0 { 56 return none ··· 58 return tokens.first() 59 } 60 61 pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! { 62 sql app.db { 63 delete from Token where user_id == user_id 64 }! 65 } 66 67 - pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! { 68 sql app.db { 69 - delete from Token where ip == ip 70 }! 71 } 72
··· 1 // From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v 2 // The original file's source is licensed under MIT. 3 4 + // ~~ 5 // This fork re-introduces the `ip` field of each token for additional security, 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. 11 12 module auth 13 ··· 27 id int @[primary; sql: serial] 28 user_id int 29 value string 30 } 31 32 pub fn new[T](db T) Auth[T] { ··· 39 } 40 } 41 42 + pub fn (mut app Auth[T]) add_token(user_id int) !string { 43 mut uuid := rand.uuid_v4() 44 token := Token{ 45 user_id: user_id 46 value: uuid 47 } 48 sql app.db { 49 insert token into Token ··· 51 return uuid 52 } 53 54 + pub fn (app &Auth[T]) find_token(value string) ?Token { 55 tokens := sql app.db { 56 + select from Token where value == value limit 1 57 } or { []Token{} } 58 if tokens.len == 0 { 59 return none ··· 61 return tokens.first() 62 } 63 64 + // logs out of all devices 65 pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! { 66 sql app.db { 67 delete from Token where user_id == user_id 68 }! 69 } 70 71 + // logs out of one device 72 + pub fn (mut app Auth[T]) delete_tokens_for_value(value string) ! { 73 sql app.db { 74 + delete from Token where value == value 75 }! 76 } 77
+17
src/beep_sql/beep_sql.v
···
··· 1 + module beep_sql 2 + 3 + import os 4 + import db.pg 5 + 6 + fn load_procedures(mut db pg.DB) { 7 + os.walk('src/beep_sql/procedures/', fn [mut db] (it string) { 8 + println('-> loading procedure: ${it}') 9 + db.exec(os.read_file(it) or { panic(err) }) or { panic(err) } 10 + }) 11 + } 12 + 13 + pub fn load(mut db pg.DB) { 14 + println('-> loading sql code') 15 + load_procedures(mut db) 16 + println('<- done') 17 + }
+12
src/beep_sql/procedures/search_posts.sql
···
··· 1 + CREATE OR REPLACE FUNCTION search_for_posts (IN Query TEXT, IN Count INT, IN Index INT) 2 + RETURNS SETOF "Post" 3 + AS $$ 4 + SELECT * 5 + FROM "Post" 6 + WHERE title LIKE CONCAT('%', Query, '%') OR body LIKE CONCAT('%', Query, '%') 7 + ORDER BY (CASE 8 + WHEN title LIKE CONCAT('%', Query, '%') THEN 1 9 + WHEN body LIKE CONCAT('%', Query, '%') THEN 2 10 + END) 11 + LIMIT Count OFFSET Index; 12 + $$ LANGUAGE SQL;
+12
src/beep_sql/procedures/search_users.sql
···
··· 1 + CREATE OR REPLACE FUNCTION search_for_users (IN Query TEXT, IN Count INT, IN Index INT) 2 + RETURNS SETOF "User" 3 + AS $$ 4 + SELECT * 5 + FROM "User" 6 + WHERE username LIKE CONCAT('%', Query, '%') OR nickname LIKE CONCAT('%', Query, '%') 7 + ORDER BY (CASE 8 + WHEN username LIKE CONCAT('%', Query, '%') THEN 1 9 + WHEN nickname LIKE CONCAT('%', Query, '%') THEN 2 10 + END) 11 + LIMIT Count OFFSET Index; 12 + $$ LANGUAGE SQL;
+5 -9
src/database/like.v
··· 1 module database 2 3 import entity { Like, LikeCache } 4 5 // add_like adds a like to the database, returns true if this succeeds and false 6 // otherwise. ··· 18 // get_net_likes_for_post returns the net likes of the given post. 19 pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 20 // check cache 21 - cache := sql app.db { 22 - select from LikeCache where post_id == post_id limit 1 23 - } or { [] } 24 25 mut likes := 0 26 27 if cache.len != 1 { 28 println('calculating net likes for post: ${post_id}') 29 // calculate 30 - db_likes := sql app.db { 31 - select from Like where post_id == post_id 32 - } or { [] } 33 - 34 for like in db_likes { 35 - if like.is_like { 36 likes++ 37 } else { 38 likes-- ··· 51 return likes 52 } 53 } else { 54 - likes = cache.first().likes 55 } 56 57 return likes
··· 1 module database 2 3 import entity { Like, LikeCache } 4 + import util 5 6 // add_like adds a like to the database, returns true if this succeeds and false 7 // otherwise. ··· 19 // get_net_likes_for_post returns the net likes of the given post. 20 pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 21 // check cache 22 + cache := app.db.exec_param('SELECT likes FROM "LikeCache" WHERE post_id = $1 LIMIT 1', post_id.str()) or { [] } 23 24 mut likes := 0 25 26 if cache.len != 1 { 27 println('calculating net likes for post: ${post_id}') 28 // calculate 29 + db_likes := app.db.exec_param('SELECT is_like FROM "Like" WHERE post_id = $1', post_id.str()) or { [] } 30 for like in db_likes { 31 + if util.or_throw(like.vals[0]).bool() { 32 likes++ 33 } else { 34 likes-- ··· 47 return likes 48 } 49 } else { 50 + likes = util.or_throw(cache.first().vals[0]).int() 51 } 52 53 return likes
+1 -3
src/database/notification.v
··· 47 // get_notification_count gets the amount of notifications a user has, with a 48 // given limit. 49 pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 50 - notifications := sql app.db { 51 - select from Notification where user_id == user_id limit limit 52 - } or { [] } 53 return notifications.len 54 } 55
··· 47 // get_notification_count gets the amount of notifications a user has, with a 48 // given limit. 49 pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 50 + notifications := app.db.exec_param2('SELECT id FROM "Notification" WHERE user_id = $1 LIMIT $2', user_id.str(), limit.str()) or { [] } 51 return notifications.len 52 } 53
+23 -22
src/database/post.v
··· 1 module database 2 3 import time 4 import entity { Post, User, Like, LikeCache } 5 6 // add_post adds a new post to the database, returns true if this succeeded and 7 // false otherwise. ··· 66 // get_popular_posts returns a list of the ten most liked posts. 67 // TODO: make this time-gated (i.e, top ten liked posts of the day) 68 pub fn (app &DatabaseAccess) get_popular_posts() []Post { 69 - cached_likes := sql app.db { 70 - select from LikeCache order by likes desc limit 10 71 - } or { [] } 72 - posts := cached_likes.map(fn [app] (it LikeCache) Post { 73 - return app.get_post_by_id(it.post_id) or { 74 eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 75 return Post{} 76 } ··· 109 110 // update_post updates the given post's title and body with the given title and 111 // body, returns true if this succeeds and false otherwise. 112 - pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string) bool { 113 sql app.db { 114 - update Post set body = new_body, title = new_title where id == post_id 115 } or { 116 return false 117 } ··· 161 } 162 163 // search_for_posts searches for posts matching the given query. 164 - // todo: query options/filters, such as user:beep, !excluded-text, etc 165 pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult { 166 - //TODO: SANATIZE 167 - sql_query := "\ 168 - SELECT *, CASE 169 - WHEN title LIKE '%${query}%' THEN 1 170 - WHEN body LIKE '%${query}%' THEN 2 171 - END AS priority 172 - FROM \"Post\" 173 - WHERE title LIKE '%${query}%' OR body LIKE '%${query}%' 174 - ORDER BY priority ASC LIMIT ${limit} OFFSET ${offset}" 175 176 - queried_posts := app.db.q_strings(sql_query) or { 177 - eprintln('search_for_posts error in app.db.q_strings: ${err}') 178 [] 179 } 180 - 181 - posts := queried_posts.map(|it| Post.from_row(it)) 182 - return PostSearchResult.from_post_list(app, posts) 183 }
··· 1 module database 2 3 import time 4 + import db.pg 5 import entity { Post, User, Like, LikeCache } 6 + import util 7 8 // add_post adds a new post to the database, returns true if this succeeded and 9 // false otherwise. ··· 68 // get_popular_posts returns a list of the ten most liked posts. 69 // TODO: make this time-gated (i.e, top ten liked posts of the day) 70 pub fn (app &DatabaseAccess) get_popular_posts() []Post { 71 + cached_likes := app.db.exec('SELECT post_id FROM "LikeCache" ORDER BY likes DESC LIMIT 10') or { [] } 72 + posts := cached_likes.map(fn [app] (it pg.Row) Post { 73 + return app.get_post_by_id(util.or_throw(it.vals[0]).int()) or { 74 eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 75 return Post{} 76 } ··· 109 110 // update_post updates the given post's title and body with the given title and 111 // body, returns true if this succeeds and false otherwise. 112 + pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string, new_nsfw bool) bool { 113 sql app.db { 114 + update Post set body = new_body, title = new_title, nsfw = new_nsfw where id == post_id 115 } or { 116 return false 117 } ··· 161 } 162 163 // search_for_posts searches for posts matching the given query. 164 + // todo: levenshtein distance, query options/filters (user:beep, !excluded-text, 165 + // etc) 166 pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult { 167 + queried_posts := app.db.exec_param_many_result('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or { 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}') 181 [] 182 } 183 + return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() } 184 }
+7 -6
src/database/saved_post.v
··· 1 module database 2 3 import entity { SavedPost, Post } 4 5 // get_saved_posts_for gets all SavedPost objects for a given user. 6 pub fn (app &DatabaseAccess) get_saved_posts_for(user_id int) []SavedPost { ··· 13 // get_saved_posts_as_post_for gets all saved posts for a given user converted 14 // to Post objects. 15 pub fn (app &DatabaseAccess) get_saved_posts_as_post_for(user_id int) []Post { 16 - saved_posts := sql app.db { 17 - select from SavedPost where user_id == user_id && saved == true 18 - } or { [] } 19 - posts := saved_posts.map(fn [app] (it SavedPost) Post { 20 - return app.get_post_by_id(it.post_id) or { 21 // if the post does not exist, we will remove it now 22 sql app.db { 23 - delete from SavedPost where id == it.id 24 } or { 25 eprintln('get_saved_posts_as_post_for: failed to remove non-existent post from saved post: ${it}') 26 }
··· 1 module database 2 3 + import db.pg 4 import entity { SavedPost, Post } 5 + import util 6 7 // get_saved_posts_for gets all SavedPost objects for a given user. 8 pub fn (app &DatabaseAccess) get_saved_posts_for(user_id int) []SavedPost { ··· 15 // get_saved_posts_as_post_for gets all saved posts for a given user converted 16 // to Post objects. 17 pub fn (app &DatabaseAccess) get_saved_posts_as_post_for(user_id int) []Post { 18 + saved_posts := app.db.exec_param('SELECT id, post_id FROM "SavedPost" WHERE user_id = $1 AND saved = TRUE', user_id.str()) or { [] } 19 + posts := saved_posts.map(fn [app] (it pg.Row) Post { 20 + return app.get_post_by_id(util.or_throw(it.vals[1]).int()) or { 21 // if the post does not exist, we will remove it now 22 + id := util.or_throw(it.vals[0]).int() 23 sql app.db { 24 + delete from SavedPost where id == id 25 } or { 26 eprintln('get_saved_posts_as_post_for: failed to remove non-existent post from saved post: ${it}') 27 }
+4 -1
src/database/site.v
··· 3 import entity { Site } 4 5 pub fn (app &DatabaseAccess) get_or_create_site_config() Site { 6 - configs := sql app.db { 7 select from Site 8 } or { [] } 9 if configs.len == 0 { ··· 12 sql app.db { 13 insert site_config into Site 14 } or { panic('failed to create site config (${err})') } 15 } else if configs.len > 1 { 16 // this should never happen 17 panic('there are multiple site configs')
··· 3 import entity { Site } 4 5 pub fn (app &DatabaseAccess) get_or_create_site_config() Site { 6 + mut configs := sql app.db { 7 select from Site 8 } or { [] } 9 if configs.len == 0 { ··· 12 sql app.db { 13 insert site_config into Site 14 } or { panic('failed to create site config (${err})') } 15 + configs = sql app.db { 16 + select from Site 17 + } or { [] } 18 } else if configs.len > 1 { 19 // this should never happen 20 panic('there are multiple site configs')
+53 -34
src/database/user.v
··· 1 module database 2 3 import entity { User, Notification, Like, LikeCache, Post } 4 5 // new_user creates a new user and returns their struct after creation. 6 pub fn (app &DatabaseAccess) new_user(user User) ?User { ··· 64 return true 65 } 66 67 // set_theme sets the given user's theme url, returns true if this succeeded and 68 // false otherwise. 69 pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { ··· 76 return true 77 } 78 79 // set_pronouns sets the given user's pronouns, returns true if this succeeded 80 // and false otherwise. 81 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { ··· 134 135 // does_user_like_post returns true if a user likes the given post. 136 pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 137 - likes := sql app.db { 138 - select from Like where user_id == user_id && post_id == post_id 139 - } or { [] } 140 if likes.len > 1 { 141 // something is very wrong lol 142 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 143 } else if likes.len == 0 { 144 return false 145 } 146 - return likes.first().is_like 147 } 148 149 // does_user_dislike_post returns true if a user dislikes the given post. 150 pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 151 - likes := sql app.db { 152 - select from Like where user_id == user_id && post_id == post_id 153 - } or { [] } 154 if likes.len > 1 { 155 // something is very wrong lol 156 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 157 } else if likes.len == 0 { 158 return false 159 } 160 - return !likes.first().is_like 161 } 162 163 // does_user_like_or_dislike_post returns true if a user likes *or* dislikes the 164 // given post. 165 pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 166 - likes := sql app.db { 167 - select from Like where user_id == user_id && post_id == post_id 168 - } or { [] } 169 if likes.len > 1 { 170 // something is very wrong lol 171 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 172 } 173 return likes.len == 1 174 } ··· 185 } 186 187 // delete posts and their likes 188 - posts_from_this_user := sql app.db { 189 - select from Post where author_id == user_id 190 - } or { [] } 191 192 for post in posts_from_this_user { 193 sql app.db { 194 - delete from Like where post_id == post.id 195 - delete from LikeCache where post_id == post.id 196 } or { 197 - eprintln('failed to delete like cache for post during user deletion: ${post.id}') 198 } 199 } 200 ··· 210 // search_for_users searches for posts matching the given query. 211 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 212 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 213 - //TODO: SANATIZE 214 - sql_query := "\ 215 - SELECT *, CASE 216 - WHEN username LIKE '%${query}%' THEN 1 217 - WHEN nickname LIKE '%${query}%' THEN 2 218 - END AS priority 219 - FROM \"User\" 220 - WHERE username LIKE '%${query}%' OR nickname LIKE '%${query}%' 221 - ORDER BY priority ASC LIMIT ${limit} OFFSET ${offset}" 222 223 - queried_users := app.db.q_strings(sql_query) or { 224 - eprintln('search_for_users error in app.db.q_strings: ${err}') 225 [] 226 } 227 - 228 - users := queried_users.map(|it| User.from_row(it)) 229 - return users 230 }
··· 1 module database 2 3 import entity { User, Notification, Like, LikeCache, Post } 4 + import util 5 + import db.pg 6 7 // new_user creates a new user and returns their struct after creation. 8 pub fn (app &DatabaseAccess) new_user(user User) ?User { ··· 66 return true 67 } 68 69 + // set_automated sets the given user's automated status, returns true if this 70 + // succeeded and false otherwise. 71 + pub fn (app &DatabaseAccess) set_automated(user_id int, automated bool) bool { 72 + sql app.db { 73 + update User set automated = automated where id == user_id 74 + } or { 75 + eprintln('failed to update automated status for ${user_id}') 76 + return false 77 + } 78 + return true 79 + } 80 + 81 // set_theme sets the given user's theme url, returns true if this succeeded and 82 // false otherwise. 83 pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { ··· 90 return true 91 } 92 93 + // set_css sets the given user's custom CSS, returns true if this succeeded and 94 + // false otherwise. 95 + pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool { 96 + sql app.db { 97 + update User set css = css where id == user_id 98 + } or { 99 + eprintln('failed to update css for ${user_id}') 100 + return false 101 + } 102 + return true 103 + } 104 + 105 // set_pronouns sets the given user's pronouns, returns true if this succeeded 106 // and false otherwise. 107 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { ··· 160 161 // does_user_like_post returns true if a user likes the given post. 162 pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 163 + likes := app.db.exec_param2('SELECT id, is_like FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] } 164 if likes.len > 1 { 165 // something is very wrong lol 166 + eprintln('does_user_like_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 167 } else if likes.len == 0 { 168 return false 169 } 170 + return util.or_throw(likes.first().vals[1]).bool() 171 } 172 173 // does_user_dislike_post returns true if a user dislikes the given post. 174 pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 175 + likes := app.db.exec_param2('SELECT id, is_like FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] } 176 if likes.len > 1 { 177 // something is very wrong lol 178 + eprintln('does_user_dislike_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 179 } else if likes.len == 0 { 180 return false 181 } 182 + return !util.or_throw(likes.first().vals[1]).bool() 183 } 184 185 // does_user_like_or_dislike_post returns true if a user likes *or* dislikes the 186 // given post. 187 pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 188 + likes := app.db.exec_param2('SELECT id FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] } 189 if likes.len > 1 { 190 // something is very wrong lol 191 + eprintln('does_user_like_or_dislike_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 192 } 193 return likes.len == 1 194 } ··· 205 } 206 207 // delete posts and their likes 208 + posts_from_this_user := app.db.exec_param('SELECT id FROM "Post" WHERE author_id = $1', user_id.str()) or { [] } 209 210 for post in posts_from_this_user { 211 + id := util.or_throw(post.vals[0]).int() 212 sql app.db { 213 + delete from Like where post_id == id 214 + delete from LikeCache where post_id == id 215 } or { 216 + eprintln('failed to delete like cache for post during user deletion: ${id}') 217 } 218 } 219 ··· 229 // search_for_users searches for posts matching the given query. 230 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 231 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 232 + queried_users := app.db.exec_param_many_result('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or { 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}') 246 [] 247 } 248 + return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() } 249 }
+1 -1
src/entity/likes.v
··· 13 pub struct LikeCache { 14 pub mut: 15 id int @[primary; sql: serial] 16 - post_id int 17 likes int 18 }
··· 13 pub struct LikeCache { 14 pub mut: 15 id int @[primary; sql: serial] 16 + post_id int @[unique] 17 likes int 18 }
+20 -9
src/entity/post.v
··· 14 body string 15 16 pinned bool 17 18 posted_at time.Time = time.now() 19 } ··· 21 // Post.from_row creates a post object from the given database row. 22 // see src/database/post.v#search_for_posts for usage. 23 @[inline] 24 - pub fn Post.from_row(row pg.Row) Post { 25 // this throws a cgen error when put in Post{} 26 //todo: report this 27 - posted_at := time.parse(util.or_throw[string](row.vals[6])) or { panic(err) } 28 29 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()) 34 } 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()) 38 posted_at: posted_at 39 } 40 }
··· 14 body string 15 16 pinned bool 17 + nsfw bool 18 19 posted_at time.Time = time.now() 20 } ··· 22 // Post.from_row creates a post object from the given database row. 23 // see src/database/post.v#search_for_posts for usage. 24 @[inline] 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 + 34 // this throws a cgen error when put in Post{} 35 //todo: report this 36 + posted_at := time.parse(ct('posted_at')) or { panic(err) } 37 + nsfw := util.map_or_throw[string, bool](ct('nsfw'), |it| it.bool()) 38 39 return Post{ 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()) 44 } 45 + title: ct('title') 46 + body: ct('body') 47 + pinned: util.map_or_throw[string, bool](ct('pinned'), |it| it.bool()) 48 + nsfw: nsfw 49 posted_at: posted_at 50 } 51 }
+23 -13
src/entity/user.v
··· 13 password string 14 password_salt string 15 16 - muted bool 17 - admin bool 18 19 theme string 20 21 bio string 22 pronouns string ··· 43 // User.from_row creates a user object from the given database row. 44 // see src/database/user.v#search_for_users for usage. 45 @[inline] 46 - pub fn User.from_row(row pg.Row) User { 47 // this throws a cgen error when put in User{} 48 //todo: report this 49 - created_at := time.parse(util.or_throw[string](row.vals[10])) or { panic(err) } 50 51 return User{ 52 - id: util.or_throw[string](row.vals[0]).int() 53 - username: util.or_throw[string](row.vals[1]) 54 - nickname: if row.vals[2] == none { ?string(none) } else { 55 - util.or_throw[string](row.vals[2]) 56 } 57 password: 'haha lol, nope' 58 password_salt: 'haha lol, nope' 59 - muted: util.map_or_throw[string, bool](row.vals[5], |it| it.bool()) 60 - admin: util.map_or_throw[string, bool](row.vals[6], |it| it.bool()) 61 - theme: util.or_throw[string](row.vals[7]) 62 - bio: util.or_throw[string](row.vals[8]) 63 - pronouns: util.or_throw[string](row.vals[9]) 64 created_at: created_at 65 } 66 }
··· 13 password string 14 password_salt string 15 16 + muted bool 17 + admin bool 18 + automated bool 19 20 theme string 21 + css string 22 23 bio string 24 pronouns string ··· 45 // User.from_row creates a user object from the given database row. 46 // see src/database/user.v#search_for_users for usage. 47 @[inline] 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 + 57 // this throws a cgen error when put in User{} 58 //todo: report this 59 + created_at := time.parse(ct('created_at')) or { panic(err) } 60 61 return User{ 62 + id: ct('id').int() 63 + username: ct('username') 64 + nickname: if c('nickname') == none { none } else { 65 + ct('nickname') 66 } 67 password: 'haha lol, nope' 68 password_salt: 'haha lol, nope' 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') 74 created_at: created_at 75 } 76 }
+92 -38
src/main.v
··· 6 import entity 7 import os 8 import webapp { App, Context, StringValidator } 9 10 - fn main() { 11 - config := webapp.load_config_from(os.args[1]) 12 - 13 - println('-> connecting to db...') 14 - mut db := pg.connect(pg.Config{ 15 - host: config.postgres.host 16 - dbname: config.postgres.db 17 - user: config.postgres.user 18 - password: config.postgres.password 19 - port: config.postgres.port 20 - })! 21 - println('<- connected') 22 - 23 - defer { 24 - db.close() 25 } 26 27 - mut app := &App{ 28 - config: config 29 - db: db 30 - auth: auth.new(db) 31 - } 32 - 33 - // vfmt off 34 - app.validators.username = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 35 - app.validators.password = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 36 - app.validators.nickname = StringValidator.new(config.user.nickname_min_len, config.user.nickname_max_len, config.user.nickname_pattern) 37 - app.validators.user_bio = StringValidator.new(config.user.bio_min_len, config.user.bio_max_len, config.user.bio_pattern) 38 - app.validators.pronouns = StringValidator.new(config.user.pronouns_min_len, config.user.pronouns_max_len, config.user.pronouns_pattern) 39 - app.validators.post_title = StringValidator.new(config.post.title_min_len, config.post.title_max_len, config.post.title_pattern) 40 - app.validators.post_body = StringValidator.new(config.post.body_min_len, config.post.body_max_len, config.post.body_pattern) 41 - // vfmt on 42 - 43 - app.mount_static_folder_at(app.config.static_path, '/static')! 44 - 45 - println('-> initializing database...') 46 - sql db { 47 create table entity.Site 48 create table entity.User 49 create table entity.Post ··· 51 create table entity.LikeCache 52 create table entity.Notification 53 create table entity.SavedPost 54 - }! 55 - println('<- done') 56 57 // make the website config, if it does not exist 58 app.get_or_create_site_config() 59 60 - if config.dev_mode { 61 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 62 } 63 64 veb.run[App, Context](mut app, app.config.http.port) 65 }
··· 6 import entity 7 import os 8 import webapp { App, Context, StringValidator } 9 + import beep_sql 10 + import util 11 12 + @[inline] 13 + fn connect(mut app App) { 14 + println('-> connecting to database...') 15 + app.db = pg.connect(pg.Config{ 16 + host: app.config.postgres.host 17 + dbname: app.config.postgres.db 18 + user: app.config.postgres.user 19 + password: app.config.postgres.password 20 + port: app.config.postgres.port 21 + }) or { 22 + panic('failed to connect to database: ${err}') 23 } 24 + } 25 26 + @[inline] 27 + fn init_db(mut app App) { 28 + println('-> initializing database') 29 + sql app.db { 30 create table entity.Site 31 create table entity.User 32 create table entity.Post ··· 34 create table entity.LikeCache 35 create table entity.Notification 36 create table entity.SavedPost 37 + } or { 38 + panic('failed to initialize database: ${err}') 39 + } 40 + } 41 + 42 + @[inline] 43 + fn load_validators(mut app App) { 44 + // vfmt off 45 + user := app.config.user 46 + app.validators.username = StringValidator.new(user.username_min_len, user.username_max_len, user.username_pattern) 47 + app.validators.password = StringValidator.new(user.password_min_len, user.password_max_len, user.password_pattern) 48 + app.validators.nickname = StringValidator.new(user.nickname_min_len, user.nickname_max_len, user.nickname_pattern) 49 + app.validators.user_bio = StringValidator.new(user.bio_min_len, user.bio_max_len, user.bio_pattern) 50 + app.validators.pronouns = StringValidator.new(user.pronouns_min_len, user.pronouns_max_len, user.pronouns_pattern) 51 + post := app.config.post 52 + app.validators.post_title = StringValidator.new(post.title_min_len, post.title_max_len, post.title_pattern) 53 + app.validators.post_body = StringValidator.new(post.body_min_len, post.body_max_len, post.body_pattern) 54 + // vfmt on 55 + } 56 + 57 + fn main() { 58 + mut stopwatch := util.Stopwatch.new() 59 + 60 + mut app := &App{ 61 + config: if os.args.len > 1 { 62 + webapp.load_config_from(os.args[1]) 63 + } else if os.exists('config.real.maple') { 64 + webapp.load_config_from('config.real.maple') 65 + } else { 66 + panic('no config found nor specified!') 67 + } 68 + buildinfo: if os.exists('buildinfo.maple') { 69 + webapp.load_buildinfo_from('buildinfo.maple') 70 + } else { 71 + webapp.BuildInfo{} 72 + } 73 + } 74 + 75 + // connect to database 76 + util.time_it( 77 + it: fn [mut app] () { 78 + connect(mut app) 79 + } 80 + name: 'connect to db' 81 + log: true 82 + ) 83 + defer { app.db.close() or { panic(err) } } 84 + 85 + // initialize database 86 + util.time_it(it: fn [mut app] () { 87 + init_db(mut app) 88 + }, name: 'init db', log: true) 89 + 90 + // load sql files kept in beep_sql/ 91 + util.time_it( 92 + it: fn [mut app] () { 93 + beep_sql.load(mut app.db) 94 + } 95 + name: 'load beep_sql' 96 + log: true 97 + ) 98 + 99 + // add authenticator 100 + app.auth = auth.new(app.db) 101 + 102 + // load validators 103 + load_validators(mut app) 104 + 105 + // mount static things 106 + app.mount_static_folder_at(app.config.static_path, '/static')! 107 108 // make the website config, if it does not exist 109 app.get_or_create_site_config() 110 111 + if app.config.dev_mode { 112 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 113 } 114 + 115 + stop := stopwatch.stop() 116 + println('-> took ${stop} to start app') 117 118 veb.run[App, Context](mut app, app.config.http.port) 119 }
+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 // give the body a loading """animation""" while we let the fetches cook 62 element.innerText = 'loading...' 63 64 - const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g) 65 const cache = {} 66 for (const match of matches) { 67 // mention 68 - if (match[0][0] == '@') { 69 if (cache.hasOwnProperty(match[0])) { 70 html = html.replace(match[0], cache[match[0]]) 71 continue
··· 61 // give the body a loading """animation""" while we let the fetches cook 62 element.innerText = 'loading...' 63 64 + const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g) 65 const cache = {} 66 for (const match of matches) { 67 + // escaped 68 + if (match[0][0] == '\\') { 69 + html = html.replace(match[0], match[0].replace('\\', '')) 70 + } 71 // mention 72 + else if (match[0][0] == '@') { 73 if (cache.hasOwnProperty(match[0])) { 74 html = html.replace(match[0], cache[match[0]]) 75 continue
+26 -2
src/static/style.css
··· 1 .post, 2 .notification { 3 border: 2px solid; 4 padding: 8px; 5 } 6 7 - .post p, 8 - .notification p { 9 margin: 0; 10 } 11 12 .post + .post, ··· 16 17 pre { 18 white-space: pre-wrap; 19 } 20 21 /* ··· 24 */ 25 input[hidden] { 26 display: none !important; 27 }
··· 1 + :root { 2 + --c-nsfw-border: red; 3 + } 4 + 5 .post, 6 .notification { 7 border: 2px solid; 8 padding: 8px; 9 } 10 11 + .post > p, 12 + .notification > p { 13 margin: 0; 14 + } 15 + 16 + .post > pre, 17 + .notification > pre { 18 + margin: 0; 19 + display: inline; 20 } 21 22 .post + .post, ··· 26 27 pre { 28 white-space: pre-wrap; 29 + word-wrap: break-word; 30 + } 31 + 32 + span.nsfw-indicator { 33 + border: 2px solid var(--c-nsfw-border); 34 + border-radius: 2px; 35 + padding-left: 4px; 36 + padding-right: 4px; 37 + margin-left: 6px; 38 + } 39 + 40 + details>summary:hover { 41 + cursor: pointer; 42 } 43 44 /* ··· 47 */ 48 input[hidden] { 49 display: none !important; 50 + visibility: none !important; 51 }
+239
src/static/themes/default.css
···
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Onest:wght@100..900&family=Oxygen+Mono&display=swap'); 2 + 3 + :root { 4 + /* palette */ 5 + /* greys */ 6 + --p-black: #333333; 7 + --p-grey0: #414141; 8 + --p-grey1: #4a4a4a; 9 + --p-grey2: #4f4f4f; 10 + --p-grey3: #5c5c5c; 11 + --p-grey4: #5f5f5f; 12 + --p-white: #e7e7e7; 13 + /* rainbow */ 14 + --p-red: #faa; /* == light red */ 15 + --p-orange: #fa7; 16 + --p-yellow: #ffa; /* == light-orange */ 17 + --p-teal: #7fa; 18 + --p-green: #af7; 19 + --p-blue: #7af; 20 + --p-purple: #a7f; 21 + --p-pink: #f7a; 22 + /* light rainbow */ 23 + --p-light-red: #faa; 24 + --p-light-blue: #aaf; 25 + --p-light-green: #afa; 26 + --p-light-orange: #ffa; /* == yellow */ 27 + --p-light-purple: #faf; 28 + --p-light-blue: #aff; 29 + 30 + /* colours */ 31 + --c-bg: var(--p-black); 32 + --c-panel-bg: var(--p-grey0); 33 + --c-panel-border: var(--p-grey2); 34 + --c-panel2-bg: var(--p-grey1); 35 + --c-panel2-border: var(--p-grey3); 36 + --c-panel3-bg: var(--p-grey2); 37 + --c-panel3-border: var(--p-grey4); 38 + --c-fg: var(--p-white); 39 + --c-nsfw-border: var(--p-orange); 40 + --c-link: var(--p-blue); 41 + --c-link-hover: var(--p-light-blue); 42 + --c-accent: var(--p-light-green); 43 + --c-notify-ok: var(--p-light-green); 44 + --c-notify-error: var(--p-light-red); 45 + 46 + /* text */ 47 + --t-font: 'Onest', Arial, serif; 48 + --t-post-font: Garamond, 'Times New Roman', var(--t-font); 49 + --t-mono-font: 'Oxygen Mono', monospace; 50 + --t-h-font: 'Oxygen Mono', var(--t-post-font); 51 + --t-font-weight: 400; 52 + --t-font-style: normal; 53 + --t-font-size: 20px; 54 + 55 + /* layout */ 56 + --l-body-padding: 16px; 57 + --l-body-gap: 12px; 58 + --l-body-width: 75vw; 59 + --l-border-width: 2px; 60 + --l-border-style: solid; 61 + --l-border-radius: 0px; 62 + } 63 + 64 + html { 65 + padding: 0; 66 + offset: 0; 67 + margin: 0; 68 + 69 + width: 100vw; 70 + overflow-x: hidden; 71 + 72 + display: flex; 73 + flex-direction: column; 74 + align-items: center; 75 + 76 + background-color: var(--c-bg); 77 + color: var(--c-fg); 78 + 79 + font-family: var(--t-font); 80 + font-weight: var(--t-font-weight); 81 + font-style: var(--t-font-style); 82 + font-size: var(--t-font-size); 83 + } 84 + 85 + body { 86 + padding: var(--l-body-padding) 0 var(--l-body-padding) 0; 87 + offset: 0; 88 + margin: 0; 89 + width: var(--l-body-width); 90 + } 91 + 92 + header { 93 + padding-bottom: var(--l-body-padding); 94 + } 95 + 96 + footer { 97 + padding-top: var(--l-body-padding); 98 + } 99 + 100 + main { 101 + padding: var(--l-body-padding); 102 + background-color: var(--c-panel-bg); 103 + border: var(--l-border-width) var(--l-border-style) var(--c-panel-border); 104 + border-radius: var(--l-border-radius); 105 + 106 + display: flex; 107 + flex-direction: column; 108 + gap: var(--l-body-gap); 109 + } 110 + 111 + form { 112 + display: flex; 113 + flex-direction: column; 114 + gap: var(--l-body-gap); 115 + } 116 + 117 + button:hover { 118 + cursor: pointer; 119 + } 120 + 121 + input, 122 + textarea, 123 + button { 124 + background-color: var(--c-panel-bg); 125 + color: var(--c-fg); 126 + 127 + border: var(--l-border-width) var(--l-border-style) var(--c-accent); 128 + border-radius: var(--l-border-radius); 129 + padding: 6px; 130 + 131 + font-family: var(--t-font); 132 + } 133 + 134 + input:hover, 135 + textarea:hover, 136 + button:hover { 137 + border-color: var(--c-fg); 138 + } 139 + 140 + input:focus, 141 + textarea:focus, 142 + button:focus { 143 + background-color: var(--c-accent); 144 + color: var(--c-bg); 145 + } 146 + 147 + h1, h2, h3, h4, h5, h6, p { 148 + margin: 0; 149 + } 150 + 151 + h1, header, footer { 152 + font-family: var(--t-h-font); 153 + } 154 + 155 + a { 156 + color: var(--c-link); 157 + transition: 0.15s linear color; 158 + } 159 + 160 + a:hover { 161 + color: var(--c-link-hover); 162 + } 163 + 164 + hr { 165 + width: 100%; 166 + } 167 + 168 + pre { 169 + font-family: var(--t-mono-font); 170 + } 171 + 172 + .post { 173 + border: none; 174 + border-left: var(--l-border-width) var(--l-border-style) var(--c-fg); 175 + } 176 + 177 + .post>pre { 178 + font-family: var(--t-post-font); 179 + } 180 + 181 + .post + .post, 182 + .notification + .notification { 183 + margin-top: 18px; 184 + } 185 + 186 + form:not(.form-inline), 187 + #recent-posts, 188 + #pinned-posts { 189 + padding: 16px 24px 16px 24px; 190 + background-color: var(--c-panel2-bg); 191 + border: var(--l-border-width) var(--l-border-style) var(--c-panel2-border); 192 + border-radius: var(--l-border-radius); 193 + } 194 + 195 + #errors:empty { 196 + display: none; 197 + visibility: hidden; 198 + } 199 + 200 + #errors { 201 + display: flex; 202 + flex-direction: column; 203 + gap: var(--l-body-gap); 204 + } 205 + 206 + #errors>p { 207 + background-color: var(--c-panel3-bg); 208 + border: var(--l-border-width) var(--l-border-style) var(--c-panel3-border); 209 + border-radius: var(--l-border-radius); 210 + 211 + padding: 8px; 212 + width: calc(100% - 16px); 213 + 214 + display: inline-flex; 215 + align-items: center; 216 + justify-content: center; 217 + gap: 12px; 218 + } 219 + 220 + #errors>p>button { 221 + border-color: inherit; 222 + flex-grow: 0; 223 + } 224 + 225 + #errors>p>button:hover { 226 + border-color: var(--c-fg); 227 + } 228 + 229 + #errors>p>span { 230 + flex-grow: 1; 231 + } 232 + 233 + #errors>p.ok { 234 + border-color: var(--c-notify-ok); 235 + } 236 + 237 + #errors>p.error { 238 + border-color: var(--c-notify-error); 239 + }
+34
src/templates/about.html
···
··· 1 + @include 'partial/header.html' 2 + 3 + <h1>about this instance</h1> 4 + 5 + <div> 6 + <p><strong>general:</strong></p> 7 + <p>name: @{app.config.instance.name}</p> 8 + <p>version: @{app.config.instance.version}</p> 9 + <p>public: @{app.config.instance.public_data}</p> 10 + @if app.config.instance.owner_username != '' 11 + <p>owner: <a href="/user/@{app.config.instance.owner_username}">@{app.config.instance.owner_username}</a></p> 12 + @end 13 + 14 + <br> 15 + <p><strong>stats:</strong></p> 16 + <p>users: @{app.get_user_count()}</p> 17 + <p>posts: @{app.get_post_count()}</p> 18 + 19 + @if app.config.instance.source != '' 20 + <br> 21 + <p><strong>nerd info:</strong></p> 22 + <p>beep source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p> 23 + <p>beep commit: <code><a href="@{app.config.instance.source}/commit/@{app.buildinfo.commit}">@{app.buildinfo.commit}</a></code></p> 24 + <p>V source: <a href="@{app.config.instance.v_source}">@{app.config.instance.v_source}</a></p> 25 + <p>V commit: <code><a href="@{app.config.instance.v_source}/commit/@{app.v_hash}">@{app.v_hash}</a></code></p> 26 + <p>built at <span id="built_at">date n/a</span> (unix: <code>@{app.built_at}</code>)</p> 27 + @end 28 + </div> 29 + 30 + <script> 31 + document.getElementById('built_at').innerText = new Date(@{app.built_at} * 1000).toLocaleString() 32 + </script> 33 + 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 <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 <a href="/post/@post.id">@post.title</a> 5 </p> 6 </div>
··· 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 <a href="/post/@post.id">@post.title</a> 5 + @if post.nsfw 6 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 7 + @end 8 </p> 9 </div>
+16 -4
src/templates/components/post_small.html
··· 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> 5 @else 6 - <p>@post.body</p> 7 @end 8 <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 </div>
··· 1 <div class="post post-small"> 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> 12 @else 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 18 @end 19 + 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> 21 </div>
+24 -7
src/templates/edit.html
··· 7 <h1>edit post</h1> 8 9 <div class="post post-full"> 10 - <form action="/api/post/edit" method="post"> 11 <input 12 type="number" 13 name="id" ··· 47 >@post.body</textarea> 48 <br> 49 50 <input type="submit" value="save"> 51 </form> 52 - 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 </div> 58 59 <hr> 60 61 <div> 62 <h2>danger zone:</h2> 63 - <form action="/api/post/delete" method="post"> 64 <input 65 type="number" 66 name="id" ··· 74 <input type="submit" value="delete"> 75 </form> 76 </div> 77 78 @include 'partial/footer.html'
··· 7 <h1>edit post</h1> 8 9 <div class="post post-full"> 10 + <form action="/api/post/edit" method="post" beep-redirect="/post/@post.id"> 11 <input 12 type="number" 13 name="id" ··· 47 >@post.body</textarea> 48 <br> 49 50 + @if app.config.post.allow_nsfw 51 + <div> 52 + <label for="nsfw">is nsfw:</label> 53 + <input 54 + type="checkbox" 55 + name="nsfw" 56 + id="nsfw" 57 + @if post.nsfw 58 + checked aria-checked 59 + @end 60 + /> 61 + </div> 62 + <br> 63 + @else 64 + <input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden /> 65 + @end 66 + 67 <input type="submit" value="save"> 68 </form> 69 </div> 70 71 <hr> 72 73 <div> 74 <h2>danger zone:</h2> 75 + <form action="/api/post/delete" method="post" beep-redirect="/"> 76 <input 77 type="number" 78 name="id" ··· 86 <input type="submit" value="delete"> 87 </form> 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> 94 95 @include 'partial/footer.html'
+10 -3
src/templates/inbox.html
··· 9 @if notifications.len == 0 10 <p>your inbox is empty!</p> 11 @else 12 - <a href="/api/user/notification/clear_all">clear all</a> 13 <hr> 14 @for notification in notifications.reverse() 15 <div class="notification"> 16 - <p><strong>@notification.summary</strong></p> 17 <pre id="notif-@{notification.id}">@notification.body</pre> 18 - <a href="/api/user/notification/clear?id=@{notification.id}">clear</a> 19 <script> 20 render_body('notif-@{notification.id}') 21 </script>
··· 9 @if notifications.len == 0 10 <p>your inbox is empty!</p> 11 @else 12 + <form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox"> 13 + <button>clear all</button> 14 + </form> 15 <hr> 16 @for notification in notifications.reverse() 17 <div class="notification"> 18 + <div style="display: flex; flex-direction: row; align-items: center; gap: 12px;"> 19 + <p><strong>@notification.summary</strong></p> 20 + <form action="/api/user/notification/clear" method="post" beep-redirect="/inbox" class="form-inline" style="display: inline;"> 21 + <input type="number" value="@{notification.id}" name="id" required aria-required hidden aria-hidden readonly aria-readonly /> 22 + <button style="display: inline;">clear</button> 23 + </form> 24 + </div> 25 <pre id="notif-@{notification.id}">@notification.body</pre> 26 <script> 27 render_body('notif-@{notification.id}') 28 </script>
+4 -4
src/templates/index.html
··· 8 9 <div> 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 13 @for post in pinned_posts 14 @include 'components/post_small.html' 15 @end ··· 17 <br> 18 @end 19 20 - <h2>recent posts:</h2> 21 - <div> 22 @if recent_posts.len > 0 23 @for post in recent_posts 24 @include 'components/post_small.html'
··· 8 9 <div> 10 @if pinned_posts.len > 0 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 @for post in pinned_posts 14 @include 'components/post_small.html' 15 @end ··· 17 <br> 18 @end 19 20 + <div id="recent-posts"> 21 + <h2>recent posts:</h2> 22 @if recent_posts.len > 0 23 @for post in recent_posts 24 @include 'components/post_small.html'
+2 -2
src/templates/login.html
··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 - <form action="/api/user/login" method="post"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 39 @end 40 </div> 41 42 - @include 'partial/footer.html'
··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 + <form action="/api/user/login" method="post" beep-redirect="/me"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 39 @end 40 </div> 41 42 + @include 'partial/footer.html'
+1 -52
src/templates/new_post.html
··· 8 @else 9 <h2>make a post...</h2> 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> 63 @else 64 <p>uh oh, you need to be logged in to see this page</p> 65 @end
··· 8 @else 9 <h2>make a post...</h2> 10 @end 11 + @include 'components/new_post.html' 12 @else 13 <p>uh oh, you need to be logged in to see this page</p> 14 @end
+5 -3
src/templates/partial/footer.html
··· 1 </main> 2 3 <footer> 4 - @if ctx.is_logged_in() 5 <p> 6 <a href="/settings">settings</a> 7 @if user.admin 8 - ··· 10 @end 11 - 12 <a href="/logout">log out</a> 13 </p> 14 - @end 15 16 - <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 17 </footer> 18 19 </body>
··· 1 </main> 2 3 <footer> 4 <p> 5 + @if ctx.is_logged_in() 6 <a href="/settings">settings</a> 7 @if user.admin 8 - ··· 10 @end 11 - 12 <a href="/logout">log out</a> 13 + - 14 + @end 15 + <a href="/about">about</a> 16 </p> 17 18 + <p>powered by <a href="https://tangled.org/emmeline.girlkisser.top/beep">beep</a></p> 19 </footer> 20 21 </body>
+13 -9
src/templates/partial/header.html
··· 1 <!DOCTYPE html> 2 - <html> 3 4 <head> 5 <meta charset="utf-8" /> 6 - <meta name="description" content="" /> 7 - <link rel="icon" href="/favicon.png" /> 8 <meta name="viewport" content="width=device-width, initial-scale=1" /> 9 <title>@ctx.title</title> 10 11 @include 'assets/style.html' ··· 17 @endif 18 19 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 20 </head> 21 22 <body> ··· 47 </header> 48 49 <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
··· 1 <!DOCTYPE html> 2 + <html lang="en"> 3 4 <head> 5 <meta charset="utf-8" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="description" content="" /> 8 + 9 <title>@ctx.title</title> 10 11 @include 'assets/style.html' ··· 17 @endif 18 19 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 20 + 21 + @if ctx.is_logged_in() && user.css != '' 22 + <style>@{user.css}</style> 23 + @else 24 + <style>@{app.config.instance.default_css}</style> 25 + @end 26 + 27 + <script src="/static/js/notify.js" defer></script> 28 + <script src="/static/js/form.js" defer></script> 29 </head> 30 31 <body> ··· 56 </header> 57 58 <main> 59 + <div id="errors"></div>
+49 -21
src/templates/post.html
··· 12 @else 13 replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a> 14 @end 15 </h2> 16 <pre id="post-@{post.id}">@post.body</pre> 17 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 18 <p><em>posted at: @post.posted_at</em></p> 19 20 - @if ctx.is_logged_in() 21 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 - @end 23 - 24 - @if ctx.is_logged_in() 25 <br> 26 <div> 27 <button onclick="like(@post.id)"> ··· 61 62 @if post.author_id == user.id 63 <h4>manage post:</h4> 64 - @else if user.admin 65 - <h4>admin powers:</h4> 66 - @end 67 68 - @if post.author_id == user.id 69 <p><a href="/post/@{post.id}/edit">edit</a></p> 70 @end 71 72 @if user.admin 73 - <form action="/api/post/pin" method="post"> 74 - <input 75 - type="number" 76 - name="id" 77 - id="id" 78 - placeholder="post id" 79 - value="@post.id" 80 - required aria-required 81 - readonly aria-readonly 82 - hidden aria-hidden 83 - > 84 - <input type="submit" value="pin"> 85 - </form> 86 @end 87 88 </div>
··· 12 @else 13 replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a> 14 @end 15 + @if post.nsfw 16 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 17 + @end 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 28 <pre id="post-@{post.id}">@post.body</pre> 29 + @end 30 + 31 + <hr> 32 + 33 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 34 <p><em>posted at: @post.posted_at</em></p> 35 36 + @if ctx.is_logged_in() && !user.automated 37 + <br> 38 <p><a href="/post/@{post.id}/reply">reply</a></p> 39 <br> 40 <div> 41 <button onclick="like(@post.id)"> ··· 75 76 @if post.author_id == user.id 77 <h4>manage post:</h4> 78 79 <p><a href="/post/@{post.id}/edit">edit</a></p> 80 @end 81 82 @if user.admin 83 + <details> 84 + <summary>admin powers</summary> 85 + 86 + <form action="/api/post/pin" method="post"> 87 + <input 88 + type="number" 89 + name="id" 90 + id="id" 91 + placeholder="post id" 92 + value="@post.id" 93 + required aria-required 94 + readonly aria-readonly 95 + hidden aria-hidden 96 + > 97 + <input type="submit" value="pin"> 98 + </form> 99 + 100 + <form action="/api/post/delete" method="post" beep-redirect="/"> 101 + <input 102 + type="number" 103 + name="id" 104 + id="id" 105 + placeholder="post id" 106 + value="@post.id" 107 + required aria-required 108 + readonly aria-readonly 109 + hidden aria-hidden 110 + > 111 + <input type="submit" value="delete"> 112 + </form> 113 + </details> 114 @end 115 116 </div>
+32 -3
src/templates/register.html
··· 1 @include 'partial/header.html' 2 3 <h1>register</h1> 4 5 <div> ··· 11 <p>you are already logged in as @{user.get_name()}!</p> 12 <a href="/api/user/logout">log out</a> 13 @else 14 - <form action="/api/user/register" method="post"> 15 <label for="username">username:</label> 16 <input 17 type="text" ··· 23 required 24 > 25 <br> 26 - <label for="password">password:</label> 27 <input 28 type="password" 29 name="password" ··· 34 required 35 > 36 <br> 37 <input type="submit" value="register"> 38 </form> 39 @end 40 </div> 41 42 - @include 'partial/footer.html'
··· 1 @include 'partial/header.html' 2 3 + <script src="/static/js/password.js"></script> 4 + 5 <h1>register</h1> 6 7 <div> ··· 13 <p>you are already logged in as @{user.get_name()}!</p> 14 <a href="/api/user/logout">log out</a> 15 @else 16 + <form action="/api/user/register" method="post" beep-redirect="/me"> 17 <label for="username">username:</label> 18 <input 19 type="text" ··· 25 required 26 > 27 <br> 28 + <label for="password">password: <a href="#" id="view-password" style="display: inline;">view</a></label> 29 <input 30 type="password" 31 name="password" ··· 36 required 37 > 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 57 + @if app.config.hcaptcha.enabled 58 + <div class="h-captcha" data-sitekey="@{app.config.hcaptcha.site_key}"></div> 59 + <script src="https://js.hcaptcha.com/1/api.js" async defer></script> 60 + <br> 61 + @end 62 <input type="submit" value="register"> 63 </form> 64 @end 65 </div> 66 67 + <script> 68 + add_password_checkers('password', 'confirm-password', 'passwords-match'); 69 + </script> 70 + 71 + @include 'partial/footer.html'
+3
src/templates/saved_posts.html
··· 16 <p> 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 <a href="/post/@post.id">@post.title</a> 19 <button onclick="save(@post.id)" style="display: inline-block;">unsave</button> 20 </p> 21 </div>
··· 16 <p> 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 <a href="/post/@post.id">@post.title</a> 19 + @if post.nsfw 20 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 21 + @end 22 <button onclick="save(@post.id)" style="display: inline-block;">unsave</button> 23 </p> 24 </div>
+3
src/templates/saved_posts_for_later.html
··· 16 <p> 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 <a href="/post/@post.id">@post.title</a> 19 <button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button> 20 </p> 21 </div>
··· 16 <p> 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 <a href="/post/@post.id">@post.title</a> 19 + @if post.nsfw 20 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 21 + @end 22 <button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button> 23 </p> 24 </div>
+8
src/templates/search.html
··· 67 post_link.innerText = result.post.title 68 p.appendChild(post_link) 69 70 element.appendChild(p) 71 results.appendChild(element) 72 }
··· 67 post_link.innerText = result.post.title 68 p.appendChild(post_link) 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 + 78 element.appendChild(p) 79 results.appendChild(element) 80 }
+57 -7
src/templates/settings.html
··· 2 3 @if ctx.is_logged_in() 4 <script src="/static/js/text_area_counter.js"></script> 5 6 <h1>user settings:</h1> 7 ··· 15 rows="10" 16 minlength="@app.config.user.bio_min_len" 17 maxlength="@app.config.user.bio_max_len" 18 - required aria-required 19 >@user.bio</textarea> 20 <br> 21 <input type="submit" value="save"> ··· 33 maxlength="@app.config.user.pronouns_max_len" 34 pattern="@app.config.user.pronouns_pattern" 35 value="@user.pronouns" 36 - required aria-required 37 > 38 <input type="submit" value="save"> 39 </form> ··· 50 minlength="@app.config.user.nickname_min_len" 51 maxlength="@app.config.user.nickname_max_len" 52 value="@{user.nickname or { '' }}" 53 - required aria-required 54 > 55 <input type="submit" value="save"> 56 </form> ··· 70 71 <form action="/api/user/set_theme" method="post"> 72 <label for="url">theme:</label> 73 - <input type="url" name="url" id="url" value="@user.theme"> 74 <input type="submit" value="save"> 75 </form> 76 @end ··· 94 95 <hr> 96 97 <details> 98 <summary>dangerous settings (click to reveal)</summary> 99 100 <details> 101 <summary>change password (click to reveal)</summary> 102 - <form action="/api/user/set_password" method="post"> 103 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 104 <label for="current_password">current password:</label> 105 <input ··· 112 required aria-required 113 autocomplete="off" aria-autocomplete="off" 114 > 115 - <label for="new_password">new password:</label> 116 <input 117 type="password" 118 name="new_password" ··· 123 required aria-required 124 autocomplete="off" aria-autocomplete="off" 125 > 126 <input type="submit" value="save"> 127 </form> 128 </details> 129 130 <details> 131 <summary>account deletion (click to reveal)</summary> 132 - <form action="/api/user/delete" autocomplete="off"> 133 <input 134 type="number" 135 name="id" ··· 163 </form> 164 </details> 165 </details> 166 167 @else 168 <p>uh oh, you need to be logged in to view this page!</p>
··· 2 3 @if ctx.is_logged_in() 4 <script src="/static/js/text_area_counter.js"></script> 5 + <script src="/static/js/password.js"></script> 6 7 <h1>user settings:</h1> 8 ··· 16 rows="10" 17 minlength="@app.config.user.bio_min_len" 18 maxlength="@app.config.user.bio_max_len" 19 >@user.bio</textarea> 20 <br> 21 <input type="submit" value="save"> ··· 33 maxlength="@app.config.user.pronouns_max_len" 34 pattern="@app.config.user.pronouns_pattern" 35 value="@user.pronouns" 36 > 37 <input type="submit" value="save"> 38 </form> ··· 49 minlength="@app.config.user.nickname_min_len" 50 maxlength="@app.config.user.nickname_max_len" 51 value="@{user.nickname or { '' }}" 52 > 53 <input type="submit" value="save"> 54 </form> ··· 68 69 <form action="/api/user/set_theme" method="post"> 70 <label for="url">theme:</label> 71 + <input type="text" name="url" id="url" value="@user.theme"> 72 + <input type="submit" value="save"> 73 + </form> 74 + 75 + <hr> 76 + 77 + <form action="/api/user/set_css" method="post"> 78 + <label for="css">custom css:</label> 79 + <br> 80 + <textarea type="text" name="css" id="css" style="font: monospace;">@user.css</textarea> 81 <input type="submit" value="save"> 82 </form> 83 @end ··· 101 102 <hr> 103 104 + <form action="/api/user/set_automated" method="post"> 105 + <div> 106 + <label for="is_automated">is automated:</label> 107 + <input 108 + type="checkbox" 109 + name="is_automated" 110 + id="is_automated" 111 + value="true" 112 + @if user.automated 113 + checked aria-checked 114 + @end 115 + > 116 + </div> 117 + <input type="submit" value="save"> 118 + <p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p> 119 + <p>it will also hide most front-end interactions since the user of this account likely will not be using those very often.</p> 120 + </form> 121 + 122 + <hr> 123 + 124 <details> 125 <summary>dangerous settings (click to reveal)</summary> 126 127 + <br> 128 + 129 <details> 130 <summary>change password (click to reveal)</summary> 131 + <form action="/api/user/set_password" method="post" beep-redirect="/login"> 132 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 133 <label for="current_password">current password:</label> 134 <input ··· 141 required aria-required 142 autocomplete="off" aria-autocomplete="off" 143 > 144 + <br> 145 + <label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label> 146 <input 147 type="password" 148 name="new_password" ··· 153 required aria-required 154 autocomplete="off" aria-autocomplete="off" 155 > 156 + <label for="confirm_password">confirm password: <input type="button" id="view-confirm_password" style="display: inline;" value="view"></input></label> 157 + <input 158 + type="password" 159 + name="confirm_password" 160 + id="confirm_password" 161 + pattern="@app.config.user.password_pattern" 162 + minlength="@app.config.user.password_min_len" 163 + maxlength="@app.config.user.password_max_len" 164 + required aria-required 165 + autocomplete="off" aria-autocomplete="off" 166 + > 167 + <br> 168 + <p>passwords match: <span id="passwords-match">yes</span></p> 169 + <br> 170 <input type="submit" value="save"> 171 </form> 172 </details> 173 174 + <br> 175 + 176 <details> 177 <summary>account deletion (click to reveal)</summary> 178 + <form action="/api/user/delete" autocomplete="off" beep-redirect="/"> 179 <input 180 type="number" 181 name="id" ··· 209 </form> 210 </details> 211 </details> 212 + 213 + <script> 214 + add_password_checkers('new_password', 'confirm_password', 'passwords-match'); 215 + </script> 216 217 @else 218 <p>uh oh, you need to be logged in to view this page!</p>
+11 -47
src/templates/user.html
··· 8 (@viewing.pronouns) 9 @end 10 11 - @if viewing.muted && viewing.admin 12 - [muted admin, somehow] 13 - @else if viewing.muted 14 [muted] 15 - @else if viewing.admin 16 [admin] 17 @end 18 </h1> 19 20 @if app.logged_in_as(mut ctx, viewing.id) 21 - <script src="/static/js/text_area_counter.js"></script> 22 - 23 <p>this is you!</p> 24 - 25 - <div> 26 - <form action="/api/post/new_post" method="post"> 27 - <h2>new post:</h2> 28 - 29 - <p id="title_chars">0/@{app.config.post.title_max_len}</p> 30 - <input 31 - type="text" 32 - name="title" 33 - id="title" 34 - minlength="@app.config.post.title_min_len" 35 - maxlength="@app.config.post.title_max_len" 36 - pattern="@app.config.post.title_pattern" 37 - placeholder="title" 38 - required aria-required 39 - autocomplete="off" aria-autocomplete="off" 40 - > 41 - <br> 42 - 43 - <p id="body_chars">0/@{app.config.post.body_max_len}</p> 44 - <textarea 45 - name="body" 46 - id="body" 47 - minlength="@app.config.post.body_min_len" 48 - maxlength="@app.config.post.body_max_len" 49 - rows="10" 50 - cols="30" 51 - placeholder="body" 52 - required aria-required 53 - autocomplete="off" aria-autocomplete="off" 54 - ></textarea> 55 - <br> 56 - 57 - <input type="submit" value="post!"> 58 - </form> 59 - 60 - <script> 61 - add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 62 - add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 63 - </script> 64 - </div> 65 <hr> 66 @end 67 68 @if viewing.bio != ''
··· 8 (@viewing.pronouns) 9 @end 10 11 + @if viewing.muted 12 [muted] 13 + @end 14 + 15 + @if viewing.automated 16 + [automated] 17 + @end 18 + 19 + @if viewing.admin 20 [admin] 21 @end 22 </h1> 23 24 @if app.logged_in_as(mut ctx, viewing.id) 25 <p>this is you!</p> 26 + @if !user.automated 27 + @include 'components/new_post.html' 28 <hr> 29 + @end 30 @end 31 32 @if viewing.bio != ''
+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 + }
+44
src/util/stopwatch.v
···
··· 1 + module util 2 + 3 + import time 4 + 5 + @[noinit] 6 + pub struct Stopwatch { 7 + pub: 8 + start time.Time = time.now() 9 + pub mut: 10 + stop time.Time 11 + took ?time.Duration 12 + } 13 + 14 + @[inline] 15 + pub fn Stopwatch.new() Stopwatch { 16 + return Stopwatch{} 17 + } 18 + 19 + @[inline] 20 + pub fn (mut stopwatch Stopwatch) stop() time.Duration { 21 + stopwatch.stop = time.now() 22 + duration := stopwatch.stop - stopwatch.start 23 + stopwatch.took = duration 24 + return duration 25 + } 26 + 27 + @[params] 28 + pub struct TimeItParams { 29 + pub: 30 + it fn () @[required] 31 + name string 32 + log bool 33 + } 34 + 35 + @[inline] 36 + pub fn time_it(params TimeItParams) Stopwatch { 37 + mut stopwatch := Stopwatch.new() 38 + params.it() 39 + took := stopwatch.stop() 40 + if params.log { 41 + println('-> (time_it) ${params.name} took ${took}') 42 + } 43 + return stopwatch 44 + }
+276 -207
src/webapp/api.v
··· 2 3 import veb 4 import auth 5 - import entity { Like, LikeCache, Post, Site, User, Notification } 6 import database { PostSearchResult } 7 8 // search_hard_limit is the maximum limit for a search query, used to prevent 9 // people from requesting searches with huge limits and straining the SQL server 10 - pub const search_hard_limit := 50 11 12 ////// user ////// 13 14 @['/api/user/register'; post] 15 fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { 16 if app.get_user_by_name(username) != none { 17 - ctx.error('username taken') 18 - return ctx.redirect('/register') 19 } 20 21 // validate username 22 if !app.validators.username.validate(username) { 23 - ctx.error('invalid username') 24 - return ctx.redirect('/register') 25 } 26 27 // validate password 28 if !app.validators.password.validate(password) { 29 - ctx.error('invalid password') 30 - return ctx.redirect('/register') 31 } 32 33 salt := auth.generate_salt() ··· 42 } 43 44 if x := app.new_user(user) { 45 - app.send_notification_to( 46 - x.id, 47 - app.config.welcome.summary.replace('%s', x.get_name()), 48 - app.config.welcome.body.replace('%s', x.get_name()) 49 - ) 50 - token := app.auth.add_token(x.id, ctx.ip()) or { 51 - eprintln(err) 52 - ctx.error('could not create token for user with id ${x.id}') 53 - return ctx.redirect('/') 54 } 55 ctx.set_cookie( 56 name: 'token' ··· 60 path: '/' 61 ) 62 } else { 63 - eprintln('could not log into newly-created user: ${user}') 64 - ctx.error('could not log into newly-created user.') 65 } 66 67 - return ctx.redirect('/') 68 } 69 70 @['/api/user/set_username'; post] 71 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 72 user := app.whoami(mut ctx) or { 73 - ctx.error('you are not logged in!') 74 - return ctx.redirect('/login') 75 } 76 77 if app.get_user_by_name(new_username) != none { 78 - ctx.error('username taken') 79 - return ctx.redirect('/settings') 80 } 81 82 // validate username 83 if !app.validators.username.validate(new_username) { 84 - ctx.error('invalid username') 85 - return ctx.redirect('/settings') 86 } 87 88 if !app.set_username(user.id, new_username) { 89 - ctx.error('failed to update username') 90 } 91 92 - return ctx.redirect('/settings') 93 } 94 95 @['/api/user/set_password'; post] 96 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 97 user := app.whoami(mut ctx) or { 98 - ctx.error('you are not logged in!') 99 - return ctx.redirect('/login') 100 } 101 102 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 103 - ctx.error('current_password is incorrect') 104 - return ctx.redirect('/settings') 105 } 106 107 // validate password 108 if !app.validators.password.validate(new_password) { 109 - ctx.error('invalid password') 110 - return ctx.redirect('/settings') 111 } 112 113 - // invalidate tokens 114 - app.auth.delete_tokens_for_user(user.id) or { 115 - eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 116 - return ctx.redirect('/settings') 117 } 118 119 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 120 - 121 if !app.set_password(user.id, hashed_new_password) { 122 - ctx.error('failed to update password') 123 } 124 125 - return ctx.redirect('/login') 126 } 127 128 @['/api/user/login'; post] 129 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 130 user := app.get_user_by_name(username) or { 131 - ctx.error('invalid credentials') 132 - return ctx.redirect('/login') 133 } 134 135 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 136 - ctx.error('invalid credentials') 137 - return ctx.redirect('/login') 138 } 139 140 - token := app.auth.add_token(user.id, ctx.ip()) or { 141 eprintln('failed to add token on log in: ${err}') 142 - ctx.error('could not create token for user with id ${user.id}') 143 - return ctx.redirect('/login') 144 } 145 146 ctx.set_cookie( ··· 151 path: '/' 152 ) 153 154 - return ctx.redirect('/') 155 } 156 157 - @['/api/user/logout'] 158 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 159 if token := ctx.get_cookie('token') { 160 - if user := app.get_user_by_token(ctx, token) { 161 - app.auth.delete_tokens_for_ip(ctx.ip()) or { 162 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 163 - return ctx.redirect('/login') 164 } 165 } else { 166 eprintln('failed to get user for token for logout') ··· 177 path: '/' 178 ) 179 180 - return ctx.redirect('/login') 181 } 182 183 - @['/api/user/full_logout'] 184 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 185 if token := ctx.get_cookie('token') { 186 - if user := app.get_user_by_token(ctx, token) { 187 app.auth.delete_tokens_for_user(user.id) or { 188 eprintln('failed to yeet tokens for ${user.id}') 189 - return ctx.redirect('/login') 190 } 191 } else { 192 eprintln('failed to get user for token for full_logout') ··· 203 path: '/' 204 ) 205 206 - return ctx.redirect('/login') 207 } 208 209 @['/api/user/set_nickname'; post] 210 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 211 user := app.whoami(mut ctx) or { 212 - ctx.error('you are not logged in!') 213 - return ctx.redirect('/login') 214 } 215 216 mut clean_nickname := ?string(nickname.trim_space()) ··· 220 221 // validate 222 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 223 - ctx.error('invalid nickname') 224 - return ctx.redirect('/me') 225 } 226 227 if !app.set_nickname(user.id, clean_nickname) { 228 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 229 - return ctx.redirect('/me') 230 } 231 232 - return ctx.redirect('/me') 233 } 234 235 @['/api/user/set_muted'; post] 236 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 237 user := app.whoami(mut ctx) or { 238 - ctx.error('you are not logged in!') 239 - return ctx.redirect('/login') 240 } 241 242 to_mute := app.get_user_by_id(id) or { 243 - ctx.error('no such user') 244 - return ctx.redirect('/') 245 } 246 247 if user.admin { 248 if !app.set_muted(to_mute.id, muted) { 249 - ctx.error('failed to change mute status') 250 - return ctx.redirect('/user/${to_mute.username}') 251 } 252 - return ctx.redirect('/user/${to_mute.username}') 253 } else { 254 - ctx.error('insufficient permissions!') 255 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 256 - return ctx.redirect('/user/${to_mute.username}') 257 } 258 } 259 260 @['/api/user/set_theme'; post] 261 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 262 if !app.config.instance.allow_changing_theme { 263 - ctx.error('this instance disallows changing themes :(') 264 - return ctx.redirect('/me') 265 } 266 267 user := app.whoami(mut ctx) or { 268 - ctx.error('you are not logged in!') 269 - return ctx.redirect('/login') 270 } 271 272 mut theme := ?string(none) 273 - if url.trim_space() != '' { 274 theme = url.trim_space() 275 } 276 277 if !app.set_theme(user.id, theme) { 278 - ctx.error('failed to change theme') 279 - return ctx.redirect('/me') 280 } 281 282 - return ctx.redirect('/me') 283 } 284 285 @['/api/user/set_pronouns'; post] 286 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 287 user := app.whoami(mut ctx) or { 288 - ctx.error('you are not logged in!') 289 - return ctx.redirect('/login') 290 } 291 292 clean_pronouns := pronouns.trim_space() 293 if !app.validators.pronouns.validate(clean_pronouns) { 294 - ctx.error('invalid pronouns') 295 - return ctx.redirect('/me') 296 } 297 298 if !app.set_pronouns(user.id, clean_pronouns) { 299 - ctx.error('failed to change pronouns') 300 - return ctx.redirect('/me') 301 } 302 303 - return ctx.redirect('/me') 304 } 305 306 @['/api/user/set_bio'; post] 307 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 308 user := app.whoami(mut ctx) or { 309 - ctx.error('you are not logged in!') 310 - return ctx.redirect('/login') 311 } 312 313 clean_bio := bio.trim_space() 314 if !app.validators.user_bio.validate(clean_bio) { 315 - ctx.error('invalid bio') 316 - return ctx.redirect('/me') 317 } 318 319 if !app.set_bio(user.id, clean_bio) { 320 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 321 - return ctx.redirect('/me') 322 } 323 324 - return ctx.redirect('/me') 325 } 326 327 - @['/api/user/get_name'] 328 fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 329 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 330 return ctx.text(user.get_name()) 331 } 332 333 - /// user/notification /// 334 - 335 - @['/api/user/notification/clear'] 336 - fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 337 - user := app.whoami(mut ctx) or { 338 - ctx.error('you are not logged in!') 339 - return ctx.redirect('/login') 340 - } 341 - 342 - if notification := app.get_notification_by_id(id) { 343 - if notification.user_id != user.id { 344 - ctx.error('no such notification for user') 345 - return ctx.redirect('/inbox') 346 - } else { 347 - if !app.delete_notification(id) { 348 - ctx.error('failed to delete notification') 349 - return ctx.redirect('/inbox') 350 - } 351 - } 352 - } else { 353 - ctx.error('no such notification for user') 354 - } 355 - 356 - return ctx.redirect('/inbox') 357 - } 358 - 359 - @['/api/user/notification/clear_all'] 360 - fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 361 - user := app.whoami(mut ctx) or { 362 - ctx.error('you are not logged in!') 363 - return ctx.redirect('/login') 364 - } 365 - if !app.delete_notifications_for_user(user.id) { 366 - ctx.error('failed to delete notifications') 367 - return ctx.redirect('/inbox') 368 - } 369 - return ctx.redirect('/inbox') 370 - } 371 - 372 - @['/api/user/delete'] 373 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 374 user := app.whoami(mut ctx) or { 375 - ctx.error('you are not logged in!') 376 - return ctx.redirect('/login') 377 } 378 379 - println('attempting to delete ${id} as ${user.id}') 380 381 - if user.admin || user.id == id { 382 // yeet 383 if !app.delete_user(user.id) { 384 - ctx.error('failed to delete user: ${id}') 385 - return ctx.redirect('/') 386 } 387 388 app.auth.delete_tokens_for_user(id) or { ··· 399 ) 400 } 401 println('deleted user ${id}') 402 } else { 403 - ctx.error('be nice. deleting other users is off-limits.') 404 } 405 - 406 - return ctx.redirect('/') 407 } 408 409 @['/api/user/search'; get] 410 fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 411 if limit >= search_hard_limit { 412 - return ctx.text('limit exceeds hard limit (${search_hard_limit})') 413 } 414 users := app.search_for_users(query, limit, offset) 415 return ctx.json[[]User](users) 416 } 417 418 ////// post ////// 419 420 @['/api/post/new_post'; post] 421 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 422 user := app.whoami(mut ctx) or { 423 - ctx.error('not logged in!') 424 - return ctx.redirect('/login') 425 } 426 427 if user.muted { 428 - ctx.error('you are muted!') 429 - return ctx.redirect('/post/new') 430 } 431 432 // validate title 433 if !app.validators.post_title.validate(title) { 434 - ctx.error('invalid title') 435 - return ctx.redirect('/post/new') 436 } 437 438 // validate body 439 if !app.validators.post_body.validate(body) { 440 - ctx.error('invalid body') 441 - return ctx.redirect('/post/new') 442 } 443 444 mut post := Post{ 445 author_id: user.id 446 title: title 447 body: body 448 } 449 450 if replying_to != 0 { 451 // check if replying post exists 452 app.get_post_by_id(replying_to) or { 453 - ctx.error('the post you are trying to reply to does not exist') 454 - return ctx.redirect('/post/new') 455 } 456 post.replying_to = replying_to 457 } 458 459 if !app.add_post(post) { 460 - ctx.error('failed to post!') 461 println('failed to post: ${post} from user ${user.id}') 462 - return ctx.redirect('/post/new') 463 } 464 465 // find the post's id to process mentions with 466 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 467 app.process_post_mentions(x) 468 - return ctx.redirect('/post/${x.id}') 469 } else { 470 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 471 - return ctx.redirect('/me') 472 } 473 } 474 475 @['/api/post/delete'; post] 476 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 477 user := app.whoami(mut ctx) or { 478 - ctx.error('not logged in!') 479 - return ctx.redirect('/login') 480 } 481 482 post := app.get_post_by_id(id) or { 483 - ctx.error('post does not exist') 484 - return ctx.redirect('/') 485 } 486 487 if user.admin || user.id == post.author_id { 488 if !app.delete_post(post.id) { 489 - ctx.error('failed to delete post') 490 - eprintln('failed to delete post: ${id}') 491 - return ctx.redirect('/') 492 } 493 println('deleted post: ${id}') 494 - return ctx.redirect('/') 495 } else { 496 - ctx.error('insufficient permissions!') 497 eprintln('insufficient perms to delete post: ${id} (${user.id})') 498 - return ctx.redirect('/') 499 } 500 } 501 502 - @['/api/post/like'] 503 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 504 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 505 506 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 507 508 if app.does_user_like_post(user.id, post.id) { 509 if !app.unlike_post(post.id, user.id) { ··· 532 } 533 } 534 535 - @['/api/post/dislike'] 536 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 537 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 538 539 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 540 541 if app.does_user_dislike_post(user.id, post.id) { 542 if !app.unlike_post(post.id, user.id) { ··· 565 } 566 } 567 568 - @['/api/post/save'] 569 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 570 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 571 572 if app.get_post_by_id(id) != none { 573 if app.toggle_save_post(user.id, id) { ··· 580 } 581 } 582 583 - @['/api/post/save_for_later'] 584 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 585 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 586 587 if app.get_post_by_id(id) != none { 588 if app.toggle_save_for_later_post(user.id, id) { ··· 595 } 596 } 597 598 - @['/api/post/get_title'] 599 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 600 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 601 return ctx.text(post.title) 602 } ··· 604 @['/api/post/edit'; post] 605 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 606 user := app.whoami(mut ctx) or { 607 - ctx.error('not logged in!') 608 - return ctx.redirect('/login') 609 } 610 post := app.get_post_by_id(id) or { 611 - ctx.error('no such post') 612 - return ctx.redirect('/') 613 } 614 if post.author_id != user.id { 615 - ctx.error('insufficient permissions') 616 - return ctx.redirect('/') 617 } 618 619 - if !app.update_post(id, title, body) { 620 eprintln('failed to update post') 621 - ctx.error('failed to update post') 622 - return ctx.redirect('/') 623 } 624 625 - return ctx.redirect('/post/${id}') 626 } 627 628 @['/api/post/pin'; post] 629 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 630 user := app.whoami(mut ctx) or { 631 - ctx.error('not logged in!') 632 - return ctx.redirect('/login') 633 } 634 635 if user.admin { 636 if !app.pin_post(id) { 637 eprintln('failed to pin post: ${id}') 638 - ctx.error('failed to pin post') 639 - return ctx.redirect('/post/${id}') 640 } 641 - return ctx.redirect('/post/${id}') 642 } else { 643 - ctx.error('insufficient permissions!') 644 eprintln('insufficient perms to pin post: ${id} (${user.id})') 645 - return ctx.redirect('/') 646 } 647 } 648 649 @['/api/post/get/<id>'; get] 650 fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 651 - post := app.get_post_by_id(id) or { 652 - return ctx.text('no such post') 653 } 654 return ctx.json[Post](post) 655 } 656 657 @['/api/post/search'; get] 658 fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 659 if limit >= search_hard_limit { 660 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 661 } ··· 668 @['/api/site/set_motd'; post] 669 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 670 user := app.whoami(mut ctx) or { 671 - ctx.error('not logged in!') 672 - return ctx.redirect('/login') 673 } 674 675 if user.admin { 676 if !app.set_motd(motd) { 677 - ctx.error('failed to set motd') 678 eprintln('failed to set motd: ${motd}') 679 - return ctx.redirect('/') 680 } 681 println('set motd to: ${motd}') 682 - return ctx.redirect('/') 683 } else { 684 - ctx.error('insufficient permissions!') 685 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 686 - return ctx.redirect('/') 687 } 688 }
··· 2 3 import veb 4 import auth 5 + import entity { Like, Post, User } 6 import database { PostSearchResult } 7 + import net.http 8 + import json 9 10 // search_hard_limit is the maximum limit for a search query, used to prevent 11 // people from requesting searches with huge limits and straining the SQL server 12 + pub const search_hard_limit = 50 13 + pub const not_logged_in_msg = 'you are not logged in!' 14 15 ////// user ////// 16 17 + struct HcaptchaResponse { 18 + pub: 19 + success bool 20 + error_codes []string @[json: 'error-codes'] 21 + } 22 + 23 @['/api/user/register'; post] 24 fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { 25 + // before doing *anything*, check the captchas 26 + if app.config.hcaptcha.enabled { 27 + token := ctx.form['h-captcha-response'] 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 + } 35 + data := json.decode(HcaptchaResponse, response.body) or { 36 + return ctx.server_error('failed to decode hcaptcha response: ${err}') 37 + } 38 + if !data.success { 39 + return ctx.server_error('failed to verify hcaptcha: ${data}') 40 + } 41 + } 42 + 43 + if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code { 44 + return ctx.server_error('invalid invite code') 45 + } 46 + 47 if app.get_user_by_name(username) != none { 48 + return ctx.server_error('username taken') 49 } 50 51 // validate username 52 if !app.validators.username.validate(username) { 53 + return ctx.server_error('invalid username') 54 } 55 56 // validate password 57 if !app.validators.password.validate(password) { 58 + return ctx.server_error('invalid password') 59 + } 60 + 61 + if password != ctx.form['confirm-password'] { 62 + return ctx.server_error('passwords do not match') 63 } 64 65 salt := auth.generate_salt() ··· 74 } 75 76 if x := app.new_user(user) { 77 + app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()), 78 + app.config.welcome.body.replace('%s', x.get_name())) 79 + token := app.auth.add_token(x.id) or { 80 + eprintln('api_user_register: could not create token for user with id ${x.id}: ${err}') 81 + return ctx.server_error('could not create token for user') 82 } 83 ctx.set_cookie( 84 name: 'token' ··· 88 path: '/' 89 ) 90 } else { 91 + eprintln('api_user_register: could not log into newly-created user: ${user}') 92 + return ctx.server_error('could not log into newly-created user.') 93 } 94 95 + return ctx.ok('user registered') 96 } 97 98 @['/api/user/set_username'; post] 99 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 100 user := app.whoami(mut ctx) or { 101 + return ctx.unauthorized(not_logged_in_msg) 102 } 103 104 if app.get_user_by_name(new_username) != none { 105 + return ctx.server_error('username taken') 106 } 107 108 // validate username 109 if !app.validators.username.validate(new_username) { 110 + return ctx.server_error('invalid username') 111 } 112 113 if !app.set_username(user.id, new_username) { 114 + return ctx.server_error('failed to update username') 115 } 116 117 + return ctx.ok('username updated') 118 } 119 120 @['/api/user/set_password'; post] 121 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 122 user := app.whoami(mut ctx) or { 123 + return ctx.unauthorized(not_logged_in_msg) 124 } 125 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 127 + return ctx.server_error('current_password is incorrect') 128 } 129 130 // validate password 131 if !app.validators.password.validate(new_password) { 132 + return ctx.server_error('invalid password') 133 } 134 135 + if new_password != ctx.form['confirm_password'] { 136 + return ctx.server_error('passwords do not match') 137 } 138 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 140 if !app.set_password(user.id, hashed_new_password) { 141 + return ctx.server_error('failed to update password') 142 } 143 144 + // invalidate tokens and log out 145 + app.auth.delete_tokens_for_user(user.id) or { 146 + eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 147 + return ctx.server_error('failed to delete tokens during password deletion') 148 + } 149 + ctx.set_cookie( 150 + name: 'token' 151 + value: '' 152 + same_site: .same_site_none_mode 153 + secure: true 154 + path: '/' 155 + ) 156 + 157 + return ctx.ok('password updated') 158 } 159 160 @['/api/user/login'; post] 161 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 162 user := app.get_user_by_name(username) or { 163 + return ctx.server_error('invalid credentials') 164 } 165 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 167 + return ctx.server_error('invalid credentials') 168 } 169 170 + token := app.auth.add_token(user.id) or { 171 eprintln('failed to add token on log in: ${err}') 172 + return ctx.server_error('could not create token for user with id ${user.id}') 173 } 174 175 ctx.set_cookie( ··· 180 path: '/' 181 ) 182 183 + return ctx.ok('logged in') 184 } 185 186 + @['/api/user/logout'; post] 187 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 188 if token := ctx.get_cookie('token') { 189 + if user := app.get_user_by_token(token) { 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 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 196 } 197 } else { 198 eprintln('failed to get user for token for logout') ··· 209 path: '/' 210 ) 211 212 + return ctx.ok('logged out') 213 } 214 215 + @['/api/user/full_logout'; post] 216 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 217 if token := ctx.get_cookie('token') { 218 + if user := app.get_user_by_token(token) { 219 app.auth.delete_tokens_for_user(user.id) or { 220 eprintln('failed to yeet tokens for ${user.id}') 221 } 222 } else { 223 eprintln('failed to get user for token for full_logout') ··· 234 path: '/' 235 ) 236 237 + return ctx.ok('logged out') 238 } 239 240 @['/api/user/set_nickname'; post] 241 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 242 user := app.whoami(mut ctx) or { 243 + return ctx.unauthorized(not_logged_in_msg) 244 } 245 246 mut clean_nickname := ?string(nickname.trim_space()) ··· 250 251 // validate 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 253 + return ctx.server_error('invalid nickname') 254 } 255 256 if !app.set_nickname(user.id, clean_nickname) { 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 258 + return ctx.server_error('failed to update nickname') 259 } 260 261 + return ctx.ok('updated nickname') 262 } 263 264 @['/api/user/set_muted'; post] 265 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 266 user := app.whoami(mut ctx) or { 267 + return ctx.unauthorized(not_logged_in_msg) 268 } 269 270 to_mute := app.get_user_by_id(id) or { 271 + return ctx.server_error('no such user') 272 } 273 274 if user.admin { 275 if !app.set_muted(to_mute.id, muted) { 276 + return ctx.server_error('failed to change mute status') 277 } 278 + return ctx.ok('muted user') 279 } else { 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 281 + return ctx.unauthorized('insufficient permissions') 282 + } 283 + } 284 + 285 + @['/api/user/set_automated'; post] 286 + fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result { 287 + user := app.whoami(mut ctx) or { 288 + return ctx.unauthorized(not_logged_in_msg) 289 + } 290 + 291 + if !app.set_automated(user.id, is_automated) { 292 + return ctx.server_error('failed to set automated status.') 293 + } 294 + 295 + if is_automated { 296 + return ctx.ok('you\'re now a bot! :D') 297 + } else { 298 + return ctx.ok('you\'re no longer a bot :(') 299 } 300 } 301 302 @['/api/user/set_theme'; post] 303 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 304 if !app.config.instance.allow_changing_theme { 305 + return ctx.server_error('this instance disallows changing themes :(') 306 } 307 308 user := app.whoami(mut ctx) or { 309 + return ctx.unauthorized(not_logged_in_msg) 310 } 311 312 mut theme := ?string(none) 313 + if url.trim_space() == '' { 314 + theme = app.config.instance.default_theme 315 + } else { 316 theme = url.trim_space() 317 } 318 319 if !app.set_theme(user.id, theme) { 320 + return ctx.server_error('failed to change theme') 321 } 322 323 + return ctx.ok('theme updated') 324 + } 325 + 326 + @['/api/user/set_css'; post] 327 + fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { 328 + if !app.config.instance.allow_changing_theme { 329 + return ctx.server_error('this instance disallows changing themes :(') 330 + } 331 + 332 + user := app.whoami(mut ctx) or { 333 + return ctx.unauthorized(not_logged_in_msg) 334 + } 335 + 336 + c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() } 337 + 338 + if !app.set_css(user.id, c) { 339 + return ctx.server_error('failed to change css') 340 + } 341 + 342 + return ctx.ok('css updated') 343 } 344 345 @['/api/user/set_pronouns'; post] 346 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 347 user := app.whoami(mut ctx) or { 348 + return ctx.unauthorized(not_logged_in_msg) 349 } 350 351 clean_pronouns := pronouns.trim_space() 352 if !app.validators.pronouns.validate(clean_pronouns) { 353 + return ctx.server_error('invalid pronouns') 354 } 355 356 if !app.set_pronouns(user.id, clean_pronouns) { 357 + return ctx.server_error('failed to change pronouns') 358 } 359 360 + return ctx.ok('pronouns updated') 361 } 362 363 @['/api/user/set_bio'; post] 364 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 365 user := app.whoami(mut ctx) or { 366 + return ctx.unauthorized(not_logged_in_msg) 367 } 368 369 clean_bio := bio.trim_space() 370 if !app.validators.user_bio.validate(clean_bio) { 371 + return ctx.server_error('invalid bio') 372 } 373 374 if !app.set_bio(user.id, clean_bio) { 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 376 + return ctx.server_error('failed to update bio') 377 } 378 379 + return ctx.ok('bio updated') 380 } 381 382 + @['/api/user/get_name'; get] 383 fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 384 + if !app.config.instance.public_data { 385 + _ := app.whoami(mut ctx) or { 386 + return ctx.unauthorized('no such user') 387 + } 388 + } 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 390 return ctx.text(user.get_name()) 391 } 392 393 + @['/api/user/delete'; post] 394 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 395 user := app.whoami(mut ctx) or { 396 + return ctx.unauthorized(not_logged_in_msg) 397 } 398 399 + if user.admin || user.id == id { 400 + println('attempting to delete ${id} as ${user.id}') 401 402 // yeet 403 if !app.delete_user(user.id) { 404 + return ctx.server_error('failed to delete user: ${id}') 405 } 406 407 app.auth.delete_tokens_for_user(id) or { ··· 418 ) 419 } 420 println('deleted user ${id}') 421 + return ctx.ok('user deleted') 422 } else { 423 + return ctx.unauthorized('be nice. deleting other users is off-limits.') 424 } 425 } 426 427 @['/api/user/search'; get] 428 fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result { 429 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 430 if limit >= search_hard_limit { 431 + return ctx.server_error('limit exceeds hard limit (${search_hard_limit})') 432 } 433 users := app.search_for_users(query, limit, offset) 434 return ctx.json[[]User](users) 435 } 436 437 + @['/api/user/whoami'; get] 438 + fn (mut app App) api_user_whoami(mut ctx Context) veb.Result { 439 + user := app.whoami(mut ctx) or { 440 + return ctx.unauthorized(not_logged_in_msg) 441 + } 442 + return ctx.text(user.username) 443 + } 444 + 445 + /// user/notification /// 446 + 447 + @['/api/user/notification/clear'; post] 448 + fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 449 + user := app.whoami(mut ctx) or { 450 + return ctx.unauthorized(not_logged_in_msg) 451 + } 452 + 453 + if notification := app.get_notification_by_id(id) { 454 + if notification.user_id != user.id { 455 + return ctx.server_error('no such notification for user') 456 + } else if !app.delete_notification(id) { 457 + return ctx.server_error('failed to delete notification') 458 + } 459 + } else { 460 + return ctx.server_error('no such notification for user') 461 + } 462 + 463 + return ctx.ok('cleared notification') 464 + } 465 + 466 + @['/api/user/notification/clear_all'; post] 467 + fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 468 + user := app.whoami(mut ctx) or { 469 + return ctx.unauthorized(not_logged_in_msg) 470 + } 471 + if !app.delete_notifications_for_user(user.id) { 472 + return ctx.server_error('failed to delete notifications') 473 + } 474 + return ctx.ok('cleared notifications') 475 + } 476 + 477 ////// post ////// 478 479 @['/api/post/new_post'; post] 480 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 481 user := app.whoami(mut ctx) or { 482 + return ctx.unauthorized(not_logged_in_msg) 483 } 484 485 if user.muted { 486 + return ctx.server_error('you are muted!') 487 } 488 489 // validate title 490 if !app.validators.post_title.validate(title) { 491 + return ctx.server_error('invalid title') 492 } 493 494 // validate body 495 if !app.validators.post_body.validate(body) { 496 + return ctx.server_error('invalid body') 497 + } 498 + 499 + nsfw := 'nsfw' in ctx.form 500 + if nsfw && !app.config.post.allow_nsfw { 501 + return ctx.server_error('nsfw posts are not allowed on this instance') 502 } 503 504 mut post := Post{ 505 author_id: user.id 506 title: title 507 body: body 508 + nsfw: nsfw 509 } 510 511 if replying_to != 0 { 512 // check if replying post exists 513 app.get_post_by_id(replying_to) or { 514 + return ctx.server_error('the post you are trying to reply to does not exist') 515 } 516 post.replying_to = replying_to 517 } 518 519 if !app.add_post(post) { 520 println('failed to post: ${post} from user ${user.id}') 521 + return ctx.server_error('failed to post') 522 } 523 524 + //TODO: Can I not just get the ID directly?? This method feels dicey at best. 525 // find the post's id to process mentions with 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 527 app.process_post_mentions(x) 528 + return ctx.ok('posted. id=${x.id}') 529 } else { 530 + eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}') 531 + return ctx.server_error('failed to get post ID, this error should never happen') 532 } 533 } 534 535 @['/api/post/delete'; post] 536 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 537 user := app.whoami(mut ctx) or { 538 + return ctx.unauthorized(not_logged_in_msg) 539 } 540 541 post := app.get_post_by_id(id) or { 542 + return ctx.server_error('post does not exist') 543 } 544 545 if user.admin || user.id == post.author_id { 546 if !app.delete_post(post.id) { 547 + eprintln('api_post_delete: failed to delete post: ${id}') 548 + return ctx.server_error('failed to delete post') 549 } 550 println('deleted post: ${id}') 551 + return ctx.ok('post deleted') 552 } else { 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 554 + return ctx.unauthorized('insufficient permissions') 555 } 556 } 557 558 + @['/api/post/like'; post] 559 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 560 + user := app.whoami(mut ctx) or { 561 + return ctx.unauthorized(not_logged_in_msg) 562 + } 563 564 + post := app.get_post_by_id(id) or { 565 + return ctx.server_error('post does not exist') 566 + } 567 568 if app.does_user_like_post(user.id, post.id) { 569 if !app.unlike_post(post.id, user.id) { ··· 592 } 593 } 594 595 + @['/api/post/dislike'; post] 596 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 597 + user := app.whoami(mut ctx) or { 598 + return ctx.unauthorized(not_logged_in_msg) 599 + } 600 601 + post := app.get_post_by_id(id) or { 602 + return ctx.server_error('post does not exist') 603 + } 604 605 if app.does_user_dislike_post(user.id, post.id) { 606 if !app.unlike_post(post.id, user.id) { ··· 629 } 630 } 631 632 + @['/api/post/save'; post] 633 fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 634 + user := app.whoami(mut ctx) or { 635 + return ctx.unauthorized(not_logged_in_msg) 636 + } 637 638 if app.get_post_by_id(id) != none { 639 if app.toggle_save_post(user.id, id) { ··· 646 } 647 } 648 649 + @['/api/post/save_for_later'; post] 650 fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 651 + user := app.whoami(mut ctx) or { 652 + return ctx.unauthorized(not_logged_in_msg) 653 + } 654 655 if app.get_post_by_id(id) != none { 656 if app.toggle_save_for_later_post(user.id, id) { ··· 663 } 664 } 665 666 + @['/api/post/get_title'; get] 667 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 668 + if !app.config.instance.public_data { 669 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 670 + } 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 672 return ctx.text(post.title) 673 } ··· 675 @['/api/post/edit'; post] 676 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 677 user := app.whoami(mut ctx) or { 678 + return ctx.unauthorized(not_logged_in_msg) 679 } 680 post := app.get_post_by_id(id) or { 681 + return ctx.server_error('no such post') 682 } 683 if post.author_id != user.id { 684 + return ctx.unauthorized('insufficient permissions') 685 + } 686 + 687 + nsfw := if 'nsfw' in ctx.form { 688 + app.config.post.allow_nsfw 689 + } else { 690 + post.nsfw 691 } 692 693 + if !app.update_post(id, title, body, nsfw) { 694 eprintln('failed to update post') 695 + return ctx.server_error('failed to update post') 696 } 697 698 + return ctx.ok('posted edited') 699 } 700 701 @['/api/post/pin'; post] 702 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 703 user := app.whoami(mut ctx) or { 704 + return ctx.unauthorized(not_logged_in_msg) 705 } 706 707 if user.admin { 708 if !app.pin_post(id) { 709 eprintln('failed to pin post: ${id}') 710 + return ctx.server_error('failed to pin post') 711 } 712 + return ctx.ok('post pinned') 713 } else { 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 715 + return ctx.unauthorized('insufficient permissions') 716 } 717 } 718 719 @['/api/post/get/<id>'; get] 720 fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 721 + if !app.config.instance.public_data { 722 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 723 } 724 + post := app.get_post_by_id(id) or { return ctx.text('no such post') } 725 return ctx.json[Post](post) 726 } 727 728 @['/api/post/search'; get] 729 fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result { 730 + _ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) } 731 if limit >= search_hard_limit { 732 return ctx.text('limit exceeds hard limit (${search_hard_limit})') 733 } ··· 740 @['/api/site/set_motd'; post] 741 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 742 user := app.whoami(mut ctx) or { 743 + return ctx.unauthorized(not_logged_in_msg) 744 } 745 746 if user.admin { 747 if !app.set_motd(motd) { 748 eprintln('failed to set motd: ${motd}') 749 + return ctx.server_error('failed to set motd') 750 } 751 println('set motd to: ${motd}') 752 + return ctx.ok('motd updated') 753 } else { 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 755 + return ctx.unauthorized('insufficient permissions') 756 } 757 }
+15 -6
src/webapp/app.v
··· 11 veb.StaticHandler 12 DatabaseAccess 13 pub: 14 - config Config 15 pub mut: 16 auth auth.Auth[pg.DB] 17 validators struct { ··· 28 29 // get_user_by_token returns a user by their token, returns none if the user was 30 // not found. 31 - pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 32 - user_token := app.auth.find_token(token, ctx.ip()) or { 33 eprintln('no such user corresponding to token') 34 return none 35 } ··· 43 if token == '' { 44 return none 45 } 46 - if user := app.get_user_by_token(ctx, token) { 47 if user.username == '' || user.id == 0 { 48 eprintln('a user had a token for the blank user') 49 // Clear token ··· 123 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 124 return 125 } 126 - matches := re.find_all_str(post.body) 127 - for mat in matches { 128 println('found mentioned user: ${mat}') 129 username := mat#[2..-1] 130 user := app.get_user_by_name(username) or {
··· 11 veb.StaticHandler 12 DatabaseAccess 13 pub: 14 + config Config 15 + buildinfo BuildInfo 16 + built_at string = @BUILD_TIMESTAMP 17 + v_hash string = @VHASH 18 pub mut: 19 auth auth.Auth[pg.DB] 20 validators struct { ··· 31 32 // get_user_by_token returns a user by their token, returns none if the user was 33 // not found. 34 + pub fn (app &App) get_user_by_token(token string) ?User { 35 + user_token := app.auth.find_token(token) or { 36 eprintln('no such user corresponding to token') 37 return none 38 } ··· 46 if token == '' { 47 return none 48 } 49 + if user := app.get_user_by_token(token) { 50 if user.username == '' || user.id == 0 { 51 eprintln('a user had a token for the blank user') 52 // Clear token ··· 126 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 127 return 128 } 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 + 137 println('found mentioned user: ${mat}') 138 username := mat#[2..-1] 139 user := app.get_user_by_name(username) or {
+43
src/webapp/config.v
··· 12 name string 13 welcome string 14 default_theme string 15 allow_changing_theme bool 16 } 17 http struct { 18 pub mut: ··· 26 password string 27 db string 28 } 29 post struct { 30 pub mut: 31 title_min_len int ··· 34 body_min_len int 35 body_max_len int 36 body_pattern string 37 } 38 user struct { 39 pub mut: ··· 71 config.instance.name = loaded_instance.get('name').to_str() 72 config.instance.welcome = loaded_instance.get('welcome').to_str() 73 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 74 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 75 76 loaded_http := loaded.get('http') 77 config.http.port = loaded_http.get('port').to_int() ··· 82 config.postgres.user = loaded_postgres.get('user').to_str() 83 config.postgres.password = loaded_postgres.get('password').to_str() 84 config.postgres.db = loaded_postgres.get('db').to_str() 85 86 loaded_post := loaded.get('post') 87 config.post.title_min_len = loaded_post.get('title_min_len').to_int() ··· 90 config.post.body_min_len = loaded_post.get('body_min_len').to_int() 91 config.post.body_max_len = loaded_post.get('body_max_len').to_int() 92 config.post.body_pattern = loaded_post.get('body_pattern').to_str() 93 94 loaded_user := loaded.get('user') 95 config.user.username_min_len = loaded_user.get('username_min_len').to_int() ··· 114 115 return config 116 }
··· 12 name string 13 welcome string 14 default_theme string 15 + default_css string 16 allow_changing_theme bool 17 + version string 18 + source string 19 + v_source string 20 + invite_only bool 21 + invite_code string 22 + public_data bool 23 + owner_username string 24 } 25 http struct { 26 pub mut: ··· 34 password string 35 db string 36 } 37 + hcaptcha struct { 38 + pub mut: 39 + enabled bool 40 + secret string 41 + site_key string 42 + } 43 post struct { 44 pub mut: 45 title_min_len int ··· 48 body_min_len int 49 body_max_len int 50 body_pattern string 51 + allow_nsfw bool 52 } 53 user struct { 54 pub mut: ··· 86 config.instance.name = loaded_instance.get('name').to_str() 87 config.instance.welcome = loaded_instance.get('welcome').to_str() 88 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 89 + config.instance.default_css = loaded_instance.get('default_css').to_str() 90 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 91 + config.instance.version = loaded_instance.get('version').to_str() 92 + config.instance.source = loaded_instance.get('source').to_str() 93 + config.instance.v_source = loaded_instance.get('v_source').to_str() 94 + config.instance.invite_only = loaded_instance.get('invite_only').to_bool() 95 + config.instance.invite_code = loaded_instance.get('invite_code').to_str() 96 + config.instance.public_data = loaded_instance.get('public_data').to_bool() 97 + config.instance.owner_username = loaded_instance.get('owner_username').to_str() 98 99 loaded_http := loaded.get('http') 100 config.http.port = loaded_http.get('port').to_int() ··· 105 config.postgres.user = loaded_postgres.get('user').to_str() 106 config.postgres.password = loaded_postgres.get('password').to_str() 107 config.postgres.db = loaded_postgres.get('db').to_str() 108 + 109 + loaded_hcaptcha := loaded.get('hcaptcha') 110 + config.hcaptcha.enabled = loaded_hcaptcha.get('enabled').to_bool() 111 + config.hcaptcha.secret = loaded_hcaptcha.get('secret').to_str() 112 + config.hcaptcha.site_key = loaded_hcaptcha.get('site_key').to_str() 113 114 loaded_post := loaded.get('post') 115 config.post.title_min_len = loaded_post.get('title_min_len').to_int() ··· 118 config.post.body_min_len = loaded_post.get('body_min_len').to_int() 119 config.post.body_max_len = loaded_post.get('body_max_len').to_int() 120 config.post.body_pattern = loaded_post.get('body_pattern').to_str() 121 + config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool() 122 123 loaded_user := loaded.get('user') 124 config.user.username_min_len = loaded_user.get('username_min_len').to_int() ··· 143 144 return config 145 } 146 + 147 + pub struct BuildInfo { 148 + pub mut: 149 + commit string 150 + } 151 + 152 + pub fn load_buildinfo_from(file_path string) BuildInfo { 153 + loaded := maple.load_file(file_path) or { panic(err) } 154 + mut buildinfo := BuildInfo{} 155 + 156 + buildinfo.commit = loaded.get('commit').to_str() 157 + 158 + return buildinfo 159 + }
+41 -3
src/webapp/pages.v
··· 4 import entity { User } 5 6 fn (mut app App) index(mut ctx Context) veb.Result { 7 ctx.title = app.config.instance.name 8 user := app.whoami(mut ctx) or { User{} } 9 recent_posts := app.get_recent_posts() ··· 91 92 @['/user/:username'] 93 fn (mut app App) user(mut ctx Context, username string) veb.Result { 94 user := app.whoami(mut ctx) or { User{} } 95 viewing := app.get_user_by_name(username) or { 96 ctx.error('user not found') ··· 98 } 99 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 100 posts := app.get_posts_from_user(viewing.id, 10) 101 return $veb.html('../templates/user.html') 102 } 103 104 @['/post/:post_id'] 105 fn (mut app App) post(mut ctx Context, post_id int) veb.Result { 106 post := app.get_post_by_id(post_id) or { 107 ctx.error('no such post') 108 return ctx.redirect('/') ··· 114 mut replying_to_user := app.get_unknown_user() 115 116 if post.replying_to != none { 117 - replying_to_post = app.get_post_by_id(post.replying_to) or { 118 - app.get_unknown_post() 119 - } 120 replying_to_user = app.get_user_by_id(replying_to_post.author_id) or { 121 app.get_unknown_user() 122 } ··· 206 ctx.title = '${app.config.instance.name} - search' 207 return $veb.html('../templates/search.html') 208 }
··· 4 import entity { User } 5 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 + 14 ctx.title = app.config.instance.name 15 user := app.whoami(mut ctx) or { User{} } 16 recent_posts := app.get_recent_posts() ··· 98 99 @['/user/:username'] 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 + 108 user := app.whoami(mut ctx) or { User{} } 109 viewing := app.get_user_by_name(username) or { 110 ctx.error('user not found') ··· 112 } 113 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 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 + 121 return $veb.html('../templates/user.html') 122 } 123 124 @['/post/:post_id'] 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 + 133 post := app.get_post_by_id(post_id) or { 134 ctx.error('no such post') 135 return ctx.redirect('/') ··· 141 mut replying_to_user := app.get_unknown_user() 142 143 if post.replying_to != none { 144 + replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() } 145 replying_to_user = app.get_user_by_id(replying_to_post.author_id) or { 146 app.get_unknown_user() 147 } ··· 231 ctx.title = '${app.config.instance.name} - search' 232 return $veb.html('../templates/search.html') 233 } 234 + 235 + @['/about'] 236 + fn (mut app App) about(mut ctx Context) veb.Result { 237 + user := app.whoami(mut ctx) or { 238 + if !app.config.instance.public_data { 239 + ctx.error('not logged in') 240 + return ctx.redirect('/login') 241 + } 242 + User{} 243 + } 244 + ctx.title = '${app.config.instance.name} - about' 245 + return $veb.html('../templates/about.html') 246 + }
+14 -2
src/webapp/validation.v
··· 13 // validate validates a given string and returns true if it succeeded and false 14 // otherwise. 15 @[inline] 16 - pub fn (validator StringValidator) validate(str string) bool { 17 - return str.len > validator.min_len && str.len < validator.max_len 18 && validator.pattern.matches_string(str) 19 } 20
··· 13 // validate validates a given string and returns true if it succeeded and false 14 // otherwise. 15 @[inline] 16 + pub fn (validator StringValidator) validate(str_ string) bool { 17 + // for whatever reason form inputs can end up with \r\n. i have 18 + // absolutely no clue why this is a thing. anyway, this is here as a fix 19 + str := str_.replace('\r\n', '\n') 20 + 21 + // used for debugging validators. don't uncomment this in prod, please. 22 + // a) it will log a crap ton of unneeded info, and b) basically all user 23 + // inputs are validated. including passwords. 24 + // println('validator on: ${str}') 25 + // println(' >= min_len: ${str.len >= validator.min_len} (${str.len} >= ${validator.min_len})') 26 + // println(' <= max_len: ${str.len <= validator.max_len} (${str.len} <= ${validator.max_len})') 27 + // println(' regex: ${validator.pattern.matches_string(str)}') 28 + 29 + return str.len >= validator.min_len && str.len <= validator.max_len 30 && validator.pattern.matches_string(str) 31 } 32
+1 -1
v.mod
··· 1 Module { 2 name: 'beep' 3 - description: 'A self-hosted mini-blogger' 4 version: '1.0.0' 5 license: 'MIT' 6 author: 'EmmaTheMartian'
··· 1 Module { 2 name: 'beep' 3 + description: 'a self-hosted mini-blogger' 4 version: '1.0.0' 5 license: 'MIT' 6 author: 'EmmaTheMartian'