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 6 7 7 [*.v] 8 8 indent_style = tab 9 + 10 + [*.{html,css,js}] 11 + indent_style = tab
+13 -24
.gitignore
··· 1 - # Binaries for programs and plugins 2 - main 3 - clockwork 4 - beep 5 - *.exe 6 - *.exe~ 7 - *.so 8 - *.dylib 9 - *.dll 1 + # Binaries 2 + /beep 3 + /build/ 4 + /scripts/fetchbuildinfo 10 5 11 - # Ignore binary output folders 12 - bin/ 13 - 14 - # Ignore common editor/system specific metadata 6 + # Editor/system specific metadata 15 7 .DS_Store 16 - .idea/ 17 8 .vscode/ 18 - *.iml 19 9 20 - # ENV 10 + # Secrets 11 + /config.real.maple 21 12 .env 22 13 23 - # vweb and database 24 - *.db 14 + # Build data 15 + /buildinfo.maple 25 16 26 - # Local V install 17 + # Local V and Clockwork install (Gitpod) 18 + /clockwork 27 19 /v/ 28 20 29 - # Local Clockwork install 30 - /clockwork/ 31 - 32 - # "Real" config (contains secrets and such) 33 - /config.real.maple 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"]
+52 -4
build.maple
··· 1 1 plugins = [ 'v' ] 2 2 3 + task::fetch-build-info = { 4 + description = 'Fetch misc build information, mainly for the about page' 5 + run = 'v scripts/fetchbuildinfo.vsh' 6 + } 7 + 8 + // Database 9 + 3 10 task:db.init = { 4 11 description = 'Initialize and start a local Postgres database via Docker' 5 12 category = 'db' ··· 9 16 -e POSTGRES_USER=beep \ 10 17 -e POSTGRES_PASSWORD=beep \ 11 18 --mount source=beep-data,target=/var/lib/postgresql/data \ 12 - -p 5432:5432 \ 13 - postgres:15' 19 + -p 127.0.0.1:5432:5432 \ 20 + postgres:17' 14 21 } 15 22 16 23 task:db.start = { ··· 43 50 run = 'docker rm beep-database && docker volume rm beep-data' 44 51 } 45 52 53 + // Ngrok 54 + 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 + 46 69 task:run = { 47 70 description = 'Run beep' 48 71 category = 'run' 49 - run = '${v} -d veb_livereload watch run ${v_main} config.maple' 72 + depends = [':fetch-build-info'] 73 + run = '${v} run ${v_main} config.maple' 50 74 } 51 75 52 76 task:run.real = { 53 77 description = 'Run beep using config.real.maple' 54 78 category = 'run' 55 - run = '${v} -d veb_livereload watch run ${v_main} config.real.maple' 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!' 101 + category = 'misc' 102 + //todo: contribute vlang support to cloc and use that here instead of it seeing all of our v code as verilog code 103 + run = 'cloc ./src/' 56 104 }
+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 + // Toggles developer mode; when true, allows access to the admin panel for all users. 1 2 dev_mode = false 3 + // Path to the static directory. You shouldn't ever need to change this. 2 4 static_path = 'src/static' 3 5 6 + // General instance settings 4 7 instance = { 8 + // Instance version. This is shown on the about page. 9 + version = '2025.12' 10 + 11 + // Set this to '' if your instance is closed source. This is shown on the about page. 12 + source = 'https://tangled.org/emmeline.girlkisser.top/beep' 13 + 14 + // Source for your V compiler. Unless you're using a fork of V, you shouldn't need to change this. 15 + v_source = 'https://github.com/vlang/v' 16 + 17 + // The instance's name, used for the page titles and on the homepage. 5 18 name = 'beep' 19 + // The welcome message to show on the homepage. 6 20 welcome = 'welcome to beep!' 7 21 8 - default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css' 22 + // TODO: Move default_theme and allow_changing_theme to user settings 23 + // Default theme applied for all users. 24 + default_theme = '/static/themes/default.css' 25 + // Default custom CSS applied for all users. 26 + default_css = '' 27 + // Whether or not users should be able to change their theme. 9 28 allow_changing_theme = true 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 = '' 10 40 } 11 41 12 42 http = { 13 43 port = 8008 14 44 } 15 45 46 + // Database settings. 16 47 postgres = { 17 - host = 'localhost' 48 + // Name of database container in compose.yml 49 + host = 'beep-database' 18 50 port = 5432 19 51 user = 'beep' 20 - password = 'beep' 52 + password = 'beep' // TODO: Read from .env 21 53 db = 'beep' 22 54 } 23 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. 24 64 post = { 25 65 title_min_len = 1 26 66 title_max_len = 50 27 - title_pattern = '(.|\s)*' 67 + title_pattern = '.*' 28 68 29 69 body_min_len = 1 30 70 body_max_len = 1000 31 - body_pattern = '(.|\s)*' 71 + body_pattern = '.*' 72 + 73 + // Whether or not posts can be marked as NSFW. 74 + allow_nsfw = true 32 75 } 33 76 77 + // User settings. 34 78 user = { 35 79 username_min_len = 3 36 80 username_max_len = 20 ··· 38 82 39 83 nickname_min_len = 1 40 84 nickname_max_len = 20 41 - nickname_pattern = '(.|\s).*' 85 + nickname_pattern = '.*' 42 86 43 87 password_min_len = 12 44 88 password_max_len = 72 45 - password_pattern = '(.|\s)+' 89 + password_pattern = '.+' 46 90 47 91 pronouns_min_len = 0 48 92 pronouns_max_len = 30 49 - pronouns_pattern = '(.|\s)*' 93 + pronouns_pattern = '.*' 50 94 51 95 bio_min_len = 0 52 96 bio_max_len = 200 53 - bio_pattern = '(.|\s)*' 97 + bio_pattern = '.*' 54 98 } 55 99 100 + // Welcome notification settings. 56 101 welcome = { 102 + // Title of the notification. 57 103 summary = 'welcome!' 104 + // Notification body text. %s is replaced with the user's name. 58 105 body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 59 106 }
+24 -7
doc/database_spec.md
··· 18 18 | `password_salt` | string | salt for this user's password | 19 19 | `muted` | bool | controls whether or not this user can make posts | 20 20 | `admin` | bool | controls whether or not this user is an admin | 21 + | `automated` | bool | controls whether or not this user is automated | 21 22 | `theme` | ?string | controls per-user css themes | 23 + | `css` | ?string | controls per-user css | 22 24 | `bio` | string | bio for this user | 23 25 | `pronouns` | string | pronouns for this user | 24 26 | `created_at` | time.Time | a timestamp of when this user was made | ··· 27 29 28 30 > represents a public post 29 31 30 - | name | type | desc | 31 - |-------------|-----------|----------------------------------------| 32 - | `id` | int | identifier for this post | 33 - | `author_id` | int | id of the user that authored this post | 34 - | `title` | string | the title of this post | 35 - | `body` | string | the body of this post | 36 - | `posted_at` | time.Time | a timestamp of when this post was made | 32 + | name | type | desc | 33 + |---------------|-----------|----------------------------------------------| 34 + | `id` | int | identifier for this post | 35 + | `author_id` | int | id of the user that authored this post | 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 | 37 42 38 43 ## `Like` 39 44 ··· 80 85 | `user_id` | int | the user that receives this notification | 81 86 | `summary` | string | the summary for this notification | 82 87 | `body` | string | the full text for this notification | 88 + 89 + ## `SavedPost` 90 + 91 + > a list of saved posts for a user 92 + 93 + | name | type | desc | 94 + |-----------|------|--------------------------------------------------| 95 + | `id` | int | identifier for this entry, this is mostly unused | 96 + | `post_id` | int | the id of the post this entry relates to | 97 + | `user_id` | int | the id of the user that saved this post | 98 + | `saved` | bool | if this post is saved | 99 + | `later` | bool | if this post is saved in "read later" |
+35
doc/resources.md
··· 9 9 ## database design 10 10 11 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 30 31 31 ## beep-specific 32 32 33 - | name | source | css theme url | 34 - |------|--------|---------------| 35 - | | | | 36 - 37 - > there is nothing here yet! do you want to be the one to change that? 33 + | name | source | css theme url | 34 + |---------|----------------------------------------------------|----------------------------| 35 + | default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css | 38 36 39 37 ## built-in 40 38 41 39 | name | based on (if applicable) | css theme url | 42 40 |-----------------------------|---------------------------------|---------------------------------| 41 + | default | n/a | default.css | 43 42 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 44 43 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 45 44 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 48 47 > beep also features some built-in themes, some of which are based on the themes 49 48 > present in the "it just works" list! 50 49 51 - > make sure to prefix the url with `<instance url>/static/themes/` 50 + > make sure to prefix the url with `/static/themes/`
+53 -5
doc/todo.md
··· 4 4 5 5 ## in-progress 6 6 7 + - [x] post:search for posts 8 + - [ ] filters: 9 + ``` 10 + created-at:<date> 11 + created-after:<date> 12 + created-before:<date> 13 + is:pinned 14 + has-tag:<tag> 15 + posted-by:<user> 16 + !excluded-query 17 + ``` 18 + - [x] user:search for users 19 + - [ ] filters: 20 + ``` 21 + created-at:<date> 22 + created-after:<date> 23 + created-before:<date> 24 + is:admin 25 + ``` 26 + 7 27 ## planing 8 28 9 - - [ ] post:replies 10 - - [ ] post:tags ('hashtags') 11 - - [ ] post:images (should have a config.maple toggle to enable/disable) 12 - - [ ] post:saving (add the post to a list of saved posts that a user can view later) 29 + > p.s. when initially writing "planing," i made a typo. it should be "planning." 30 + > however, i will not be fixing it, because it is funny. 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? 13 45 14 46 ## ideas 15 47 16 48 - [ ] user:per-user post pins 17 49 - could be used as an alternative for a bio to include more information perhaps 50 + - [ ] site:rss feed? 18 51 19 52 ## done 20 53 ··· 23 56 - [x] user:listed pronouns 24 57 - [x] user:notifications 25 58 - [x] user:deletion 59 + - [x] user:change password 60 + - [x] user:change username 26 61 - [x] post:likes/dislikes 27 62 - [x] post:mentioning ('tagging') other users in posts 28 63 - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 29 64 - [x] post:editing 65 + - [x] post:replies 66 + - [x] post:tags ('hashtags') 67 + - [x] post:embedded links (links added to a post will be embedded into the post 68 + as images, music links, etc) 69 + - should have special handling for spotify, apple music, youtube, 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 + 77 + - [ ] ~~post:images (should have a config.maple toggle to enable/disable)~~ 78 + - replaced with post:embedded links 30 79 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 31 80 - replaced with per-user optional stylesheets 32 - - [x] site:message of the day (admins can add a welcome message displayed on index.html)
+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
-35
readme.md
··· 1 - # beep 2 - 3 - > *a legendary land of lowercase lovers.* 4 - 5 - a self-hosted 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 - edit `config.maple` to set the url, port, username, password, and database name. 15 - 16 - > `config.maple` also has settings to configure the feel of your beep instance, 17 - > including toggling images, post length, username length, etc etc. 18 - 19 - > **do not** push your `config.maple`'s secrets to git! 20 - > instead, use `config.real.maple` if you plan to push anywhere. 21 - > it will be gitignored to keep your secrets a secret. 22 - 23 - ```sh 24 - git clone https://github.com/emmathemartian/beep 25 - cd beep 26 - v -prod . 27 - ./beep config.maple 28 - ``` 29 - 30 - then go to the configured url to view (default is `http://localhost:8008`). 31 - 32 - if you do not have a database, you can either self-host a postgresql database on 33 - your machine, or you can find a free one online. i use and like 34 - [neon.tech](https://neon.tech), their free plan is pretty comfortable for a 35 - 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 + })!
-604
src/api.v
··· 1 - module main 2 - 3 - import veb 4 - import auth 5 - import entity { Like, LikeCache, Post, Site, User, Notification } 6 - 7 - ////// user ////// 8 - 9 - @['/api/user/register'; post] 10 - fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { 11 - if app.get_user_by_name(username) != none { 12 - ctx.error('username taken') 13 - return ctx.redirect('/register') 14 - } 15 - 16 - // validate username 17 - if !app.validators.username.validate(username) { 18 - ctx.error('invalid username') 19 - return ctx.redirect('/register') 20 - } 21 - 22 - // validate password 23 - if !app.validators.password.validate(password) { 24 - ctx.error('invalid password') 25 - return ctx.redirect('/register') 26 - } 27 - 28 - salt := auth.generate_salt() 29 - mut user := User{ 30 - username: username 31 - password: auth.hash_password_with_salt(password, salt) 32 - password_salt: salt 33 - } 34 - 35 - if app.config.instance.default_theme != '' { 36 - user.theme = app.config.instance.default_theme 37 - } 38 - 39 - sql app.db { 40 - insert user into User 41 - } or { 42 - eprintln('failed to insert user ${user}') 43 - return ctx.redirect('/') 44 - } 45 - 46 - println('reg: ${username}') 47 - 48 - if x := app.get_user_by_name(username) { 49 - app.send_notification_to( 50 - x.id, 51 - app.config.welcome.summary.replace('%s', x.get_name()), 52 - app.config.welcome.body.replace('%s', x.get_name()) 53 - ) 54 - token := app.auth.add_token(x.id, ctx.ip()) or { 55 - eprintln(err) 56 - ctx.error('could not create token for user with id ${x.id}') 57 - return ctx.redirect('/') 58 - } 59 - ctx.set_cookie( 60 - name: 'token' 61 - value: token 62 - same_site: .same_site_none_mode 63 - secure: true 64 - path: '/' 65 - ) 66 - } else { 67 - eprintln('could not log into newly-created user: ${user}') 68 - ctx.error('could not log into newly-created user.') 69 - } 70 - 71 - return ctx.redirect('/') 72 - } 73 - 74 - @['/api/user/login'; post] 75 - fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 76 - user := app.get_user_by_name(username) or { 77 - ctx.error('invalid credentials') 78 - return ctx.redirect('/login') 79 - } 80 - 81 - if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 82 - ctx.error('invalid credentials') 83 - return ctx.redirect('/login') 84 - } 85 - 86 - token := app.auth.add_token(user.id, ctx.ip()) or { 87 - eprintln('failed to add token on log in: ${err}') 88 - ctx.error('could not create token for user with id ${user.id}') 89 - return ctx.redirect('/login') 90 - } 91 - 92 - ctx.set_cookie( 93 - name: 'token' 94 - value: token 95 - same_site: .same_site_none_mode 96 - secure: true 97 - path: '/' 98 - ) 99 - 100 - return ctx.redirect('/') 101 - } 102 - 103 - @['/api/user/logout'] 104 - fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 105 - if token := ctx.get_cookie('token') { 106 - if user := app.get_user_by_token(ctx, token) { 107 - app.auth.delete_tokens_for_ip(ctx.ip()) or { 108 - eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()}') 109 - return ctx.redirect('/login') 110 - } 111 - } else { 112 - eprintln('failed to get user for token for logout') 113 - } 114 - } else { 115 - eprintln('failed to get token cookie for logout') 116 - } 117 - 118 - ctx.set_cookie( 119 - name: 'token' 120 - value: '' 121 - same_site: .same_site_none_mode 122 - secure: true 123 - path: '/' 124 - ) 125 - 126 - return ctx.redirect('/login') 127 - } 128 - 129 - @['/api/user/full_logout'] 130 - fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 131 - if token := ctx.get_cookie('token') { 132 - if user := app.get_user_by_token(ctx, token) { 133 - app.auth.delete_tokens_for_user(user.id) or { 134 - eprintln('failed to yeet tokens for ${user.id}') 135 - return ctx.redirect('/login') 136 - } 137 - } else { 138 - eprintln('failed to get user for token for full_logout') 139 - } 140 - } else { 141 - eprintln('failed to get token cookie for full_logout') 142 - } 143 - 144 - ctx.set_cookie( 145 - name: 'token' 146 - value: '' 147 - same_site: .same_site_none_mode 148 - secure: true 149 - path: '/' 150 - ) 151 - 152 - return ctx.redirect('/login') 153 - } 154 - 155 - @['/api/user/set_nickname'; post] 156 - fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 157 - user := app.whoami(mut ctx) or { 158 - ctx.error('you are not logged in!') 159 - return ctx.redirect('/login') 160 - } 161 - 162 - mut clean_nickname := ?string(nickname.trim_space()) 163 - if clean_nickname or { '' } == '' { 164 - clean_nickname = none 165 - } 166 - 167 - // validate 168 - if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 169 - ctx.error('invalid nickname') 170 - return ctx.redirect('/me') 171 - } 172 - 173 - sql app.db { 174 - update User set nickname = clean_nickname where id == user.id 175 - } or { 176 - ctx.error('failed to change nickname') 177 - eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 178 - return ctx.redirect('/me') 179 - } 180 - 181 - return ctx.redirect('/me') 182 - } 183 - 184 - @['/api/user/set_muted'; post] 185 - fn (mut app App) api_user_set_muted(mut ctx Context, muted bool) veb.Result { 186 - user := app.whoami(mut ctx) or { 187 - ctx.error('you are not logged in!') 188 - return ctx.redirect('/login') 189 - } 190 - 191 - if user.admin || app.config.dev_mode { 192 - sql app.db { 193 - update User set muted = muted where id == user.id 194 - } or { 195 - ctx.error('failed to change mute status') 196 - eprintln('failed to update mute status for ${user} (${user.muted} -> ${muted})') 197 - return ctx.redirect('/user/${user.username}') 198 - } 199 - return ctx.redirect('/user/${user.username}') 200 - } else { 201 - ctx.error('insufficient permissions!') 202 - eprintln('insufficient perms to update mute status for ${user} (${user.muted} -> ${muted})') 203 - return ctx.redirect('/user/${user.username}') 204 - } 205 - } 206 - 207 - @['/api/user/set_theme'; post] 208 - fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 209 - if !app.config.instance.allow_changing_theme { 210 - ctx.error('this instance disallows changing themes :(') 211 - return ctx.redirect('/me') 212 - } 213 - 214 - user := app.whoami(mut ctx) or { 215 - ctx.error('you are not logged in!') 216 - return ctx.redirect('/login') 217 - } 218 - 219 - mut theme := ?string(none) 220 - if url.trim_space() != '' { 221 - theme = url.trim_space() 222 - } 223 - 224 - sql app.db { 225 - update User set theme = theme where id == user.id 226 - } or { 227 - ctx.error('failed to change theme') 228 - eprintln('failed to update theme for ${user} (${user.theme} -> ${theme})') 229 - return ctx.redirect('/me') 230 - } 231 - 232 - return ctx.redirect('/me') 233 - } 234 - 235 - @['/api/user/set_pronouns'; post] 236 - fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) 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 - clean_pronouns := pronouns.trim_space() 243 - if !app.validators.pronouns.validate(clean_pronouns) { 244 - ctx.error('invalid pronouns') 245 - return ctx.redirect('/me') 246 - } 247 - 248 - sql app.db { 249 - update User set pronouns = clean_pronouns where id == user.id 250 - } or { 251 - ctx.error('failed to change pronouns') 252 - eprintln('failed to update pronouns for ${user} (${user.pronouns} -> ${clean_pronouns})') 253 - return ctx.redirect('/me') 254 - } 255 - 256 - return ctx.redirect('/me') 257 - } 258 - 259 - @['/api/user/set_bio'; post] 260 - fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 261 - user := app.whoami(mut ctx) or { 262 - ctx.error('you are not logged in!') 263 - return ctx.redirect('/login') 264 - } 265 - 266 - clean_bio := bio.trim_space() 267 - if !app.validators.user_bio.validate(clean_bio) { 268 - ctx.error('invalid bio') 269 - return ctx.redirect('/me') 270 - } 271 - 272 - sql app.db { 273 - update User set bio = clean_bio where id == user.id 274 - } or { 275 - ctx.error('failed to change bio') 276 - eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 277 - return ctx.redirect('/me') 278 - } 279 - 280 - return ctx.redirect('/me') 281 - } 282 - 283 - @['/api/user/get_name'] 284 - fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result { 285 - user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 286 - return ctx.text(user.get_name()) 287 - } 288 - 289 - /// user/notification /// 290 - 291 - @['/api/user/notification/clear'] 292 - fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 293 - if !ctx.is_logged_in() { 294 - ctx.error('you are not logged in!') 295 - return ctx.redirect('/login') 296 - } 297 - sql app.db { 298 - delete from Notification where id == id 299 - } or { 300 - ctx.error('failed to delete notification') 301 - return ctx.redirect('/inbox') 302 - } 303 - return ctx.redirect('/inbox') 304 - } 305 - 306 - @['/api/user/notification/clear_all'] 307 - fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 308 - user := app.whoami(mut ctx) or { 309 - ctx.error('you are not logged in!') 310 - return ctx.redirect('/login') 311 - } 312 - sql app.db { 313 - delete from Notification where user_id == user.id 314 - } or { 315 - ctx.error('failed to delete notifications') 316 - return ctx.redirect('/inbox') 317 - } 318 - return ctx.redirect('/inbox') 319 - } 320 - 321 - @['/api/user/delete'] 322 - fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 323 - user := app.whoami(mut ctx) or { 324 - ctx.error('you are not logged in!') 325 - return ctx.redirect('/login') 326 - } 327 - 328 - println('attempting to delete ${id} as ${user.id}') 329 - 330 - if user.admin || user.id == id { 331 - // yeet 332 - sql app.db { 333 - delete from User where id == id 334 - delete from Like where user_id == id 335 - delete from Notification where user_id == id 336 - } or { 337 - ctx.error('failed to delete user: ${id}') 338 - return ctx.redirect('/') 339 - } 340 - 341 - // delete posts and their likes 342 - posts_from_this_user := sql app.db { 343 - select from Post where author_id == id 344 - } or { [] } 345 - 346 - for post in posts_from_this_user { 347 - sql app.db { 348 - delete from Like where post_id == post.id 349 - delete from LikeCache where post_id == post.id 350 - } or { 351 - eprintln('failed to delete like cache for post during user deletion: ${post.id}') 352 - } 353 - } 354 - 355 - sql app.db { 356 - delete from Post where author_id == id 357 - } or { 358 - eprintln('failed to delete posts by deleting user: ${user.id}') 359 - } 360 - 361 - app.auth.delete_tokens_for_user(id) or { 362 - eprintln('failed to delete tokens for user during deletion: ${id}') 363 - } 364 - // log out 365 - if user.id == id { 366 - ctx.set_cookie( 367 - name: 'token' 368 - value: '' 369 - same_site: .same_site_none_mode 370 - secure: true 371 - path: '/' 372 - ) 373 - } 374 - println('deleted user ${id}') 375 - } else { 376 - ctx.error('be nice. deleting other users is off-limits.') 377 - } 378 - 379 - return ctx.redirect('/') 380 - } 381 - 382 - ////// post ////// 383 - 384 - @['/api/post/new_post'; post] 385 - fn (mut app App) api_post_new_post(mut ctx Context, title string, body string) veb.Result { 386 - user := app.whoami(mut ctx) or { 387 - ctx.error('not logged in!') 388 - return ctx.redirect('/') 389 - } 390 - 391 - if user.muted { 392 - ctx.error('you are muted!') 393 - return ctx.redirect('/me') 394 - } 395 - 396 - // validate title 397 - if !app.validators.post_title.validate(title) { 398 - ctx.error('invalid title') 399 - return ctx.redirect('/me') 400 - } 401 - 402 - // validate body 403 - if !app.validators.post_body.validate(body) { 404 - ctx.error('invalid body') 405 - return ctx.redirect('/me') 406 - } 407 - 408 - post := Post{ 409 - author_id: user.id 410 - title: title 411 - body: body 412 - } 413 - 414 - sql app.db { 415 - insert post into Post 416 - } or { 417 - ctx.error('failed to post!') 418 - println('failed to post: ${post} from user ${user.id}') 419 - return ctx.redirect('/me') 420 - } 421 - 422 - // find the post's id to process mentions with 423 - if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 424 - app.process_post_mentions(x) 425 - } else { 426 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 427 - } 428 - 429 - return ctx.redirect('/me') 430 - } 431 - 432 - @['/api/post/delete'; post] 433 - fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 434 - user := app.whoami(mut ctx) or { 435 - ctx.error('not logged in!') 436 - return ctx.redirect('/login') 437 - } 438 - 439 - post := app.get_post_by_id(id) or { 440 - ctx.error('post does not exist') 441 - return ctx.redirect('/') 442 - } 443 - 444 - if user.admin || user.id == post.author_id { 445 - sql app.db { 446 - delete from Post where id == id 447 - delete from Like where post_id == id 448 - } or { 449 - ctx.error('failed to delete post') 450 - eprintln('failed to delete post: ${id}') 451 - return ctx.redirect('/') 452 - } 453 - println('deleted post: ${id}') 454 - return ctx.redirect('/') 455 - } else { 456 - ctx.error('insufficient permissions!') 457 - eprintln('insufficient perms to delete post: ${id} (${user.id})') 458 - return ctx.redirect('/') 459 - } 460 - } 461 - 462 - @['/api/post/like'] 463 - fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 464 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 465 - 466 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 467 - 468 - if app.does_user_like_post(user.id, post.id) { 469 - sql app.db { 470 - delete from Like where user_id == user.id && post_id == post.id 471 - // yeet the old cached like value 472 - delete from LikeCache where post_id == post.id 473 - } or { 474 - eprintln('user ${user.id} failed to unlike post ${id}') 475 - return ctx.server_error('failed to unlike post') 476 - } 477 - return ctx.ok('unliked post') 478 - } else { 479 - // remove the old dislike, if it exists 480 - if app.does_user_dislike_post(user.id, post.id) { 481 - sql app.db { 482 - delete from Like where user_id == user.id && post_id == post.id 483 - } or { 484 - eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 485 - } 486 - } 487 - 488 - like := Like{ 489 - user_id: user.id 490 - post_id: post.id 491 - is_like: true 492 - } 493 - sql app.db { 494 - insert like into Like 495 - // yeet the old cached like value 496 - delete from LikeCache where post_id == post.id 497 - } or { 498 - eprintln('user ${user.id} failed to like post ${id}') 499 - return ctx.server_error('failed to like post') 500 - } 501 - return ctx.ok('liked post') 502 - } 503 - } 504 - 505 - @['/api/post/dislike'] 506 - fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 507 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 508 - 509 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 510 - 511 - if app.does_user_dislike_post(user.id, post.id) { 512 - sql app.db { 513 - delete from Like where user_id == user.id && post_id == post.id 514 - // yeet the old cached like value 515 - delete from LikeCache where post_id == post.id 516 - } or { 517 - eprintln('user ${user.id} failed to unlike post ${id}') 518 - return ctx.server_error('failed to unlike post') 519 - } 520 - return ctx.ok('undisliked post') 521 - } else { 522 - // remove the old like, if it exists 523 - if app.does_user_like_post(user.id, post.id) { 524 - sql app.db { 525 - delete from Like where user_id == user.id && post_id == post.id 526 - } or { 527 - eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 528 - } 529 - } 530 - 531 - like := Like{ 532 - user_id: user.id 533 - post_id: post.id 534 - is_like: false 535 - } 536 - sql app.db { 537 - insert like into Like 538 - // yeet the old cached like value 539 - delete from LikeCache where post_id == post.id 540 - } or { 541 - eprintln('user ${user.id} failed to dislike post ${id}') 542 - return ctx.server_error('failed to dislike post') 543 - } 544 - return ctx.ok('disliked post') 545 - } 546 - } 547 - 548 - @['/api/post/get_title'] 549 - fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 550 - post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 551 - return ctx.text(post.title) 552 - } 553 - 554 - @['/api/post/edit'; post] 555 - fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 556 - user := app.whoami(mut ctx) or { 557 - ctx.error('not logged in!') 558 - return ctx.redirect('/login') 559 - } 560 - post := app.get_post_by_id(id) or { 561 - ctx.error('no such post') 562 - return ctx.redirect('/') 563 - } 564 - if post.author_id != user.id { 565 - ctx.error('insufficient permissions') 566 - return ctx.redirect('/') 567 - } 568 - 569 - sql app.db { 570 - update Post set body = body, title = title where id == id 571 - } or { 572 - eprintln('failed to update post') 573 - ctx.error('failed to update post') 574 - return ctx.redirect('/') 575 - } 576 - 577 - return ctx.redirect('/post/${id}') 578 - } 579 - 580 - ////// site ////// 581 - 582 - @['/api/site/set_motd'; post] 583 - fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 584 - user := app.whoami(mut ctx) or { 585 - ctx.error('not logged in!') 586 - return ctx.redirect('/login') 587 - } 588 - 589 - if user.admin { 590 - sql app.db { 591 - update Site set motd = motd where id == 1 592 - } or { 593 - ctx.error('failed to set motd') 594 - eprintln('failed to set motd: ${motd}') 595 - return ctx.redirect('/') 596 - } 597 - println('set motd to: ${motd}') 598 - return ctx.redirect('/') 599 - } else { 600 - ctx.error('insufficient permissions!') 601 - eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 602 - return ctx.redirect('/') 603 - } 604 - }
-335
src/app.v
··· 1 - module main 2 - 3 - import veb 4 - import db.pg 5 - import regex 6 - import time 7 - import auth 8 - import entity { LikeCache, Like, Post, Site, User, Notification } 9 - 10 - pub struct App { 11 - veb.StaticHandler 12 - pub: 13 - config Config 14 - pub mut: 15 - db pg.DB 16 - auth auth.Auth[pg.DB] 17 - validators struct { 18 - pub mut: 19 - username StringValidator 20 - password StringValidator 21 - nickname StringValidator 22 - pronouns StringValidator 23 - user_bio StringValidator 24 - post_title StringValidator 25 - post_body StringValidator 26 - } 27 - } 28 - 29 - pub fn (app &App) get_user_by_name(username string) ?User { 30 - users := sql app.db { 31 - select from User where username == username 32 - } or { [] } 33 - if users.len != 1 { 34 - return none 35 - } 36 - return users[0] 37 - } 38 - 39 - pub fn (app &App) get_user_by_id(id int) ?User { 40 - users := sql app.db { 41 - select from User where id == id 42 - } or { [] } 43 - if users.len != 1 { 44 - return none 45 - } 46 - return users[0] 47 - } 48 - 49 - pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 50 - user_token := app.auth.find_token(token, ctx.ip()) or { 51 - eprintln('no such user corresponding to token') 52 - return none 53 - } 54 - return app.get_user_by_id(user_token.user_id) 55 - } 56 - 57 - pub fn (app &App) get_recent_posts() []Post { 58 - posts := sql app.db { 59 - select from Post order by posted_at desc limit 10 60 - } or { [] } 61 - return posts 62 - } 63 - 64 - pub fn (app &App) get_popular_posts() []Post { 65 - cached_likes := sql app.db { 66 - select from LikeCache order by likes desc limit 10 67 - } or { [] } 68 - posts := cached_likes.map(fn [app] (it LikeCache) Post { 69 - return app.get_post_by_id(it.post_id) or { 70 - eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 71 - return Post{} 72 - } 73 - }).filter(it.id != 0) 74 - return posts 75 - } 76 - 77 - pub fn (app &App) get_posts_from_user(user_id int) []Post { 78 - posts := sql app.db { 79 - select from Post where author_id == user_id order by posted_at desc 80 - } or { [] } 81 - return posts 82 - } 83 - 84 - pub fn (app &App) get_users() []User { 85 - users := sql app.db { 86 - select from User 87 - } or { [] } 88 - return users 89 - } 90 - 91 - pub fn (app &App) get_post_by_id(id int) ?Post { 92 - posts := sql app.db { 93 - select from Post where id == id limit 1 94 - } or { [] } 95 - if posts.len != 1 { 96 - return none 97 - } 98 - return posts[0] 99 - } 100 - 101 - pub fn (app &App) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 102 - posts := sql app.db { 103 - select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 104 - } or { [] } 105 - if posts.len == 0 { 106 - return none 107 - } 108 - return posts[0] 109 - } 110 - 111 - pub fn (app &App) get_pinned_posts() []Post { 112 - posts := sql app.db { 113 - select from Post where pinned == true 114 - } or { [] } 115 - return posts 116 - } 117 - 118 - pub fn (app &App) whoami(mut ctx Context) ?User { 119 - token := ctx.get_cookie('token') or { return none }.trim_space() 120 - if token == '' { 121 - return none 122 - } 123 - if user := app.get_user_by_token(ctx, token) { 124 - if user.username == '' || user.id == 0 { 125 - eprintln('a user had a token for the blank user') 126 - // Clear token 127 - ctx.set_cookie( 128 - name: 'token' 129 - value: '' 130 - same_site: .same_site_none_mode 131 - secure: true 132 - path: '/' 133 - ) 134 - return none 135 - } 136 - return user 137 - } else { 138 - eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)') 139 - // Clear token 140 - ctx.set_cookie( 141 - name: 'token' 142 - value: '' 143 - same_site: .same_site_none_mode 144 - secure: true 145 - path: '/' 146 - ) 147 - return none 148 - } 149 - } 150 - 151 - pub fn (app &App) get_unknown_user() User { 152 - return User{ 153 - username: 'unknown' 154 - } 155 - } 156 - 157 - pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 158 - if !ctx.is_logged_in() { 159 - return false 160 - } 161 - return app.whoami(mut ctx) or { return false }.id == id 162 - } 163 - 164 - pub fn (app &App) does_user_like_post(user_id int, post_id int) bool { 165 - likes := sql app.db { 166 - select from Like where user_id == user_id && post_id == post_id 167 - } or { [] } 168 - if likes.len > 1 { 169 - // something is very wrong lol 170 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 171 - } else if likes.len == 0 { 172 - return false 173 - } 174 - return likes.first().is_like 175 - } 176 - 177 - pub fn (app &App) does_user_dislike_post(user_id int, post_id int) bool { 178 - likes := sql app.db { 179 - select from Like where user_id == user_id && post_id == post_id 180 - } or { [] } 181 - if likes.len > 1 { 182 - // something is very wrong lol 183 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 184 - } else if likes.len == 0 { 185 - return false 186 - } 187 - return !likes.first().is_like 188 - } 189 - 190 - pub fn (app &App) does_user_like_or_dislike_post(user_id int, post_id int) bool { 191 - likes := sql app.db { 192 - select from Like where user_id == user_id && post_id == post_id 193 - } or { [] } 194 - if likes.len > 1 { 195 - // something is very wrong lol 196 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 197 - } 198 - return likes.len == 1 199 - } 200 - 201 - pub fn (app &App) get_net_likes_for_post(post_id int) int { 202 - // check cache 203 - cache := sql app.db { 204 - select from LikeCache where post_id == post_id limit 1 205 - } or { [] } 206 - 207 - mut likes := 0 208 - 209 - if cache.len != 1 { 210 - println('calculating net likes for post: ${post_id}') 211 - // calculate 212 - db_likes := sql app.db { 213 - select from Like where post_id == post_id 214 - } or { [] } 215 - 216 - for like in db_likes { 217 - if like.is_like { 218 - likes++ 219 - } else { 220 - likes-- 221 - } 222 - } 223 - 224 - // cache 225 - cached := LikeCache{ 226 - post_id: post_id 227 - likes: likes 228 - } 229 - sql app.db { 230 - insert cached into LikeCache 231 - } or { 232 - eprintln('failed to cache like: ${cached}') 233 - return likes 234 - } 235 - } else { 236 - likes = cache.first().likes 237 - } 238 - 239 - return likes 240 - } 241 - 242 - pub fn (app &App) get_or_create_site_config() Site { 243 - configs := sql app.db { 244 - select from Site 245 - } or { [] } 246 - if configs.len == 0 { 247 - // make the site config 248 - site_config := Site{} 249 - sql app.db { 250 - insert site_config into Site 251 - } or { panic('failed to create site config (${err})') } 252 - } else if configs.len > 1 { 253 - // this should never happen 254 - panic('there are multiple site configs') 255 - } 256 - return configs[0] 257 - } 258 - 259 - @[inline] 260 - pub fn (app &App) get_motd() string { 261 - site := app.get_or_create_site_config() 262 - return site.motd 263 - } 264 - 265 - pub fn (app &App) get_notifications_for(user_id int) []Notification { 266 - notifications := sql app.db { 267 - select from Notification where user_id == user_id 268 - } or { [] } 269 - return notifications 270 - } 271 - 272 - pub fn (app &App) get_notification_count(user_id int, limit int) int { 273 - notifications := sql app.db { 274 - select from Notification where user_id == user_id limit limit 275 - } or { [] } 276 - return notifications.len 277 - } 278 - 279 - pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 280 - count := app.get_notification_count(user_id, limit) 281 - if count == 0 { 282 - return '' 283 - } else if count > limit { 284 - return ' (${count}+)' 285 - } else { 286 - return ' (${count})' 287 - } 288 - } 289 - 290 - pub fn (app &App) send_notification_to(user_id int, summary string, body string) { 291 - notification := Notification{ 292 - user_id: user_id 293 - summary: summary 294 - body: body 295 - } 296 - sql app.db { 297 - insert notification into Notification 298 - } or { 299 - eprintln('failed to send notification ${notification}') 300 - } 301 - } 302 - 303 - // sends notifications to each user mentioned in a post 304 - pub fn (app &App) process_post_mentions(post &Post) { 305 - author := app.get_user_by_id(post.author_id) or { 306 - eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') 307 - return 308 - } 309 - author_name := author.get_name() 310 - 311 - mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 312 - eprintln('failed to compile regex for process_post_mentions (err: ${err})') 313 - return 314 - } 315 - matches := re.find_all_str(post.body) 316 - mut mentioned_users := []int{} 317 - for mat in matches { 318 - println('found mentioned user: ${mat}') 319 - username := mat#[2..-1] 320 - user := app.get_user_by_name(username) or { 321 - continue 322 - } 323 - 324 - if user.id in mentioned_users || user.id == author.id { 325 - continue 326 - } 327 - mentioned_users << user.id 328 - 329 - app.send_notification_to( 330 - user.id, 331 - '${author_name} mentioned you!', 332 - 'you have been mentioned in this post: *(${post.id})' 333 - ) 334 - } 335 - }
+12 -7
src/auth/auth.v
··· 1 1 // From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v 2 2 // The original file's source is licensed under MIT. 3 3 4 + // ~~ 4 5 // This fork re-introduces the `ip` field of each token for additional security, 5 6 // along with delete_tokens_for_ip 7 + // ~~ 8 + // IP has been removed since IPs can change randomly and it causes you to need 9 + // to relog wayyyy more often. I'm keeping this fork just in case I do need to 10 + // change the auth system in the future. 6 11 7 12 module auth 8 13 ··· 22 27 id int @[primary; sql: serial] 23 28 user_id int 24 29 value string 25 - ip string 26 30 } 27 31 28 32 pub fn new[T](db T) Auth[T] { ··· 35 39 } 36 40 } 37 41 38 - pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string { 42 + pub fn (mut app Auth[T]) add_token(user_id int) !string { 39 43 mut uuid := rand.uuid_v4() 40 44 token := Token{ 41 45 user_id: user_id 42 46 value: uuid 43 - ip: ip 44 47 } 45 48 sql app.db { 46 49 insert token into Token ··· 48 51 return uuid 49 52 } 50 53 51 - pub fn (app &Auth[T]) find_token(value string, ip string) ?Token { 54 + pub fn (app &Auth[T]) find_token(value string) ?Token { 52 55 tokens := sql app.db { 53 - select from Token where value == value && ip == ip limit 1 56 + select from Token where value == value limit 1 54 57 } or { []Token{} } 55 58 if tokens.len == 0 { 56 59 return none ··· 58 61 return tokens.first() 59 62 } 60 63 64 + // logs out of all devices 61 65 pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! { 62 66 sql app.db { 63 67 delete from Token where user_id == user_id 64 68 }! 65 69 } 66 70 67 - pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! { 71 + // logs out of one device 72 + pub fn (mut app Auth[T]) delete_tokens_for_value(value string) ! { 68 73 sql app.db { 69 - delete from Token where ip == ip 74 + delete from Token where value == value 70 75 }! 71 76 } 72 77
+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;
-115
src/config.v
··· 1 - module main 2 - 3 - import emmathemartian.maple 4 - 5 - pub struct Config { 6 - pub mut: 7 - dev_mode bool 8 - static_path string 9 - instance struct { 10 - pub mut: 11 - name string 12 - welcome string 13 - default_theme string 14 - allow_changing_theme bool 15 - } 16 - http struct { 17 - pub mut: 18 - port int 19 - } 20 - postgres struct { 21 - pub mut: 22 - host string 23 - port int 24 - user string 25 - password string 26 - db string 27 - } 28 - post struct { 29 - pub mut: 30 - title_min_len int 31 - title_max_len int 32 - title_pattern string 33 - body_min_len int 34 - body_max_len int 35 - body_pattern string 36 - } 37 - user struct { 38 - pub mut: 39 - username_min_len int 40 - username_max_len int 41 - username_pattern string 42 - nickname_min_len int 43 - nickname_max_len int 44 - nickname_pattern string 45 - password_min_len int 46 - password_max_len int 47 - password_pattern string 48 - pronouns_min_len int 49 - pronouns_max_len int 50 - pronouns_pattern string 51 - bio_min_len int 52 - bio_max_len int 53 - bio_pattern string 54 - } 55 - welcome struct { 56 - pub mut: 57 - summary string 58 - body string 59 - } 60 - } 61 - 62 - pub fn load_config_from(file_path string) Config { 63 - loaded := maple.load_file(file_path) or { panic(err) } 64 - mut config := Config{} 65 - 66 - config.dev_mode = loaded.get('dev_mode').to_bool() 67 - config.static_path = loaded.get('static_path').to_str() 68 - 69 - loaded_instance := loaded.get('instance') 70 - config.instance.name = loaded_instance.get('name').to_str() 71 - config.instance.welcome = loaded_instance.get('welcome').to_str() 72 - config.instance.default_theme = loaded_instance.get('default_theme').to_str() 73 - config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 74 - 75 - loaded_http := loaded.get('http') 76 - config.http.port = loaded_http.get('port').to_int() 77 - 78 - loaded_postgres := loaded.get('postgres') 79 - config.postgres.host = loaded_postgres.get('host').to_str() 80 - config.postgres.port = loaded_postgres.get('port').to_int() 81 - config.postgres.user = loaded_postgres.get('user').to_str() 82 - config.postgres.password = loaded_postgres.get('password').to_str() 83 - config.postgres.db = loaded_postgres.get('db').to_str() 84 - 85 - loaded_post := loaded.get('post') 86 - config.post.title_min_len = loaded_post.get('title_min_len').to_int() 87 - config.post.title_max_len = loaded_post.get('title_max_len').to_int() 88 - config.post.title_pattern = loaded_post.get('title_pattern').to_str() 89 - config.post.body_min_len = loaded_post.get('body_min_len').to_int() 90 - config.post.body_max_len = loaded_post.get('body_max_len').to_int() 91 - config.post.body_pattern = loaded_post.get('body_pattern').to_str() 92 - 93 - loaded_user := loaded.get('user') 94 - config.user.username_min_len = loaded_user.get('username_min_len').to_int() 95 - config.user.username_max_len = loaded_user.get('username_max_len').to_int() 96 - config.user.username_pattern = loaded_user.get('username_pattern').to_str() 97 - config.user.nickname_min_len = loaded_user.get('nickname_min_len').to_int() 98 - config.user.nickname_max_len = loaded_user.get('nickname_max_len').to_int() 99 - config.user.nickname_pattern = loaded_user.get('nickname_pattern').to_str() 100 - config.user.password_min_len = loaded_user.get('password_min_len').to_int() 101 - config.user.password_max_len = loaded_user.get('password_max_len').to_int() 102 - config.user.password_pattern = loaded_user.get('password_pattern').to_str() 103 - config.user.pronouns_min_len = loaded_user.get('pronouns_min_len').to_int() 104 - config.user.pronouns_max_len = loaded_user.get('pronouns_max_len').to_int() 105 - config.user.pronouns_pattern = loaded_user.get('pronouns_pattern').to_str() 106 - config.user.bio_min_len = loaded_user.get('bio_min_len').to_int() 107 - config.user.bio_max_len = loaded_user.get('bio_max_len').to_int() 108 - config.user.bio_pattern = loaded_user.get('bio_pattern').to_str() 109 - 110 - loaded_welcome := loaded.get('welcome') 111 - config.welcome.summary = loaded_welcome.get('summary').to_str() 112 - config.welcome.body = loaded_welcome.get('body').to_str() 113 - 114 - return config 115 - }
-18
src/context.v
··· 1 - module main 2 - 3 - import veb 4 - 5 - pub struct Context { 6 - veb.Context 7 - pub mut: 8 - title string 9 - } 10 - 11 - pub fn (ctx &Context) is_logged_in() bool { 12 - return ctx.get_cookie('token') or { '' } != '' 13 - } 14 - 15 - pub fn (mut ctx Context) unauthorized(msg string) veb.Result { 16 - ctx.res.set_status(.unauthorized) 17 - return ctx.send_response_to_client('text/plain', msg) 18 - }
+25
src/database/database.v
··· 1 + // **all** interactions with the database should be handled in this module. 2 + module database 3 + 4 + import db.pg 5 + import entity { User, Post } 6 + 7 + // DatabaseAccess handles all interactions with the database. 8 + pub struct DatabaseAccess { 9 + pub mut: 10 + db pg.DB 11 + } 12 + 13 + // get_unknown_user returns a user representing an unknown user 14 + pub fn (app &DatabaseAccess) get_unknown_user() User { 15 + return User{ 16 + username: 'unknown' 17 + } 18 + } 19 + 20 + // get_unknown_post returns a post representing an unknown post 21 + pub fn (app &DatabaseAccess) get_unknown_post() Post { 22 + return Post{ 23 + title: 'unknown' 24 + } 25 + }
+67
src/database/like.v
··· 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. 8 + pub fn (app &DatabaseAccess) add_like(like &Like) bool { 9 + sql app.db { 10 + insert like into Like 11 + // yeet the old cached like value 12 + delete from LikeCache where post_id == like.post_id 13 + } or { 14 + return false 15 + } 16 + return true 17 + } 18 + 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-- 35 + } 36 + } 37 + 38 + // cache 39 + cached := LikeCache{ 40 + post_id: post_id 41 + likes: likes 42 + } 43 + sql app.db { 44 + insert cached into LikeCache 45 + } or { 46 + eprintln('failed to cache like: ${cached}') 47 + return likes 48 + } 49 + } else { 50 + likes = util.or_throw(cache.first().vals[0]).int() 51 + } 52 + 53 + return likes 54 + } 55 + 56 + // unlike_post removes a (dis)like from the given post, returns true if this 57 + // succeeds and false otherwise. 58 + pub fn (app &DatabaseAccess) unlike_post(post_id int, user_id int) bool { 59 + sql app.db { 60 + delete from Like where user_id == user_id && post_id == post_id 61 + // yeet the old cached like value 62 + delete from LikeCache where post_id == post_id 63 + } or { 64 + return false 65 + } 66 + return true 67 + }
+66
src/database/notification.v
··· 1 + module database 2 + 3 + import entity { Notification } 4 + 5 + // get_notification_by_id gets a notification by its given id, returns none if 6 + // the notification does not exist. 7 + pub fn (app &DatabaseAccess) get_notification_by_id(id int) ?Notification { 8 + notifications := sql app.db { 9 + select from Notification where id == id 10 + } or { [] } 11 + if notifications.len != 1 { 12 + return none 13 + } 14 + return notifications[0] 15 + } 16 + 17 + // delete_notification deletes the given notification, returns true if this 18 + // succeeded and false otherwise. 19 + pub fn (app &DatabaseAccess) delete_notification(id int) bool { 20 + sql app.db { 21 + delete from Notification where id == id 22 + } or { 23 + return false 24 + } 25 + return true 26 + } 27 + 28 + // delete_notifications_for_user deletes all notifications for the given user, 29 + // returns true if this succeeded and false otherwise. 30 + pub fn (app &DatabaseAccess) delete_notifications_for_user(user_id int) bool { 31 + sql app.db { 32 + delete from Notification where user_id == user_id 33 + } or { 34 + return false 35 + } 36 + return true 37 + } 38 + 39 + // get_notifications_for gets a list of notifications for the given user. 40 + pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification { 41 + notifications := sql app.db { 42 + select from Notification where user_id == user_id 43 + } or { [] } 44 + return notifications 45 + } 46 + 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 + 54 + // send_notification_to sends a notification to the given user. 55 + pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) { 56 + notification := Notification{ 57 + user_id: user_id 58 + summary: summary 59 + body: body 60 + } 61 + sql app.db { 62 + insert notification into Notification 63 + } or { 64 + eprintln('failed to send notification ${notification}') 65 + } 66 + }
+184
src/database/post.v
··· 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. 10 + pub fn (app &DatabaseAccess) add_post(post &Post) bool { 11 + sql app.db { 12 + insert post into Post 13 + } or { 14 + return false 15 + } 16 + return true 17 + } 18 + 19 + // get_post_by_id gets a post by its id, returns none if it does not exist. 20 + pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post { 21 + posts := sql app.db { 22 + select from Post where id == id limit 1 23 + } or { [] } 24 + if posts.len != 1 { 25 + return none 26 + } 27 + return posts[0] 28 + } 29 + 30 + // get_post_by_author_and_timestamp gets a post by its author and timestamp, 31 + // returns none if it does not exist 32 + pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 33 + posts := sql app.db { 34 + select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 35 + } or { [] } 36 + if posts.len == 0 { 37 + return none 38 + } 39 + return posts[0] 40 + } 41 + 42 + // get_posts_with_tag gets a list of the 10 most recent posts with the given tag. 43 + // this performs sql string operations and probably is not very efficient, use 44 + // sparingly. 45 + pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post { 46 + posts := sql app.db { 47 + select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset 48 + } or { [] } 49 + return posts 50 + } 51 + 52 + // get_pinned_posts returns a list of all pinned posts. 53 + pub fn (app &DatabaseAccess) get_pinned_posts() []Post { 54 + posts := sql app.db { 55 + select from Post where pinned == true 56 + } or { [] } 57 + return posts 58 + } 59 + 60 + // get_recent_posts returns a list of the ten most recent posts. 61 + pub fn (app &DatabaseAccess) get_recent_posts() []Post { 62 + posts := sql app.db { 63 + select from Post order by posted_at desc limit 10 64 + } or { [] } 65 + return posts 66 + } 67 + 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 + } 77 + }).filter(it.id != 0) 78 + return posts 79 + } 80 + 81 + // get_posts_from_user returns a list of all posts from a user in descending 82 + // order by posting date. 83 + pub fn (app &DatabaseAccess) get_posts_from_user(user_id int, limit int) []Post { 84 + posts := sql app.db { 85 + select from Post where author_id == user_id order by posted_at desc limit limit 86 + } or { [] } 87 + return posts 88 + } 89 + 90 + // get_all_posts_from_user returns a list of all posts from a user in descending 91 + // order by posting date. 92 + pub fn (app &DatabaseAccess) get_all_posts_from_user(user_id int) []Post { 93 + posts := sql app.db { 94 + select from Post where author_id == user_id order by posted_at desc 95 + } or { [] } 96 + return posts 97 + } 98 + 99 + // pin_post pins the given post, returns true if this succeeds and false 100 + // otherwise. 101 + pub fn (app &DatabaseAccess) pin_post(post_id int) bool { 102 + sql app.db { 103 + update Post set pinned = true where id == post_id 104 + } or { 105 + return false 106 + } 107 + return true 108 + } 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 + } 118 + return true 119 + } 120 + 121 + // delete_post deletes the given post and all likes associated with it, returns 122 + // true if this succeeds and false otherwise. 123 + pub fn (app &DatabaseAccess) delete_post(id int) bool { 124 + sql app.db { 125 + delete from Post where id == id 126 + delete from Like where post_id == id 127 + delete from LikeCache where post_id == id 128 + } or { 129 + return false 130 + } 131 + return true 132 + } 133 + 134 + ////// searching ////// 135 + 136 + // PostSearchResult represents a search result for a post. 137 + pub struct PostSearchResult { 138 + pub mut: 139 + post Post 140 + author User 141 + } 142 + 143 + @[inline] 144 + pub fn PostSearchResult.from_post(app &DatabaseAccess, post &Post) PostSearchResult { 145 + return PostSearchResult{ 146 + post: post 147 + author: app.get_user_by_id(post.author_id) or { app.get_unknown_user() } 148 + } 149 + } 150 + 151 + @[inline] 152 + pub fn PostSearchResult.from_post_list(app &DatabaseAccess, posts []Post) []PostSearchResult { 153 + mut results := []PostSearchResult{ 154 + cap: posts.len, 155 + len: posts.len 156 + } 157 + for index, post in posts { 158 + results[index] = PostSearchResult.from_post(app, post) 159 + } 160 + return results 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 + }
+158
src/database/saved_post.v
··· 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 { 9 + saved_posts := sql app.db { 10 + select from SavedPost where user_id == user_id && saved == true 11 + } or { [] } 12 + return saved_posts 13 + } 14 + 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 + } 28 + app.get_unknown_post() 29 + } 30 + }).filter(it.id != 0) 31 + return posts 32 + } 33 + 34 + // get_saved_posts_as_post_for gets all posts saved for later for a given user 35 + // converted to Post objects. 36 + pub fn (app &DatabaseAccess) get_saved_for_later_posts_as_post_for(user_id int) []Post { 37 + saved_posts := sql app.db { 38 + select from SavedPost where user_id == user_id && later == true 39 + } or { [] } 40 + posts := saved_posts.map(fn [app] (it SavedPost) Post { 41 + return app.get_post_by_id(it.post_id) or { 42 + // if the post does not exist, we will remove it now 43 + sql app.db { 44 + delete from SavedPost where id == it.id 45 + } or { 46 + eprintln('get_saved_for_later_posts_as_post_for: failed to remove non-existent post from saved post: ${it}') 47 + } 48 + app.get_unknown_post() 49 + } 50 + }).filter(it.id != 0) 51 + return posts 52 + } 53 + 54 + // get_user_post_save_status returns the SavedPost object representing the user 55 + // and post id. returns none if the post is not saved anywhere. 56 + pub fn (app &DatabaseAccess) get_user_post_save_status(user_id int, post_id int) ?SavedPost { 57 + saved_posts := sql app.db { 58 + select from SavedPost where user_id == user_id && post_id == post_id 59 + } or { [] } 60 + if saved_posts.len == 1 { 61 + return saved_posts[0] 62 + } else if saved_posts.len == 0 { 63 + return none 64 + } else { 65 + eprintln('get_user_post_save_status: user `${user_id}` had multiple SavedPost entries for post `${post_id}') 66 + return none 67 + } 68 + } 69 + 70 + pub fn (app &DatabaseAccess) is_post_saved_by(user_id int, post_id int) bool { 71 + saved_post := app.get_user_post_save_status(user_id, post_id) or { 72 + return false 73 + } 74 + return saved_post.saved 75 + } 76 + 77 + pub fn (app &DatabaseAccess) is_post_saved_for_later_by(user_id int, post_id int) bool { 78 + saved_post := app.get_user_post_save_status(user_id, post_id) or { 79 + return false 80 + } 81 + return saved_post.later 82 + } 83 + 84 + // toggle_save_post (un)saves the given post for the user. returns true if this 85 + // succeeds and false otherwise. 86 + pub fn (app &DatabaseAccess) toggle_save_post(user_id int, post_id int) bool { 87 + if s := app.get_user_post_save_status(user_id, post_id) { 88 + if s.saved { 89 + sql app.db { 90 + update SavedPost set saved = false where id == s.id 91 + } or { 92 + eprintln('toggle_save_post: failed to unsave post (user_id: ${user_id}, post_id: ${post_id})') 93 + return false 94 + } 95 + return true 96 + } else { 97 + sql app.db { 98 + update SavedPost set saved = true where id == s.id 99 + } or { 100 + eprintln('toggle_save_post: failed to save post (user_id: ${user_id}, post_id: ${post_id})') 101 + return false 102 + } 103 + return true 104 + } 105 + } else { 106 + post := SavedPost{ 107 + user_id: user_id 108 + post_id: post_id 109 + saved: true 110 + later: false 111 + } 112 + sql app.db { 113 + insert post into SavedPost 114 + } or { 115 + eprintln('toggle_save_post: failed to create saved post: ${post}') 116 + return false 117 + } 118 + return true 119 + } 120 + } 121 + 122 + // toggle_save_for_later_post (un)saves the given post for later for the user. 123 + // returns true if this succeeds and false otherwise. 124 + pub fn (app &DatabaseAccess) toggle_save_for_later_post(user_id int, post_id int) bool { 125 + if s := app.get_user_post_save_status(user_id, post_id) { 126 + if s.later { 127 + sql app.db { 128 + update SavedPost set later = false where id == s.id 129 + } or { 130 + eprintln('toggle_save_post: failed to unsave post for later (user_id: ${user_id}, post_id: ${post_id})') 131 + return false 132 + } 133 + return true 134 + } else { 135 + sql app.db { 136 + update SavedPost set later = true where id == s.id 137 + } or { 138 + eprintln('toggle_save_post: failed to save post for later (user_id: ${user_id}, post_id: ${post_id})') 139 + return false 140 + } 141 + return true 142 + } 143 + } else { 144 + post := SavedPost{ 145 + user_id: user_id 146 + post_id: post_id 147 + saved: false 148 + later: true 149 + } 150 + sql app.db { 151 + insert post into SavedPost 152 + } or { 153 + eprintln('toggle_save_post: failed to create saved post for later: ${post}') 154 + return false 155 + } 156 + return true 157 + } 158 + }
+34
src/database/site.v
··· 1 + module database 2 + 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 { 10 + // make the site config 11 + site_config := Site{} 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') 21 + } 22 + return configs[0] 23 + } 24 + 25 + // set_motd sets the site's current message of the day, returns true if this 26 + // succeeds and false otherwise. 27 + pub fn (app &DatabaseAccess) set_motd(motd string) bool { 28 + sql app.db { 29 + update Site set motd = motd where id == 1 30 + } or { 31 + return false 32 + } 33 + return true 34 + }
+249
src/database/user.v
··· 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 { 9 + sql app.db { 10 + insert user into User 11 + } or { 12 + eprintln('failed to insert user ${user}') 13 + return none 14 + } 15 + 16 + println('reg: ${user.username}') 17 + 18 + return app.get_user_by_name(user.username) 19 + } 20 + 21 + // set_username sets the given user's username, returns true if this succeeded 22 + // and false otherwise. 23 + pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool { 24 + sql app.db { 25 + update User set username = new_username where id == user_id 26 + } or { 27 + eprintln('failed to update username for ${user_id}') 28 + return false 29 + } 30 + return true 31 + } 32 + 33 + // set_password sets the given user's password, returns true if this succeeded 34 + // and false otherwise. 35 + pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool { 36 + sql app.db { 37 + update User set password = hashed_new_password where id == user_id 38 + } or { 39 + eprintln('failed to update password for ${user_id}') 40 + return false 41 + } 42 + return true 43 + } 44 + 45 + // set_nickname sets the given user's nickname, returns true if this succeeded 46 + // and false otherwise. 47 + pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool { 48 + sql app.db { 49 + update User set nickname = new_nickname where id == user_id 50 + } or { 51 + eprintln('failed to update nickname for ${user_id}') 52 + return false 53 + } 54 + return true 55 + } 56 + 57 + // set_muted sets the given user's muted status, returns true if this succeeded 58 + // and false otherwise. 59 + pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool { 60 + sql app.db { 61 + update User set muted = muted where id == user_id 62 + } or { 63 + eprintln('failed to update muted status for ${user_id}') 64 + return false 65 + } 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 { 84 + sql app.db { 85 + update User set theme = theme where id == user_id 86 + } or { 87 + eprintln('failed to update theme url for ${user_id}') 88 + return false 89 + } 90 + return true 91 + } 92 + 93 + // set_css sets the given user's custom CSS, returns true if this succeeded and 94 + // false otherwise. 95 + pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool { 96 + sql app.db { 97 + update User set css = css where id == user_id 98 + } or { 99 + eprintln('failed to update css for ${user_id}') 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 { 108 + sql app.db { 109 + update User set pronouns = pronouns where id == user_id 110 + } or { 111 + eprintln('failed to update pronouns for ${user_id}') 112 + return false 113 + } 114 + return true 115 + } 116 + 117 + // set_bio sets the given user's bio, returns true if this succeeded and false 118 + // otherwise. 119 + pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool { 120 + sql app.db { 121 + update User set bio = bio where id == user_id 122 + } or { 123 + eprintln('failed to update bio for ${user_id}') 124 + return false 125 + } 126 + return true 127 + } 128 + 129 + // get_user_by_name gets a user by their username, returns none if the user was 130 + // not found. 131 + pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User { 132 + users := sql app.db { 133 + select from User where username == username 134 + } or { [] } 135 + if users.len != 1 { 136 + return none 137 + } 138 + return users[0] 139 + } 140 + 141 + // get_user_by_id gets a user by their id, returns none if the user was not 142 + // found. 143 + pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User { 144 + users := sql app.db { 145 + select from User where id == id 146 + } or { [] } 147 + if users.len != 1 { 148 + return none 149 + } 150 + return users[0] 151 + } 152 + 153 + // get_users returns all users. 154 + pub fn (app &DatabaseAccess) get_users() []User { 155 + users := sql app.db { 156 + select from User 157 + } or { [] } 158 + return users 159 + } 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 + } 195 + 196 + // delete_user deletes the given user and their data, returns true if this 197 + // succeeded and false otherwise. 198 + pub fn (app &DatabaseAccess) delete_user(user_id int) bool { 199 + sql app.db { 200 + delete from User where id == user_id 201 + delete from Like where user_id == user_id 202 + delete from Notification where user_id == user_id 203 + } or { 204 + return false 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 + 220 + sql app.db { 221 + delete from Post where author_id == user_id 222 + } or { 223 + eprintln('failed to delete posts by deleting user: ${user_id}') 224 + } 225 + 226 + return true 227 + } 228 + 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 + }
+3 -3
src/entity/likes.v
··· 1 1 module entity 2 2 3 - // stores like information for posts 3 + // Like stores like information for a post. 4 4 pub struct Like { 5 5 pub mut: 6 6 id int @[primary; sql: serial] ··· 9 9 is_like bool 10 10 } 11 11 12 - // Stores total likes per post 12 + // LikeCache stores the total likes for a post. 13 13 pub struct LikeCache { 14 14 pub mut: 15 15 id int @[primary; sql: serial] 16 - post_id int 16 + post_id int @[unique] 17 17 likes int 18 18 }
+37 -2
src/entity/post.v
··· 1 1 module entity 2 2 3 + import db.pg 3 4 import time 5 + import util 4 6 5 7 pub struct Post { 6 8 pub mut: 7 - id int @[primary; sql: serial] 8 - author_id int 9 + id int @[primary; sql: serial] 10 + author_id int 11 + replying_to ?int 9 12 10 13 title string 11 14 body string 12 15 13 16 pinned bool 17 + nsfw bool 14 18 15 19 posted_at time.Time = time.now() 16 20 } 21 + 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 + }
+16
src/entity/saved_post.v
··· 1 + module entity 2 + 3 + // SavedPost represents a saved post for a given user 4 + pub struct SavedPost { 5 + pub mut: 6 + id int @[primary; sql: serial] 7 + post_id int 8 + user_id int 9 + saved bool 10 + later bool 11 + } 12 + 13 + // can_remove returns true if the SavedPost is neither saved or saved for later. 14 + pub fn (post &SavedPost) can_remove() bool { 15 + return !post.saved && !post.later 16 + }
+1
src/entity/site.v
··· 1 1 module entity 2 2 3 + // Site stores mutable site-wide config and data. 3 4 pub struct Site { 4 5 pub mut: 5 6 id int @[primary; sql: serial]
+44 -8
src/entity/user.v
··· 1 1 module entity 2 2 3 + import db.pg 3 4 import time 5 + import util 4 6 5 7 pub struct User { 6 8 pub mut: ··· 11 13 password string 12 14 password_salt string 13 15 14 - muted bool 15 - admin bool 16 + muted bool 17 + admin bool 18 + automated bool 16 19 17 - theme ?string 20 + theme string 21 + css string 18 22 19 23 bio string 20 24 pronouns string ··· 22 26 created_at time.Time = time.now() 23 27 } 24 28 29 + // get_name returns the user's nickname if it is not none, if so then their 30 + // username is returned. 25 31 @[inline] 26 32 pub fn (user User) get_name() string { 27 33 return user.nickname or { user.username } 28 34 } 29 35 30 - @[inline] 31 - pub fn (user User) get_theme() string { 32 - return user.theme or { '' } 33 - } 34 - 36 + // to_str_without_sensitive_data returns the stringified data for the user with 37 + // their password and salt censored. 35 38 @[inline] 36 39 pub fn (user User) to_str_without_sensitive_data() string { 37 40 return user.str() 38 41 .replace(user.password, '*'.repeat(16)) 39 42 .replace(user.password_salt, '*'.repeat(16)) 40 43 } 44 + 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 + }
+87 -35
src/main.v
··· 5 5 import auth 6 6 import entity 7 7 import os 8 + import webapp { App, Context, StringValidator } 9 + import beep_sql 10 + import util 8 11 9 - fn init_db(db pg.DB) ! { 10 - sql db { 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 { 11 30 create table entity.Site 12 31 create table entity.User 13 32 create table entity.Post 14 33 create table entity.Like 15 34 create table entity.LikeCache 16 35 create table entity.Notification 17 - }! 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 18 55 } 19 56 20 57 fn main() { 21 - config := load_config_from(os.args[1]) 22 - 23 - println('-> connecting to db...') 24 - mut db := pg.connect(pg.Config{ 25 - host: config.postgres.host 26 - dbname: config.postgres.db 27 - user: config.postgres.user 28 - password: config.postgres.password 29 - port: config.postgres.port 30 - })! 31 - println('<- connected') 32 - 33 - defer { 34 - db.close() 35 - } 58 + mut stopwatch := util.Stopwatch.new() 36 59 37 60 mut app := &App{ 38 - config: config 39 - db: db 40 - auth: auth.new(db) 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 + } 41 73 } 42 74 43 - // vfmt off 44 - app.validators.username = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 45 - app.validators.password = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 46 - app.validators.nickname = StringValidator.new(config.user.nickname_min_len, config.user.nickname_max_len, config.user.nickname_pattern) 47 - app.validators.user_bio = StringValidator.new(config.user.bio_min_len, config.user.bio_max_len, config.user.bio_pattern) 48 - app.validators.pronouns = StringValidator.new(config.user.pronouns_min_len, config.user.pronouns_max_len, config.user.pronouns_pattern) 49 - app.validators.post_title = StringValidator.new(config.post.title_min_len, config.post.title_max_len, config.post.title_pattern) 50 - app.validators.post_body = StringValidator.new(config.post.body_min_len, config.post.body_max_len, config.post.body_pattern) 51 - // vfmt on 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 + ) 52 98 53 - app.mount_static_folder_at(app.config.static_path, '/static')! 99 + // add authenticator 100 + app.auth = auth.new(app.db) 54 101 55 - println('-> initializing database...') 56 - init_db(db)! 57 - println('<- done') 102 + // load validators 103 + load_validators(mut app) 104 + 105 + // mount static things 106 + app.mount_static_folder_at(app.config.static_path, '/static')! 58 107 59 108 // make the website config, if it does not exist 60 109 app.get_or_create_site_config() 61 110 62 - if config.dev_mode { 111 + if app.config.dev_mode { 63 112 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 64 113 } 114 + 115 + stop := stopwatch.stop() 116 + println('-> took ${stop} to start app') 65 117 66 118 veb.run[App, Context](mut app, app.config.http.port) 67 119 }
-90
src/pages.v
··· 1 - module main 2 - 3 - import veb 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() 10 - pinned_posts := app.get_pinned_posts() 11 - motd := app.get_motd() 12 - return $veb.html() 13 - } 14 - 15 - fn (mut app App) login(mut ctx Context) veb.Result { 16 - ctx.title = 'login to ${app.config.instance.name}' 17 - user := app.whoami(mut ctx) or { User{} } 18 - return $veb.html() 19 - } 20 - 21 - fn (mut app App) register(mut ctx Context) veb.Result { 22 - ctx.title = 'register for ${app.config.instance.name}' 23 - user := app.whoami(mut ctx) or { User{} } 24 - return $veb.html() 25 - } 26 - 27 - fn (mut app App) me(mut ctx Context) veb.Result { 28 - user := app.whoami(mut ctx) or { 29 - ctx.error('not logged in') 30 - return ctx.redirect('/login') 31 - } 32 - ctx.title = '${app.config.instance.name} - ${user.get_name()}' 33 - return ctx.redirect('/user/${user.username}') 34 - } 35 - 36 - fn (mut app App) admin(mut ctx Context) veb.Result { 37 - ctx.title = '${app.config.instance.name} dashboard' 38 - user := app.whoami(mut ctx) or { User{} } 39 - return $veb.html() 40 - } 41 - 42 - fn (mut app App) inbox(mut ctx Context) veb.Result { 43 - user := app.whoami(mut ctx) or { 44 - ctx.error('not logged in') 45 - return ctx.redirect('/login') 46 - } 47 - ctx.title = '${app.config.instance.name} inbox' 48 - notifications := app.get_notifications_for(user.id) 49 - return $veb.html() 50 - } 51 - 52 - @['/user/:username'] 53 - fn (mut app App) user(mut ctx Context, username string) veb.Result { 54 - user := app.whoami(mut ctx) or { User{} } 55 - viewing := app.get_user_by_name(username) or { 56 - ctx.error('user not found') 57 - return ctx.redirect('/') 58 - } 59 - ctx.title = '${app.config.instance.name} - ${user.get_name()}' 60 - return $veb.html() 61 - } 62 - 63 - @['/post/:post_id'] 64 - fn (mut app App) post(mut ctx Context, post_id int) veb.Result { 65 - post := app.get_post_by_id(post_id) or { 66 - ctx.error('no such post') 67 - return ctx.redirect('/') 68 - } 69 - ctx.title = '${app.config.instance.name} - ${post.title}' 70 - user := app.whoami(mut ctx) or { User{} } 71 - return $veb.html() 72 - } 73 - 74 - @['/post/:post_id/edit'] 75 - fn (mut app App) edit(mut ctx Context, post_id int) veb.Result { 76 - user := app.whoami(mut ctx) or { 77 - ctx.error('not logged in') 78 - return ctx.redirect('/login') 79 - } 80 - post := app.get_post_by_id(post_id) or { 81 - ctx.error('no such post') 82 - return ctx.redirect('/') 83 - } 84 - if post.author_id != user.id { 85 - ctx.error('insufficient permissions') 86 - return ctx.redirect('/post/${post_id}') 87 - } 88 - ctx.title = '${app.config.instance.name} - editing ${post.title}' 89 - return $veb.html() 90 - }
src/static/favicon/favicon-144x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon-16x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon-32x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon-48x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon-64x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon-96x.png

This is a binary file and will not be displayed.

src/static/favicon/favicon.ico

This is a binary file and will not be displayed.

+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 + }
+14
src/static/js/post.js
··· 11 11 }) 12 12 window.location.reload() 13 13 } 14 + 15 + const save = async id => { 16 + await fetch('/api/post/save?id=' + id, { 17 + method: 'GET' 18 + }) 19 + window.location.reload() 20 + } 21 + 22 + const save_for_later = async id => { 23 + await fetch('/api/post/save_for_later?id=' + id, { 24 + method: 'GET' 25 + }) 26 + window.location.reload() 27 + }
+120 -26
src/static/js/render_body.js
··· 1 - // TODO: move this to the backend? 1 + const get_apple_music_iframe = src => 2 + `<iframe 3 + class="post-iframe iframe-music iframe-music-apple" 4 + style="border-radius:12px" 5 + width="100%" 6 + height="152" 7 + frameBorder="0" 8 + allowfullscreen="" 9 + allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" 10 + loading="lazy" 11 + src="${src}" 12 + ></iframe>` 13 + 14 + const get_spotify_iframe = src => 15 + `<iframe 16 + class="post-iframe iframe-music iframe-music-spotify" 17 + allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write" 18 + frameborder="0" 19 + height="175" 20 + style="width:100%;overflow:hidden;border-radius:10px;" 21 + sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation" 22 + loading="lazy" 23 + src="${src}" 24 + ></iframe>` 25 + 26 + const get_youtube_frame = src => 27 + `<iframe 28 + width="560" 29 + height="315" 30 + src="${src}" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe>` 37 + 38 + const link_handlers = { 39 + 'https://music.apple.com/': link => { 40 + const embed_url = `https://embed.${link.substring(8)}` 41 + return get_apple_music_iframe(embed_url) 42 + }, 43 + 'https://open.spotify.com/': link => { 44 + const type = link.substring(link.indexOf('/', 8) + 1, link.indexOf('/', link.indexOf('/', 8) + 1)) 45 + const id = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?')) 46 + const embed_url = `https://open.spotify.com/embed/${type}/${id}?utm_source=generator&theme=0` 47 + return get_spotify_iframe(embed_url) 48 + }, 49 + 'https://youtu.be/': link => { 50 + const id = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?')) 51 + const embed_url = `https://www.youtube.com/embed/${id}` 52 + return get_youtube_frame(embed_url) 53 + }, 54 + } 55 + 2 56 const render_body = async id => { 3 57 const element = document.getElementById(id) 4 58 var body = element.innerText 59 + var html = element.innerHTML 60 + 61 + // give the body a loading """animation""" while we let the fetches cook 62 + element.innerText = 'loading...' 5 63 6 - const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g) 64 + const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g) 7 65 const cache = {} 8 66 for (const match of matches) { 67 + // escaped 68 + if (match[0][0] == '\\') { 69 + html = html.replace(match[0], match[0].replace('\\', '')) 70 + } 9 71 // mention 10 - if (match[0][0] == '@') { 72 + else if (match[0][0] == '@') { 11 73 if (cache.hasOwnProperty(match[0])) { 12 - element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]]) 74 + html = html.replace(match[0], cache[match[0]]) 13 75 continue 14 76 } 15 - (await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 16 - if (s == 'no such user') { 17 - return 18 - } 19 - const link = document.createElement('a') 20 - link.href = `/user/${match[0].substring(2, match[0].length - 1)}` 21 - link.innerText = s 22 - cache[match[0]] = link.outerHTML 23 - element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 24 - }) 77 + const s = await (await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text() 78 + const link = document.createElement('a') 79 + link.href = `/user/${match[0].substring(2, match[0].length - 1)}` 80 + link.innerText = '@' + s 81 + cache[match[0]] = link.outerHTML 82 + html = html.replace(match[0], link.outerHTML) 25 83 } 26 - // hashtag 84 + // tags 27 85 else if (match[0][0] == '#') { 86 + // we do not cache tags because they do not need to do 87 + // any http queries, and most people will not use the 88 + // same tag multiple times in a single post. 89 + const link = document.createElement('a') 90 + const tag = match[0].substring(2, match[0].length - 1) 91 + link.href = `/tag/${tag}` 92 + link.innerText = '#' + tag 93 + cache[match[0]] = link.outerHTML 94 + html = html.replace(match[0], link.outerHTML) 28 95 } 29 96 // post reference 30 97 else if (match[0][0] == '*') { 31 98 if (cache.hasOwnProperty(match[0])) { 32 - element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]]) 99 + html = html.replace(match[0], cache[match[0]]) 33 100 continue 34 101 } 35 - (await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 36 - if (s == 'no such post') { 37 - return 38 - } 39 - const link = document.createElement('a') 40 - link.href = `/post/${match[0].substring(2, match[0].length - 1)}` 41 - link.innerText = s 42 - cache[match[0]] = link.outerHTML 43 - element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 44 - }) 102 + const s = await (await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text() 103 + const link = document.createElement('a') 104 + link.href = `/post/${match[0].substring(2, match[0].length - 1)}` 105 + link.innerText = '*' + s 106 + cache[match[0]] = link.outerHTML 107 + html = html.replace(match[0], link.outerHTML) 108 + } 109 + } 110 + 111 + var handled_links = [] 112 + // i am not willing to write a url regex myself, so here is where i got 113 + // this: https://stackoverflow.com/a/3809435 114 + const links = html.matchAll(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g) 115 + for (const match of links) { 116 + const link = match[0] 117 + for (const entry of Object.entries(link_handlers)) { 118 + if (link.startsWith(entry[0])) { 119 + handled_links.push(entry[1](link)) 120 + break 121 + } 122 + } 123 + // sanatize the link before rendering it directly. no link 124 + // should ever have these three characters in them anyway. 125 + const sanatized = link 126 + .replace('<', '&gt;') 127 + .replace('>', '&lt;') 128 + .replace('"', '&quot;') 129 + html = html.replace(link, `<a href="${sanatized}">${sanatized}</a>`) 130 + } 131 + 132 + // append handled links 133 + if (handled_links.length > 0) { 134 + // element.innerHTML += '\n\nlinks:\n' 135 + for (const handled of handled_links) { 136 + html += `\n\n${handled}` 45 137 } 46 138 } 139 + 140 + element.innerHTML = html 47 141 }
+15
src/static/js/search.js
··· 1 + const search_posts = async (query, limit, offset) => { 2 + const data = await fetch(`/api/post/search?query=${query}&limit=${limit}&offset=${offset}`, { 3 + method: 'GET' 4 + }) 5 + const json = await data.json() 6 + return json 7 + } 8 + 9 + const search_users = async (query, limit, offset) => { 10 + const data = await fetch(`/api/user/search?query=${query}&limit=${limit}&offset=${offset}`, { 11 + method: 'GET' 12 + }) 13 + const json = await data.json() 14 + return json 15 + }
+10
src/static/js/text_area_counter.js
··· 1 + // this script is used to provide character counters to textareas 2 + 3 + const add_character_counter = (textarea_id, p_id, max_len) => { 4 + const textarea = document.getElementById(textarea_id) 5 + const p = document.getElementById(p_id) 6 + textarea.addEventListener('input', () => { 7 + p.innerText = textarea.value.length + '/' + max_len 8 + }) 9 + p.innerText = textarea.value.length + '/' + max_len 10 + }
+1
src/static/js/user_utils.js
··· 1 + const get_display_name = user => user.nickname == undefined ? user.username : user.nickname
+26 -2
src/static/style.css
··· 1 + :root { 2 + --c-nsfw-border: red; 3 + } 4 + 1 5 .post, 2 6 .notification { 3 7 border: 2px solid; 4 8 padding: 8px; 5 9 } 6 10 7 - .post p, 8 - .notification p { 11 + .post > p, 12 + .notification > p { 9 13 margin: 0; 14 + } 15 + 16 + .post > pre, 17 + .notification > pre { 18 + margin: 0; 19 + display: inline; 10 20 } 11 21 12 22 .post + .post, ··· 16 26 17 27 pre { 18 28 white-space: pre-wrap; 29 + word-wrap: break-word; 30 + } 31 + 32 + span.nsfw-indicator { 33 + border: 2px solid var(--c-nsfw-border); 34 + border-radius: 2px; 35 + padding-left: 4px; 36 + padding-right: 4px; 37 + margin-left: 6px; 38 + } 39 + 40 + details>summary:hover { 41 + cursor: pointer; 19 42 } 20 43 21 44 /* ··· 24 47 */ 25 48 input[hidden] { 26 49 display: none !important; 50 + visibility: none !important; 27 51 }
+239
src/static/themes/default.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Onest:wght@100..900&family=Oxygen+Mono&display=swap'); 2 + 3 + :root { 4 + /* palette */ 5 + /* greys */ 6 + --p-black: #333333; 7 + --p-grey0: #414141; 8 + --p-grey1: #4a4a4a; 9 + --p-grey2: #4f4f4f; 10 + --p-grey3: #5c5c5c; 11 + --p-grey4: #5f5f5f; 12 + --p-white: #e7e7e7; 13 + /* rainbow */ 14 + --p-red: #faa; /* == light red */ 15 + --p-orange: #fa7; 16 + --p-yellow: #ffa; /* == light-orange */ 17 + --p-teal: #7fa; 18 + --p-green: #af7; 19 + --p-blue: #7af; 20 + --p-purple: #a7f; 21 + --p-pink: #f7a; 22 + /* light rainbow */ 23 + --p-light-red: #faa; 24 + --p-light-blue: #aaf; 25 + --p-light-green: #afa; 26 + --p-light-orange: #ffa; /* == yellow */ 27 + --p-light-purple: #faf; 28 + --p-light-blue: #aff; 29 + 30 + /* colours */ 31 + --c-bg: var(--p-black); 32 + --c-panel-bg: var(--p-grey0); 33 + --c-panel-border: var(--p-grey2); 34 + --c-panel2-bg: var(--p-grey1); 35 + --c-panel2-border: var(--p-grey3); 36 + --c-panel3-bg: var(--p-grey2); 37 + --c-panel3-border: var(--p-grey4); 38 + --c-fg: var(--p-white); 39 + --c-nsfw-border: var(--p-orange); 40 + --c-link: var(--p-blue); 41 + --c-link-hover: var(--p-light-blue); 42 + --c-accent: var(--p-light-green); 43 + --c-notify-ok: var(--p-light-green); 44 + --c-notify-error: var(--p-light-red); 45 + 46 + /* text */ 47 + --t-font: 'Onest', Arial, serif; 48 + --t-post-font: Garamond, 'Times New Roman', var(--t-font); 49 + --t-mono-font: 'Oxygen Mono', monospace; 50 + --t-h-font: 'Oxygen Mono', var(--t-post-font); 51 + --t-font-weight: 400; 52 + --t-font-style: normal; 53 + --t-font-size: 20px; 54 + 55 + /* layout */ 56 + --l-body-padding: 16px; 57 + --l-body-gap: 12px; 58 + --l-body-width: 75vw; 59 + --l-border-width: 2px; 60 + --l-border-style: solid; 61 + --l-border-radius: 0px; 62 + } 63 + 64 + html { 65 + padding: 0; 66 + offset: 0; 67 + margin: 0; 68 + 69 + width: 100vw; 70 + overflow-x: hidden; 71 + 72 + display: flex; 73 + flex-direction: column; 74 + align-items: center; 75 + 76 + background-color: var(--c-bg); 77 + color: var(--c-fg); 78 + 79 + font-family: var(--t-font); 80 + font-weight: var(--t-font-weight); 81 + font-style: var(--t-font-style); 82 + font-size: var(--t-font-size); 83 + } 84 + 85 + body { 86 + padding: var(--l-body-padding) 0 var(--l-body-padding) 0; 87 + offset: 0; 88 + margin: 0; 89 + width: var(--l-body-width); 90 + } 91 + 92 + header { 93 + padding-bottom: var(--l-body-padding); 94 + } 95 + 96 + footer { 97 + padding-top: var(--l-body-padding); 98 + } 99 + 100 + main { 101 + padding: var(--l-body-padding); 102 + background-color: var(--c-panel-bg); 103 + border: var(--l-border-width) var(--l-border-style) var(--c-panel-border); 104 + border-radius: var(--l-border-radius); 105 + 106 + display: flex; 107 + flex-direction: column; 108 + gap: var(--l-body-gap); 109 + } 110 + 111 + form { 112 + display: flex; 113 + flex-direction: column; 114 + gap: var(--l-body-gap); 115 + } 116 + 117 + button:hover { 118 + cursor: pointer; 119 + } 120 + 121 + input, 122 + textarea, 123 + button { 124 + background-color: var(--c-panel-bg); 125 + color: var(--c-fg); 126 + 127 + border: var(--l-border-width) var(--l-border-style) var(--c-accent); 128 + border-radius: var(--l-border-radius); 129 + padding: 6px; 130 + 131 + font-family: var(--t-font); 132 + } 133 + 134 + input:hover, 135 + textarea:hover, 136 + button:hover { 137 + border-color: var(--c-fg); 138 + } 139 + 140 + input:focus, 141 + textarea:focus, 142 + button:focus { 143 + background-color: var(--c-accent); 144 + color: var(--c-bg); 145 + } 146 + 147 + h1, h2, h3, h4, h5, h6, p { 148 + margin: 0; 149 + } 150 + 151 + h1, header, footer { 152 + font-family: var(--t-h-font); 153 + } 154 + 155 + a { 156 + color: var(--c-link); 157 + transition: 0.15s linear color; 158 + } 159 + 160 + a:hover { 161 + color: var(--c-link-hover); 162 + } 163 + 164 + hr { 165 + width: 100%; 166 + } 167 + 168 + pre { 169 + font-family: var(--t-mono-font); 170 + } 171 + 172 + .post { 173 + border: none; 174 + border-left: var(--l-border-width) var(--l-border-style) var(--c-fg); 175 + } 176 + 177 + .post>pre { 178 + font-family: var(--t-post-font); 179 + } 180 + 181 + .post + .post, 182 + .notification + .notification { 183 + margin-top: 18px; 184 + } 185 + 186 + form:not(.form-inline), 187 + #recent-posts, 188 + #pinned-posts { 189 + padding: 16px 24px 16px 24px; 190 + background-color: var(--c-panel2-bg); 191 + border: var(--l-border-width) var(--l-border-style) var(--c-panel2-border); 192 + border-radius: var(--l-border-radius); 193 + } 194 + 195 + #errors:empty { 196 + display: none; 197 + visibility: hidden; 198 + } 199 + 200 + #errors { 201 + display: flex; 202 + flex-direction: column; 203 + gap: var(--l-body-gap); 204 + } 205 + 206 + #errors>p { 207 + background-color: var(--c-panel3-bg); 208 + border: var(--l-border-width) var(--l-border-style) var(--c-panel3-border); 209 + border-radius: var(--l-border-radius); 210 + 211 + padding: 8px; 212 + width: calc(100% - 16px); 213 + 214 + display: inline-flex; 215 + align-items: center; 216 + justify-content: center; 217 + gap: 12px; 218 + } 219 + 220 + #errors>p>button { 221 + border-color: inherit; 222 + flex-grow: 0; 223 + } 224 + 225 + #errors>p>button:hover { 226 + border-color: var(--c-fg); 227 + } 228 + 229 + #errors>p>span { 230 + flex-grow: 1; 231 + } 232 + 233 + #errors>p.ok { 234 + border-color: var(--c-notify-ok); 235 + } 236 + 237 + #errors>p.error { 238 + border-color: var(--c-notify-error); 239 + }
+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 2 <p> 3 3 <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: 4 4 <a href="/post/@post.id">@post.title</a> 5 + @if post.nsfw 6 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 7 + @end 5 8 </p> 6 9 </div>
+16 -4
src/templates/components/post_small.html
··· 1 1 <div class="post post-small"> 2 - <p><a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: <span class="post-title">@post.title</span></p> 3 - @if post.body.len > 50 4 - <p>@{post.body[..50]}...</p> 2 + <p> 3 + <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: 4 + <span class="post-title">@post.title</span> 5 + @if post.nsfw 6 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 7 + @end 8 + </p> 9 + 10 + @if post.nsfw 11 + <p>view full to see post body</p> 5 12 @else 6 - <p>@post.body</p> 13 + @if post.body.len > 50 14 + <pre id="post-@{post.id}">@{post.body[..50]}...</pre> 15 + @else 16 + <pre id="post-@{post.id}">@post.body</pre> 17 + @end 7 18 @end 19 + 8 20 <p>likes: @{app.get_net_likes_for_post(post.id)} | posted at: @post.posted_at | <a href="/post/@post.id">view full post</a></p> 9 21 </div>
+5
src/templates/components/user_card_mini.html
··· 1 + <div class="user-card user-card-mini"> 2 + <p> 3 + <a href="/user/@{user.username}">@{user.get_name()}</a> 4 + </p> 5 + </div>
+50 -3
src/templates/edit.html
··· 1 1 @include 'partial/header.html' 2 2 3 - <script src="/static/js/post.js"></script> 4 - <script src="/static/js/render_body.js"></script> 3 + <script src="/static/js/post.js" defer></script> 4 + <script src="/static/js/render_body.js" defer></script> 5 + <script src="/static/js/text_area_counter.js"></script> 5 6 6 7 <h1>edit post</h1> 7 8 8 9 <div class="post post-full"> 9 - <form action="/api/post/edit" method="post"> 10 + <form action="/api/post/edit" method="post" beep-redirect="/post/@post.id"> 10 11 <input 11 12 type="number" 12 13 name="id" ··· 18 19 hidden 19 20 aria-hidden 20 21 > 22 + 23 + <p id="title_chars">0/@{app.config.post.title_max_len}</p> 21 24 <input 22 25 type="text" 23 26 name="title" ··· 30 33 required 31 34 > 32 35 <br> 36 + 37 + <p id="body_chars">0/@{app.config.post.body_max_len}</p> 33 38 <textarea 34 39 name="body" 35 40 id="body" ··· 41 46 required 42 47 >@post.body</textarea> 43 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 + 44 67 <input type="submit" value="save"> 45 68 </form> 46 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" 79 + id="id" 80 + placeholder="post id" 81 + value="@post.id" 82 + required aria-required 83 + readonly aria-readonly 84 + hidden aria-hidden 85 + > 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> 47 94 48 95 @include 'partial/footer.html'
+10 -3
src/templates/inbox.html
··· 9 9 @if notifications.len == 0 10 10 <p>your inbox is empty!</p> 11 11 @else 12 - <a href="/api/user/notification/clear_all">clear all</a> 12 + <form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox"> 13 + <button>clear all</button> 14 + </form> 13 15 <hr> 14 16 @for notification in notifications.reverse() 15 17 <div class="notification"> 16 - <p><strong>@notification.summary</strong></p> 18 + <div style="display: flex; flex-direction: row; align-items: center; gap: 12px;"> 19 + <p><strong>@notification.summary</strong></p> 20 + <form action="/api/user/notification/clear" method="post" beep-redirect="/inbox" class="form-inline" style="display: inline;"> 21 + <input type="number" value="@{notification.id}" name="id" required aria-required hidden aria-hidden readonly aria-readonly /> 22 + <button style="display: inline;">clear</button> 23 + </form> 24 + </div> 17 25 <pre id="notif-@{notification.id}">@notification.body</pre> 18 - <a href="/api/user/notification/clear?id=@{notification.id}">clear</a> 19 26 <script> 20 27 render_body('notif-@{notification.id}') 21 28 </script>
+6 -5
src/templates/index.html
··· 8 8 9 9 <div> 10 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 13 @for post in pinned_posts 14 14 @include 'components/post_small.html' 15 15 @end 16 16 </div> 17 + <br> 17 18 @end 18 19 19 - <h2>recent posts:</h2> 20 - <div> 20 + <div id="recent-posts"> 21 + <h2>recent posts:</h2> 21 22 @if recent_posts.len > 0 22 23 @for post in recent_posts 23 24 @include 'components/post_small.html' ··· 28 29 </div> 29 30 </div> 30 31 31 - @include 'partial/footer.html' 32 + @include 'partial/footer.html'
+2 -2
src/templates/login.html
··· 11 11 <p>you are already logged in as @{user.get_name()}!</p> 12 12 <a href="/api/user/logout">log out</a> 13 13 @else 14 - <form action="/api/user/login" method="post"> 14 + <form action="/api/user/login" method="post" beep-redirect="/me"> 15 15 <label for="username">username:</label> 16 16 <input 17 17 type="text" ··· 39 39 @end 40 40 </div> 41 41 42 - @include 'partial/footer.html' 42 + @include 'partial/footer.html'
+17
src/templates/logout.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + <h1>log out</h1> 5 + 6 + <p>you are currently logged in as: @{user.get_name()}</p> 7 + 8 + <div> 9 + <p><a href="/api/user/logout">log out</a></p> 10 + <hr> 11 + <p><a href="/api/user/full_logout">log out of all devices</a></p> 12 + </div> 13 + @else 14 + <p>you need to be logged in to log out!</p> 15 + @end 16 + 17 + @include 'partial/footer.html'
+16
src/templates/new_post.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + @if replying 6 + <h1>reply to @{replying_to_user.get_name()} with...</h1> 7 + <p>(replying to <a href="/post/${replying_to}">this</a>)</p> 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 15 + 16 + @include 'partial/footer.html'
+15 -5
src/templates/partial/footer.html
··· 1 1 </main> 2 2 3 3 <footer> 4 - <p>powered by beep</p> 5 - <p><a href="https://github.com/emmathemartian/beep">source</a></p> 6 - @if app.config.dev_mode 7 - <p>token: @{ctx.get_cookie('token')}</p> 8 - @end 4 + <p> 5 + @if ctx.is_logged_in() 6 + <a href="/settings">settings</a> 7 + @if user.admin 8 + - 9 + <a href="/admin">admin</a> 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> 9 19 </footer> 10 20 11 21 </body>
+25 -19
src/templates/partial/header.html
··· 1 1 <!DOCTYPE html> 2 - <html> 2 + <html lang="en"> 3 3 4 4 <head> 5 5 <meta charset="utf-8" /> 6 - <meta name="description" content="" /> 7 - <link rel="icon" href="/favicon.png" /> 8 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 + <meta name="description" content="" /> 8 + 9 9 <title>@ctx.title</title> 10 + 10 11 @include 'assets/style.html' 11 - @if ctx.is_logged_in() && user.theme != none 12 - <link rel="stylesheet" href="@user.get_theme()"> 12 + 13 + @if ctx.is_logged_in() && user.theme != '' 14 + <link rel="stylesheet" href="@user.theme"> 13 15 @else if app.config.instance.default_theme != '' 14 16 <link rel="stylesheet" href="@app.config.instance.default_theme"> 15 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> 16 29 </head> 17 30 18 31 <body> 19 32 20 33 <header> 34 + @if ctx.is_logged_in() 35 + <a href="/me">@@@user.get_name()</a> 36 + - 37 + @end 38 + 21 39 @if app.config.dev_mode 22 40 <span><strong>dev mode</strong></span> 23 41 - ··· 25 43 26 44 <a href="/">home</a> 27 45 - 28 - 29 - @if app.config.dev_mode || (ctx.is_logged_in() && user.admin) 30 - <a href="/admin">admin</a> 31 - - 32 - @end 33 46 34 47 @if ctx.is_logged_in() 35 - <a href="/me">profile</a> 36 - - 37 48 <a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a> 38 49 - 39 - <a href="/api/user/logout">log out</a> 50 + <a href="/search">search</a> 40 51 @else 41 52 <a href="/login">log in</a> 42 53 <span>or</span> ··· 45 56 </header> 46 57 47 58 <main> 48 - <!-- TODO: fix this lol --> 49 - @if ctx.form_error != '' 50 - <div> 51 - <p><strong>error:</strong> @ctx.form_error</p> 52 - </div> 53 - @end 59 + <div id="errors"></div>
+77 -22
src/templates/post.html
··· 4 4 <script src="/static/js/render_body.js"></script> 5 5 6 6 <div class="post post-full"> 7 - <h2><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> - @post.title</h2> 7 + <h2> 8 + <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a> 9 + - 10 + @if replying_to_post.id == 0 11 + @post.title 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 8 28 <pre id="post-@{post.id}">@post.body</pre> 29 + @end 30 + 31 + <hr> 32 + 9 33 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 10 34 <p><em>posted at: @post.posted_at</em></p> 11 35 12 - @if ctx.is_logged_in() && post.author_id == user.id 13 - <p><a href="/post/@{post.id}/edit">edit post</a></p> 14 - @end 15 - 16 - @if ctx.is_logged_in() 36 + @if ctx.is_logged_in() && !user.automated 37 + <br> 38 + <p><a href="/post/@{post.id}/reply">reply</a></p> 17 39 <br> 18 40 <div> 19 41 <button onclick="like(@post.id)"> ··· 30 52 dislike 31 53 @end 32 54 </button> 55 + <button onclick="save(@post.id)"> 56 + @if app.is_post_saved_by(user.id, post.id) 57 + saved! 58 + @else 59 + save 60 + @end 61 + </button> 62 + <button onclick="save_for_later(@post.id)"> 63 + @if app.is_post_saved_for_later_by(user.id, post.id) 64 + saved for later! 65 + @else 66 + save for later 67 + @end 68 + </button> 33 69 </div> 34 70 @end 35 71 ··· 39 75 40 76 @if post.author_id == user.id 41 77 <h4>manage post:</h4> 42 - @else if user.admin 43 - <h4>admin powers:</h4> 78 + 79 + <p><a href="/post/@{post.id}/edit">edit</a></p> 44 80 @end 45 81 46 - <form action="/api/post/delete" method="post"> 47 - <input 48 - type="number" 49 - name="id" 50 - id="id" 51 - placeholder="post id" 52 - value="@post.id" 53 - required 54 - readonly 55 - hidden 56 - aria-hidden 57 - > 58 - <input type="submit" value="delete"> 59 - </form> 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 60 115 61 116 </div> 62 117 @end
+32 -3
src/templates/register.html
··· 1 1 @include 'partial/header.html' 2 2 3 + <script src="/static/js/password.js"></script> 4 + 3 5 <h1>register</h1> 4 6 5 7 <div> ··· 11 13 <p>you are already logged in as @{user.get_name()}!</p> 12 14 <a href="/api/user/logout">log out</a> 13 15 @else 14 - <form action="/api/user/register" method="post"> 16 + <form action="/api/user/register" method="post" beep-redirect="/me"> 15 17 <label for="username">username:</label> 16 18 <input 17 19 type="text" ··· 23 25 required 24 26 > 25 27 <br> 26 - <label for="password">password:</label> 28 + <label for="password">password: <a href="#" id="view-password" style="display: inline;">view</a></label> 27 29 <input 28 30 type="password" 29 31 name="password" ··· 34 36 required 35 37 > 36 38 <br> 39 + <label for="confirm-password">confirm password: <a href="#" id="view-confirm-password" style="display: inline;">view</a></label> 40 + <input 41 + type="password" 42 + name="confirm-password" 43 + id="confirm-password" 44 + pattern="@app.config.user.password_pattern" 45 + minlength="@app.config.user.password_min_len" 46 + maxlength="@app.config.user.password_max_len" 47 + required 48 + > 49 + <br> 50 + <p>passwords match: <span id="passwords-match">yes</span></p> 51 + <br> 52 + @if app.config.instance.invite_only 53 + <label for="invite-code">invite code:</label> 54 + <input type="text" name="invite-code" id="invite-code" required> 55 + <br> 56 + @end 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 37 62 <input type="submit" value="register"> 38 63 </form> 39 64 @end 40 65 </div> 41 66 42 - @include 'partial/footer.html' 67 + <script> 68 + add_password_checkers('password', 'confirm-password', 'passwords-match'); 69 + </script> 70 + 71 + @include 'partial/footer.html'
+35
src/templates/saved_posts.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + <script src="/static/js/post.js"></script> 6 + 7 + <p><a href="/me">back</a></p> 8 + 9 + <h1>saved posts:</h1> 10 + 11 + <div> 12 + @if posts.len > 0 13 + @for post in posts 14 + <!-- components/post_mini.html --> 15 + <div class="post post-mini"> 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> 25 + @end 26 + @else 27 + <p>none!</p> 28 + @end 29 + </div> 30 + 31 + @else 32 + <p>uh oh, you need to be logged in to see this page</p> 33 + @end 34 + 35 + @include 'partial/footer.html'
+35
src/templates/saved_posts_for_later.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + <script src="/static/js/post.js"></script> 6 + 7 + <p><a href="/me">back</a></p> 8 + 9 + <h1>saved posts for later:</h1> 10 + 11 + <div> 12 + @if posts.len > 0 13 + @for post in posts 14 + <!-- components/post_mini.html --> 15 + <div class="post post-mini"> 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> 25 + @end 26 + @else 27 + <p>none!</p> 28 + @end 29 + </div> 30 + 31 + @else 32 + <p>uh oh, you need to be logged in to see this page</p> 33 + @end 34 + 35 + @include 'partial/footer.html'
+181
src/templates/search.html
··· 1 + @include 'partial/header.html' 2 + 3 + <script src="/static/js/user_utils.js"></script> 4 + <script src="/static/js/search.js"></script> 5 + 6 + <h1>search</h1> 7 + 8 + <div> 9 + <input type="text" name="query" id="query"> 10 + <div> 11 + <p>search for:</p> 12 + <input type="radio" name="search-for" id="search-for-posts" value="posts" checked aria-checked> 13 + <label for="search-for-posts">posts</label> 14 + <input type="radio" name="search-for" id="search-for-users" value="users"> 15 + <label for="search-for-users">users</label> 16 + </div> 17 + <br> 18 + <button id="search">search</button> 19 + </div> 20 + 21 + <br> 22 + 23 + <div id="pages"> 24 + </div> 25 + 26 + <div id="results"> 27 + </div> 28 + 29 + <script> 30 + const params = new URLSearchParams(window.location.search) 31 + 32 + const pages = document.getElementById('pages') 33 + const results = document.getElementById('results') 34 + 35 + const query = document.getElementById('query') 36 + if (query.value == '' && params.get('q')) { 37 + query.value = params.get('q') 38 + } 39 + 40 + let limit = params.get('limit') 41 + if (!limit) { 42 + limit = 10 43 + } 44 + 45 + let offset = params.get('offset') 46 + if (!limit) { 47 + offset = 0 48 + } 49 + 50 + const add_post_result = result => { 51 + // same as components/post_mini.html except js 52 + const element = document.createElement('div') 53 + element.classList.add('post', 'post-mini') 54 + const p = document.createElement('p') 55 + 56 + const user_link = document.createElement('a') 57 + user_link.href = '/user/' + result.author.username 58 + const user_text = document.createElement('strong') 59 + user_text.innerText = get_display_name(result.author) 60 + user_link.appendChild(user_text) 61 + p.appendChild(user_link) 62 + 63 + p.innerHTML += ': ' 64 + 65 + const post_link = document.createElement('a') 66 + post_link.href = '/post/' + result.post.id 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 + } 81 + 82 + const add_user_result = user => { 83 + const element = document.createElement('div') 84 + const p = document.createElement('p') 85 + const user_link = document.createElement('a') 86 + user_link.href = '/user/' + user.username 87 + user_link.innerText = get_display_name(user) 88 + p.appendChild(user_link) 89 + element.appendChild(p) 90 + results.appendChild(element) 91 + } 92 + 93 + const add_pages = () => { 94 + // creates a separator 95 + const sep = () => { 96 + const span = document.createElement('span') 97 + span.innerText = ' - ' 98 + pages.appendChild(span) 99 + } 100 + 101 + const first_link = document.createElement('a') 102 + // we escape the $ here because otherwise V will try to perform replacements at compile-time. 103 + //todo: report this, this behaviour should be changed or at least looked into further. 104 + first_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=0' 105 + first_link.innerText = '0' 106 + pages.appendChild(first_link) 107 + 108 + sep() 109 + 110 + const back_link = document.createElement('a') 111 + back_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + Math.min(0, offset - 10) 112 + back_link.innerText = '<' 113 + pages.appendChild(back_link) 114 + 115 + sep() 116 + 117 + const next_link = document.createElement('a') 118 + next_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + (offset + 10) 119 + next_link.innerText = '>' 120 + pages.appendChild(next_link) 121 + } 122 + 123 + document.getElementById('search').addEventListener('click', async () => { 124 + results.innerHTML = '' // yeet the children! 125 + pages.innerHTML = '' // yeet more children! 126 + 127 + var search_for 128 + for (const radio of document.getElementsByName('search-for')) { 129 + if (radio.checked) { 130 + search_for = radio.value 131 + break 132 + } 133 + } 134 + if (search_for == undefined) { 135 + alert('please select either "users" or "posts" to search for.') 136 + return 137 + } 138 + 139 + console.log('search: ', query.value, limit, offset) 140 + 141 + var search_results 142 + if (search_for == 'users') { 143 + search_results = await search_users(query.value, limit, offset) 144 + } else if (search_for == 'posts') { 145 + search_results = await search_posts(query.value, limit, offset) 146 + } else { 147 + // this should never happen 148 + alert('something wrong occured while searching, please report this (01)') 149 + return 150 + } 151 + 152 + console.log(search_results) 153 + 154 + if (search_results.length >= 0) { 155 + // i iterate inside the if statements so that i do not have to perform a redundant 156 + // string comparison for every single result. 157 + if (search_for == 'users') { 158 + for (result of search_results) { 159 + add_user_result(result) 160 + } 161 + } else if (search_for == 'posts') { 162 + for (result of search_results) { 163 + add_post_result(result) 164 + } 165 + } else { 166 + // this should never happen 167 + alert('something wrong occured while searching, please report this (02)') 168 + return 169 + } 170 + 171 + // set up pagination, but only if we actually have pages to display 172 + if (offset > 0) { 173 + add_pages() 174 + } 175 + } else { 176 + results.innerText = 'no results!' 177 + } 178 + }) 179 + </script> 180 + 181 + @include 'partial/footer.html'
+221
src/templates/settings.html
··· 1 + @include 'partial/header.html' 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 + 9 + <form action="/api/user/set_bio" method="post"> 10 + <label for="bio">bio: (<span id="bio_chars">0/@{app.config.user.bio_max_len}</span>)</label> 11 + <br> 12 + <textarea 13 + name="bio" 14 + id="bio" 15 + cols="30" 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"> 22 + </form> 23 + 24 + <hr> 25 + 26 + <form action="/api/user/set_pronouns" method="post"> 27 + <label for="pronouns">pronouns: (<span id="pronouns_chars">0/@{app.config.user.pronouns_max_len}</span>)</label> 28 + <input 29 + type="text" 30 + name="pronouns" 31 + id="pronouns" 32 + minlength="@app.config.user.pronouns_min_len" 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> 39 + 40 + <hr> 41 + 42 + <form action="/api/user/set_nickname" method="post"> 43 + <label for="nickname">nickname: (<span id="nickname_chars">0/@{app.config.user.nickname_max_len}</span>)</label> 44 + <input 45 + type="text" 46 + name="nickname" 47 + id="nickname" 48 + pattern="@app.config.user.nickname_pattern" 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> 55 + 56 + <form action="/api/user/set_nickname" method="post"> 57 + <input type="submit" value="reset nickname"> 58 + </form> 59 + 60 + <script> 61 + add_character_counter('bio', 'bio_chars', @{app.config.user.bio_max_len}) 62 + add_character_counter('pronouns', 'pronouns_chars', @{app.config.user.pronouns_max_len}) 63 + add_character_counter('nickname', 'nickname_chars', @{app.config.user.nickname_max_len}) 64 + </script> 65 + 66 + @if app.config.instance.allow_changing_theme 67 + <hr> 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 84 + 85 + <hr> 86 + 87 + <form action="/api/user/set_username" method="post"> 88 + <label for="new_username">username:</label> 89 + <input 90 + type="text" 91 + name="new_username" 92 + id="new_username" 93 + pattern="@app.config.user.username_pattern" 94 + minlength="@app.config.user.username_min_len" 95 + maxlength="@app.config.user.username_max_len" 96 + value="@{user.username}" 97 + required aria-required 98 + > 99 + <input type="submit" value="save"> 100 + </form> 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 135 + type="password" 136 + name="current_password" 137 + id="current_password" 138 + pattern="@app.config.user.password_pattern" 139 + minlength="@app.config.user.password_min_len" 140 + maxlength="@app.config.user.password_max_len" 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" 149 + id="new_password" 150 + pattern="@app.config.user.password_pattern" 151 + minlength="@app.config.user.password_min_len" 152 + maxlength="@app.config.user.password_max_len" 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" 182 + id="id" 183 + value="@user.id" 184 + required aria-required 185 + readonly aria-readonly 186 + hidden aria-hidden 187 + > 188 + <p><strong>there is NO GOING BACK after deleting your account.</strong></p> 189 + <p><strong>EVERY ONE of your posts, notifications, likes, dislikes, and ALL OTHER USER DATA WILL BE PERMANENTLY DELETED</strong></p> 190 + <div> 191 + <input type="checkbox" name="are-you-sure" id="are-you-sure" required aria-required> 192 + <label for="are-you-sure">click to confirm</label> 193 + </div> 194 + <br> 195 + <div> 196 + <input type="checkbox" name="are-you-really-sure" id="are-you-really-sure" required aria-required> 197 + <label for="are-you-really-sure">click to doubly confirm</label> 198 + </div> 199 + <br> 200 + <div> 201 + <input type="checkbox" name="are-you-absolutely-sure" id="are-you-absolutely-sure" required aria-required> 202 + <label for="are-you-absolutely-sure">click to triply confirm</label> 203 + </div> 204 + <br> 205 + <details> 206 + <summary>(click to reveal deletion button)</summary> 207 + <input type="submit" value="delete your account"> 208 + </details> 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> 219 + @end 220 + 221 + @include 'partial/footer.html'
+11
src/templates/tag.html
··· 1 + @include 'partial/header.html' 2 + 3 + <h1>posts with #@{tag}: (*@{offset} to *@{offset+10})</h1> 4 + 5 + <div> 6 + @for post in app.get_posts_with_tag(tag, offset) 7 + @include 'components/post_mini.html' 8 + @end 9 + </div> 10 + 11 + @include 'partial/footer.html'
+44 -153
src/templates/user.html
··· 8 8 (@viewing.pronouns) 9 9 @end 10 10 11 - @if viewing.muted && viewing.admin 12 - [muted admin, somehow] 13 - @else if viewing.muted 11 + @if viewing.muted 14 12 [muted] 15 - @else if viewing.admin 13 + @end 14 + 15 + @if viewing.automated 16 + [automated] 17 + @end 18 + 19 + @if viewing.admin 16 20 [admin] 17 21 @end 18 22 </h1> 19 23 20 24 @if app.logged_in_as(mut ctx, viewing.id) 21 25 <p>this is you!</p> 22 - 23 - <div> 24 - <form action="/api/post/new_post" method="post"> 25 - <h2>new post:</h2> 26 - <input 27 - type="text" 28 - name="title" 29 - id="title" 30 - minlength="@app.config.post.title_min_len" 31 - maxlength="@app.config.post.title_max_len" 32 - pattern="@app.config.post.title_pattern" 33 - placeholder="title" 34 - required 35 - > 36 - <br> 37 - <textarea 38 - name="body" 39 - id="body" 40 - minlength="@app.config.post.body_min_len" 41 - maxlength="@app.config.post.body_max_len" 42 - rows="10" 43 - cols="30" 44 - placeholder="body" 45 - required 46 - ></textarea> 47 - <br> 48 - <input type="submit" value="post!"> 49 - </form> 50 - </div> 26 + @if !user.automated 27 + @include 'components/new_post.html' 28 + <hr> 29 + @end 51 30 @end 52 31 53 32 @if viewing.bio != '' 54 33 <div> 55 34 <h2>bio:</h2> 56 - <p>@viewing.bio</p> 35 + <pre id="bio">@viewing.bio</pre> 57 36 </div> 37 + <hr> 58 38 @end 59 39 40 + @if app.logged_in_as(mut ctx, viewing.id) 60 41 <div> 61 - <h2>posts:</h2> 62 - @for post in app.get_posts_from_user(viewing.id) 63 - @include 'components/post_small.html' 64 - @end 42 + <p><a href="/me/saved">saved posts</a></p> 43 + <p><a href="/me/saved_for_later">saved for later</a></p> 65 44 </div> 45 + <hr> 46 + @end 66 47 67 48 <div> 68 - <h2>user info:</h2> 69 - <p>id: @viewing.id</p> 70 - <p>username: @viewing.username</p> 71 - <p>display name: @viewing.get_name()</p> 72 - @if app.logged_in_as(mut ctx, viewing.id) 73 - <p><a href="/api/user/logout">log out</a></p> 74 - <p><a href="/api/user/full_logout">log out of all devices</a></p> 49 + <h2>recent posts:</h2> 50 + @if posts.len > 0 51 + @for post in posts 52 + @include 'components/post_small.html' 75 53 @end 76 - </div> 77 - 78 - @if app.logged_in_as(mut ctx, viewing.id) 79 - <div> 80 - <h2>user settings:</h2> 81 - <form action="/api/user/set_bio" method="post"> 82 - <label for="bio">bio:</label> 83 - <br> 84 - <textarea 85 - name="bio" 86 - id="bio" 87 - cols="30" 88 - rows="10" 89 - minlength="@app.config.user.bio_min_len" 90 - maxlength="@app.config.user.bio_max_len" 91 - required 92 - >@user.bio</textarea> 93 - <input type="submit" value="save"> 94 - </form> 95 - <form action="/api/user/set_pronouns" method="post"> 96 - <label for="pronouns">pronouns:</label> 97 - <input 98 - type="text" 99 - name="pronouns" 100 - id="pronouns" 101 - minlength="@app.config.user.pronouns_min_len" 102 - maxlength="@app.config.user.pronouns_max_len" 103 - pattern="@app.config.user.pronouns_pattern" 104 - value="@user.pronouns" 105 - required 106 - > 107 - <br> 108 - <input type="submit" value="save"> 109 - </form> 110 - <form action="/api/user/set_nickname" method="post"> 111 - <label for="nickname">nickname:</label> 112 - <input 113 - type="text" 114 - name="nickname" 115 - id="nickname" 116 - pattern="@app.config.user.nickname_pattern" 117 - minlength="@app.config.user.nickname_min_len" 118 - maxlength="@app.config.user.nickname_max_len" 119 - value="@{user.nickname or { '' }}" 120 - required 121 - > 122 - <input type="submit" value="save"> 123 - </form> 124 - <form action="/api/user/set_nickname" method="post"> 125 - <input type="submit" value="reset nickname"> 126 - </form> 127 - @if app.config.instance.allow_changing_theme 128 - <br> 129 - <form action="/api/user/set_theme" method="post"> 130 - <label for="url">theme:</label> 131 - <input type="url" name="url" id="url" value="@{user.theme or { '' }}"> 132 - <input type="submit" value="save"> 133 - </form> 54 + @else 55 + <p>no posts!</p> 134 56 @end 135 - <br> 136 - <details> 137 - <summary>dangerous settings (click to reveal)</summary> 138 - <div> 139 - <form action="/api/user/delete" autocomplete="off"> 140 - <input 141 - type="number" 142 - name="id" 143 - id="id" 144 - value="@user.id" 145 - required 146 - readonly 147 - hidden 148 - aria-hidden 149 - > 150 - <p><strong>there is NO GOING BACK after deleting your account.</strong></p> 151 - <p><strong>EVERY ONE of your posts, notifications, likes, dislikes, and ALL OTHER USER DATA WILL BE PERMANENTLY DELETED</strong></p> 152 - <div> 153 - <input type="checkbox" name="are-you-sure" id="are-you-sure" required> 154 - <label for="are-you-sure">click to confirm</label> 155 - </div> 156 - <br> 157 - <div> 158 - <input type="checkbox" name="are-you-really-sure" id="are-you-really-sure" required> 159 - <label for="are-you-really-sure">click to doubly confirm</label> 160 - </div> 161 - <br> 162 - <div> 163 - <input type="checkbox" name="are-you-absolutely-sure" id="are-you-absolutely-sure" required> 164 - <label for="are-you-absolutely-sure">click to triply confirm</label> 165 - </div> 166 - <br> 167 - <input type="submit" value="delete your account"> 168 - </form> 169 - </div> 170 - </details> 171 57 </div> 172 - @end 173 58 174 59 @if ctx.is_logged_in() && user.admin 60 + <hr> 61 + 175 62 <div> 176 63 <h2>admin powers:</h2> 177 64 <form action="/api/user/set_muted" method="post"> ··· 180 67 name="id" 181 68 id="id" 182 69 value="@user.id" 183 - required 184 - readonly 185 - hidden 186 - aria-hidden 70 + required aria-required 71 + readonly aria-readonly 72 + hidden aria-hidden 187 73 > 188 74 @if !user.muted 189 75 <input ··· 191 77 name="muted" 192 78 id="muted" 193 79 value="true" 194 - checked 195 - readonly 196 - hidden 197 - aria-hidden 80 + checked aria-checked 81 + readonly aria-readonly 82 + hidden aria-hidden 198 83 > 199 84 <input type="submit" value="mute"> 200 85 @else ··· 203 88 name="muted" 204 89 id="muted" 205 90 value="false" 206 - checked 207 - readonly 208 - hidden 209 - aria-hidden 91 + checked aria-checked 92 + readonly aria-readonly 93 + hidden aria-hidden 210 94 > 211 95 <input type="submit" value="unmute"> 212 96 @end ··· 214 98 </div> 215 99 @end 216 100 217 - @include 'partial/footer.html' 101 + @if viewing.bio != '' 102 + <script src="/static/js/render_body.js"></script> 103 + <script> 104 + render_body('bio') 105 + </script> 106 + @end 107 + 108 + @include 'partial/footer.html'
+21
src/util/none.v
··· 1 + module util 2 + 3 + @[inline] 4 + pub fn map_or[T, R](val ?T, mapper fn (T) R, or_else R) R { 5 + return if val == none { or_else } else { mapper(val) } 6 + } 7 + 8 + @[inline] 9 + pub fn map_or_throw[T, R](val ?T, mapper fn (T) R) R { 10 + return if val == none { panic('value was none: ${val}') } else { mapper(val) } 11 + } 12 + 13 + @[inline] 14 + pub fn map_or_opt[T, R](val ?T, mapper fn (T) ?R, or_else ?R) ?R { 15 + return if val == none { or_else } else { mapper(val) } 16 + } 17 + 18 + @[inline] 19 + pub fn or_throw[T](val ?T) T { 20 + return if val == none { panic('value was none: ${val}') } else { val } 21 + }
+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 + }
-23
src/validation.v
··· 1 - module main 2 - 3 - import regex 4 - 5 - // handles validation of user-input fields 6 - pub struct StringValidator { 7 - pub: 8 - min_len int 9 - max_len int = max_int 10 - pattern regex.RE 11 - } 12 - 13 - @[inline] 14 - pub fn (validator StringValidator) validate(str string) bool { 15 - return str.len > validator.min_len && str.len < validator.max_len 16 - && validator.pattern.matches_string(str) 17 - } 18 - 19 - pub fn StringValidator.new(min int, max int, pattern string) StringValidator { 20 - mut re := regex.new() 21 - re.compile_opt(pattern) or { panic(err) } 22 - return StringValidator{min, max, re} 23 - }
+757
src/webapp/api.v
··· 1 + module webapp 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() 66 + mut user := User{ 67 + username: username 68 + password: auth.hash_password_with_salt(password, salt) 69 + password_salt: salt 70 + } 71 + 72 + if app.config.instance.default_theme != '' { 73 + user.theme = app.config.instance.default_theme 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' 85 + value: token 86 + same_site: .same_site_none_mode 87 + secure: true 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( 176 + name: 'token' 177 + value: token 178 + same_site: .same_site_none_mode 179 + secure: true 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') 199 + } 200 + } else { 201 + eprintln('failed to get token cookie for logout') 202 + } 203 + 204 + ctx.set_cookie( 205 + name: 'token' 206 + value: '' 207 + same_site: .same_site_none_mode 208 + secure: true 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') 224 + } 225 + } else { 226 + eprintln('failed to get token cookie for full_logout') 227 + } 228 + 229 + ctx.set_cookie( 230 + name: 'token' 231 + value: '' 232 + same_site: .same_site_none_mode 233 + secure: true 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()) 247 + if clean_nickname or { '' } == '' { 248 + clean_nickname = none 249 + } 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 { 408 + eprintln('failed to delete tokens for user during deletion: ${id}') 409 + } 410 + // log out 411 + if user.id == id { 412 + ctx.set_cookie( 413 + name: 'token' 414 + value: '' 415 + same_site: .same_site_none_mode 416 + secure: true 417 + path: '/' 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) { 570 + eprintln('user ${user.id} failed to unlike post ${id}') 571 + return ctx.server_error('failed to unlike post') 572 + } 573 + return ctx.ok('unliked post') 574 + } else { 575 + // remove the old dislike, if it exists 576 + if app.does_user_dislike_post(user.id, post.id) { 577 + if !app.unlike_post(post.id, user.id) { 578 + eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 579 + } 580 + } 581 + 582 + like := Like{ 583 + user_id: user.id 584 + post_id: post.id 585 + is_like: true 586 + } 587 + if !app.add_like(like) { 588 + eprintln('user ${user.id} failed to like post ${id}') 589 + return ctx.server_error('failed to like post') 590 + } 591 + return ctx.ok('liked post') 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) { 607 + eprintln('user ${user.id} failed to undislike post ${id}') 608 + return ctx.server_error('failed to undislike post') 609 + } 610 + return ctx.ok('undisliked post') 611 + } else { 612 + // remove the old like, if it exists 613 + if app.does_user_like_post(user.id, post.id) { 614 + if !app.unlike_post(post.id, user.id) { 615 + eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 616 + } 617 + } 618 + 619 + like := Like{ 620 + user_id: user.id 621 + post_id: post.id 622 + is_like: false 623 + } 624 + if !app.add_like(like) { 625 + eprintln('user ${user.id} failed to dislike post ${id}') 626 + return ctx.server_error('failed to dislike post') 627 + } 628 + return ctx.ok('disliked post') 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) { 640 + return ctx.text('toggled save') 641 + } else { 642 + return ctx.server_error('failed to save post') 643 + } 644 + } else { 645 + return ctx.server_error('post does not exist') 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) { 657 + return ctx.text('toggled save') 658 + } else { 659 + return ctx.server_error('failed to save post') 660 + } 661 + } else { 662 + return ctx.server_error('post does not exist') 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 + } 674 + 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 + } 734 + posts := app.search_for_posts(query, limit, offset) 735 + return ctx.json[[]PostSearchResult](posts) 736 + } 737 + 738 + ////// site ////// 739 + 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 + }
+154
src/webapp/app.v
··· 1 + module webapp 2 + 3 + import veb 4 + import db.pg 5 + import regex 6 + import auth 7 + import entity { LikeCache, Like, Post, Site, User, Notification } 8 + import database { DatabaseAccess } 9 + 10 + pub struct App { 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 { 21 + pub mut: 22 + username StringValidator 23 + password StringValidator 24 + nickname StringValidator 25 + pronouns StringValidator 26 + user_bio StringValidator 27 + post_title StringValidator 28 + post_body StringValidator 29 + } 30 + } 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 + } 39 + return app.get_user_by_id(user_token.user_id) 40 + } 41 + 42 + // whoami returns the current logged in user, or none if the user is not logged 43 + // in. 44 + pub fn (app &App) whoami(mut ctx Context) ?User { 45 + token := ctx.get_cookie('token') or { return none }.trim_space() 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 53 + ctx.set_cookie( 54 + name: 'token' 55 + value: '' 56 + same_site: .same_site_none_mode 57 + secure: true 58 + path: '/' 59 + ) 60 + return none 61 + } 62 + return user 63 + } else { 64 + eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)') 65 + // Clear token 66 + ctx.set_cookie( 67 + name: 'token' 68 + value: '' 69 + same_site: .same_site_none_mode 70 + secure: true 71 + path: '/' 72 + ) 73 + return none 74 + } 75 + } 76 + 77 + // logged_in_as returns true if the user is logged in as the provided user id. 78 + pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 79 + if !ctx.is_logged_in() { 80 + return false 81 + } 82 + return app.whoami(mut ctx) or { return false }.id == id 83 + } 84 + 85 + // get_motd returns the site's message of the day. 86 + @[inline] 87 + pub fn (app &App) get_motd() string { 88 + site := app.get_or_create_site_config() 89 + return site.motd 90 + } 91 + 92 + // get_notification_count_for_frontend returns the notification count for a 93 + // given user, formatted for usage on the frontend. 94 + pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 95 + count := app.get_notification_count(user_id, limit) 96 + if count == 0 { 97 + return '' 98 + } else if count > limit { 99 + return ' (${count}+)' 100 + } else { 101 + return ' (${count})' 102 + } 103 + } 104 + 105 + // process_post_mentions parses a post's body to send notifications for mentions 106 + // or replies. 107 + pub fn (app &App) process_post_mentions(post &Post) { 108 + author := app.get_user_by_id(post.author_id) or { 109 + eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') 110 + return 111 + } 112 + author_name := author.get_name() 113 + 114 + // used so we do not send more than one notification per post 115 + mut notified_users := []int{} 116 + 117 + // notify who we replied to, if applicable 118 + if post.replying_to != none { 119 + if x := app.get_post_by_id(post.replying_to) { 120 + app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})') 121 + } 122 + } 123 + 124 + // find mentions 125 + mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 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 { 140 + continue 141 + } 142 + 143 + if user.id in notified_users || user.id == author.id { 144 + continue 145 + } 146 + notified_users << user.id 147 + 148 + app.send_notification_to( 149 + user.id, 150 + '${author_name} mentioned you!', 151 + 'you have been mentioned in this post: *(${post.id})' 152 + ) 153 + } 154 + }
+159
src/webapp/config.v
··· 1 + module webapp 2 + 3 + import emmathemartian.maple 4 + 5 + // Config stores constant site-wide configuration data. 6 + pub struct Config { 7 + pub mut: 8 + dev_mode bool 9 + static_path string 10 + instance struct { 11 + pub mut: 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: 27 + port int 28 + } 29 + postgres struct { 30 + pub mut: 31 + host string 32 + port int 33 + user string 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 46 + title_max_len int 47 + title_pattern string 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: 55 + username_min_len int 56 + username_max_len int 57 + username_pattern string 58 + nickname_min_len int 59 + nickname_max_len int 60 + nickname_pattern string 61 + password_min_len int 62 + password_max_len int 63 + password_pattern string 64 + pronouns_min_len int 65 + pronouns_max_len int 66 + pronouns_pattern string 67 + bio_min_len int 68 + bio_max_len int 69 + bio_pattern string 70 + } 71 + welcome struct { 72 + pub mut: 73 + summary string 74 + body string 75 + } 76 + } 77 + 78 + pub fn load_config_from(file_path string) Config { 79 + loaded := maple.load_file(file_path) or { panic(err) } 80 + mut config := Config{} 81 + 82 + config.dev_mode = loaded.get('dev_mode').to_bool() 83 + config.static_path = loaded.get('static_path').to_str() 84 + 85 + loaded_instance := loaded.get('instance') 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() 101 + 102 + loaded_postgres := loaded.get('postgres') 103 + config.postgres.host = loaded_postgres.get('host').to_str() 104 + config.postgres.port = loaded_postgres.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() 116 + config.post.title_max_len = loaded_post.get('title_max_len').to_int() 117 + config.post.title_pattern = loaded_post.get('title_pattern').to_str() 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() 125 + config.user.username_max_len = loaded_user.get('username_max_len').to_int() 126 + config.user.username_pattern = loaded_user.get('username_pattern').to_str() 127 + config.user.nickname_min_len = loaded_user.get('nickname_min_len').to_int() 128 + config.user.nickname_max_len = loaded_user.get('nickname_max_len').to_int() 129 + config.user.nickname_pattern = loaded_user.get('nickname_pattern').to_str() 130 + config.user.password_min_len = loaded_user.get('password_min_len').to_int() 131 + config.user.password_max_len = loaded_user.get('password_max_len').to_int() 132 + config.user.password_pattern = loaded_user.get('password_pattern').to_str() 133 + config.user.pronouns_min_len = loaded_user.get('pronouns_min_len').to_int() 134 + config.user.pronouns_max_len = loaded_user.get('pronouns_max_len').to_int() 135 + config.user.pronouns_pattern = loaded_user.get('pronouns_pattern').to_str() 136 + config.user.bio_min_len = loaded_user.get('bio_min_len').to_int() 137 + config.user.bio_max_len = loaded_user.get('bio_max_len').to_int() 138 + config.user.bio_pattern = loaded_user.get('bio_pattern').to_str() 139 + 140 + loaded_welcome := loaded.get('welcome') 141 + config.welcome.summary = loaded_welcome.get('summary').to_str() 142 + config.welcome.body = loaded_welcome.get('body').to_str() 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 + }
+18
src/webapp/context.v
··· 1 + module webapp 2 + 3 + import veb 4 + 5 + pub struct Context { 6 + veb.Context 7 + pub mut: 8 + title string 9 + } 10 + 11 + pub fn (ctx &Context) is_logged_in() bool { 12 + return ctx.get_cookie('token') or { '' } != '' 13 + } 14 + 15 + pub fn (mut ctx Context) unauthorized(msg string) veb.Result { 16 + ctx.res.set_status(.unauthorized) 17 + return ctx.send_response_to_client('text/plain', msg) 18 + }
+246
src/webapp/pages.v
··· 1 + module webapp 2 + 3 + import veb 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() 17 + pinned_posts := app.get_pinned_posts() 18 + motd := app.get_motd() 19 + return $veb.html('../templates/index.html') 20 + } 21 + 22 + fn (mut app App) login(mut ctx Context) veb.Result { 23 + ctx.title = 'login to ${app.config.instance.name}' 24 + user := app.whoami(mut ctx) or { User{} } 25 + return $veb.html('../templates/login.html') 26 + } 27 + 28 + fn (mut app App) register(mut ctx Context) veb.Result { 29 + ctx.title = 'register for ${app.config.instance.name}' 30 + user := app.whoami(mut ctx) or { User{} } 31 + return $veb.html('../templates/register.html') 32 + } 33 + 34 + fn (mut app App) me(mut ctx Context) veb.Result { 35 + user := app.whoami(mut ctx) or { 36 + ctx.error('not logged in') 37 + return ctx.redirect('/login') 38 + } 39 + ctx.title = '${app.config.instance.name} - ${user.get_name()}' 40 + return ctx.redirect('/user/${user.username}') 41 + } 42 + 43 + @['/me/saved'] 44 + fn (mut app App) me_saved(mut ctx Context) veb.Result { 45 + user := app.whoami(mut ctx) or { 46 + ctx.error('not logged in') 47 + return ctx.redirect('/login') 48 + } 49 + ctx.title = '${app.config.instance.name} - saved posts' 50 + posts := app.get_saved_posts_as_post_for(user.id) 51 + return $veb.html('../templates/saved_posts.html') 52 + } 53 + 54 + @['/me/saved_for_later'] 55 + fn (mut app App) me_saved_for_later(mut ctx Context) veb.Result { 56 + user := app.whoami(mut ctx) or { 57 + ctx.error('not logged in') 58 + return ctx.redirect('/login') 59 + } 60 + ctx.title = '${app.config.instance.name} - posts saved for later' 61 + posts := app.get_saved_for_later_posts_as_post_for(user.id) 62 + return $veb.html('../templates/saved_posts_for_later.html') 63 + } 64 + 65 + fn (mut app App) settings(mut ctx Context) veb.Result { 66 + user := app.whoami(mut ctx) or { 67 + ctx.error('not logged in') 68 + return ctx.redirect('/login') 69 + } 70 + ctx.title = '${app.config.instance.name} - settings' 71 + return $veb.html('../templates/settings.html') 72 + } 73 + 74 + fn (mut app App) admin(mut ctx Context) veb.Result { 75 + ctx.title = '${app.config.instance.name} dashboard' 76 + user := app.whoami(mut ctx) or { User{} } 77 + return $veb.html('../templates/admin.html') 78 + } 79 + 80 + fn (mut app App) inbox(mut ctx Context) veb.Result { 81 + user := app.whoami(mut ctx) or { 82 + ctx.error('not logged in') 83 + return ctx.redirect('/login') 84 + } 85 + ctx.title = '${app.config.instance.name} inbox' 86 + notifications := app.get_notifications_for(user.id) 87 + return $veb.html('../templates/inbox.html') 88 + } 89 + 90 + fn (mut app App) logout(mut ctx Context) veb.Result { 91 + user := app.whoami(mut ctx) or { 92 + ctx.error('not logged in') 93 + return ctx.redirect('/login') 94 + } 95 + ctx.title = '${app.config.instance.name} logout' 96 + return $veb.html('../templates/logout.html') 97 + } 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') 111 + return ctx.redirect('/') 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('/') 136 + } 137 + ctx.title = '${app.config.instance.name} - ${post.title}' 138 + user := app.whoami(mut ctx) or { User{} } 139 + 140 + mut replying_to_post := app.get_unknown_post() 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 + } 148 + } 149 + 150 + return $veb.html('../templates/post.html') 151 + } 152 + 153 + @['/post/:post_id/edit'] 154 + fn (mut app App) edit(mut ctx Context, post_id int) veb.Result { 155 + user := app.whoami(mut ctx) or { 156 + ctx.error('not logged in') 157 + return ctx.redirect('/login') 158 + } 159 + post := app.get_post_by_id(post_id) or { 160 + ctx.error('no such post') 161 + return ctx.redirect('/') 162 + } 163 + if post.author_id != user.id { 164 + ctx.error('insufficient permissions') 165 + return ctx.redirect('/post/${post_id}') 166 + } 167 + ctx.title = '${app.config.instance.name} - editing ${post.title}' 168 + return $veb.html('../templates/edit.html') 169 + } 170 + 171 + @['/post/:post_id/reply'] 172 + fn (mut app App) reply(mut ctx Context, post_id int) veb.Result { 173 + user := app.whoami(mut ctx) or { 174 + ctx.error('not logged in') 175 + return ctx.redirect('/login') 176 + } 177 + post := app.get_post_by_id(post_id) or { 178 + ctx.error('no such post') 179 + return ctx.redirect('/') 180 + } 181 + ctx.title = '${app.config.instance.name} - reply to ${post.title}' 182 + replying := true 183 + replying_to := post_id 184 + replying_to_user := app.get_user_by_id(post.author_id) or { 185 + ctx.error('no such post') 186 + return ctx.redirect('/') 187 + } 188 + return $veb.html('../templates/new_post.html') 189 + } 190 + 191 + @['/post/new'] 192 + fn (mut app App) new(mut ctx Context) veb.Result { 193 + user := app.whoami(mut ctx) or { 194 + ctx.error('not logged in') 195 + return ctx.redirect('/login') 196 + } 197 + ctx.title = '${app.config.instance.name} - new post' 198 + replying := false 199 + replying_to := 0 200 + replying_to_user := User{} 201 + return $veb.html('../templates/new_post.html') 202 + } 203 + 204 + @['/tag/:tag'] 205 + fn (mut app App) tag(mut ctx Context, tag string) veb.Result { 206 + user := app.whoami(mut ctx) or { 207 + ctx.error('not logged in') 208 + return ctx.redirect('/login') 209 + } 210 + ctx.title = '${app.config.instance.name} - #${tag}' 211 + offset := 0 212 + return $veb.html('../templates/tag.html') 213 + } 214 + 215 + @['/tag/:tag/:offset'] 216 + fn (mut app App) tag_with_offset(mut ctx Context, tag string, offset int) veb.Result { 217 + user := app.whoami(mut ctx) or { 218 + ctx.error('not logged in') 219 + return ctx.redirect('/login') 220 + } 221 + ctx.title = '${app.config.instance.name} - #${tag}' 222 + return $veb.html('../templates/tag.html') 223 + } 224 + 225 + @['/search'] 226 + fn (mut app App) search(mut ctx Context, q string, offset int) veb.Result { 227 + user := app.whoami(mut ctx) or { 228 + ctx.error('not logged in') 229 + return ctx.redirect('/login') 230 + } 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 + }
+39
src/webapp/validation.v
··· 1 + module webapp 2 + 3 + import regex 4 + 5 + // StringValidator handles validation of user-input fields. 6 + pub struct StringValidator { 7 + pub: 8 + min_len int 9 + max_len int = max_int 10 + pattern regex.RE 11 + } 12 + 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 + 33 + // StringValidator.new creates a new StringValidator with the given min, max, 34 + // and pattern. 35 + pub fn StringValidator.new(min int, max int, pattern string) StringValidator { 36 + mut re := regex.new() 37 + re.compile_opt(pattern) or { panic(err) } 38 + return StringValidator{min, max, re} 39 + }
+1 -1
v.mod
··· 1 1 Module { 2 2 name: 'beep' 3 - description: 'A self-hosted mini-blogger' 3 + description: 'a self-hosted mini-blogger' 4 4 version: '1.0.0' 5 5 license: 'MIT' 6 6 author: 'EmmaTheMartian'