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 }
+16
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 | ··· 34 36 | `replying_to` | ?int | id of the post that this post is replying to | 35 37 | `title` | string | the title of this post | 36 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 | 37 41 | `posted_at` | time.Time | a timestamp of when this post was made | 38 42 39 43 ## `Like` ··· 81 85 | `user_id` | int | the user that receives this notification | 82 86 | `summary` | string | the summary for this notification | 83 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/`
+46 -3
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 29 > p.s. when initially writing "planing," i made a typo. it should be "planning." 10 30 > however, i will not be fixing it, because it is funny. 11 31 12 - - [ ] post:images (should have a config.maple toggle to enable/disable) 13 - - [ ] post:saving (add the post to a list of saved posts that a user can view later) 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? 14 45 15 46 ## ideas 16 47 17 48 - [ ] user:per-user post pins 18 49 - could be used as an alternative for a bio to include more information perhaps 50 + - [ ] site:rss feed? 19 51 20 52 ## done 21 53 ··· 32 64 - [x] post:editing 33 65 - [x] post:replies 34 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 35 79 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 36 80 - replaced with per-user optional stylesheets 37 - - [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 + })!
+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;
+17 -1
src/database/database.v
··· 1 + // **all** interactions with the database should be handled in this module. 1 2 module database 2 3 3 4 import db.pg 5 + import entity { User, Post } 4 6 5 - // all interactions with the database should be handled through this struct. 7 + // DatabaseAccess handles all interactions with the database. 6 8 pub struct DatabaseAccess { 7 9 pub mut: 8 10 db pg.DB 9 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 + }
+32 -10
src/database/like.v
··· 1 1 module database 2 2 3 3 import entity { Like, LikeCache } 4 + import util 4 5 5 - // returns the net likes of the given post 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. 6 20 pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int { 7 21 // check cache 8 - cache := sql app.db { 9 - select from LikeCache where post_id == post_id limit 1 10 - } or { [] } 22 + cache := app.db.exec_param('SELECT likes FROM "LikeCache" WHERE post_id = $1 LIMIT 1', post_id.str()) or { [] } 11 23 12 24 mut likes := 0 13 25 14 26 if cache.len != 1 { 15 27 println('calculating net likes for post: ${post_id}') 16 28 // calculate 17 - db_likes := sql app.db { 18 - select from Like where post_id == post_id 19 - } or { [] } 20 - 29 + db_likes := app.db.exec_param('SELECT is_like FROM "Like" WHERE post_id = $1', post_id.str()) or { [] } 21 30 for like in db_likes { 22 - if like.is_like { 31 + if util.or_throw(like.vals[0]).bool() { 23 32 likes++ 24 33 } else { 25 34 likes-- ··· 38 47 return likes 39 48 } 40 49 } else { 41 - likes = cache.first().likes 50 + likes = util.or_throw(cache.first().vals[0]).int() 42 51 } 43 52 44 53 return likes 45 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 + }
+39 -6
src/database/notification.v
··· 2 2 3 3 import entity { Notification } 4 4 5 - // get a list of notifications for the given user 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. 6 40 pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification { 7 41 notifications := sql app.db { 8 42 select from Notification where user_id == user_id ··· 10 44 return notifications 11 45 } 12 46 13 - // get the amount of notifications a user has, with a given limit 47 + // get_notification_count gets the amount of notifications a user has, with a 48 + // given limit. 14 49 pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int { 15 - notifications := sql app.db { 16 - select from Notification where user_id == user_id limit limit 17 - } or { [] } 50 + notifications := app.db.exec_param2('SELECT id FROM "Notification" WHERE user_id = $1 LIMIT $2', user_id.str(), limit.str()) or { [] } 18 51 return notifications.len 19 52 } 20 53 21 - // send a notification to the given user 54 + // send_notification_to sends a notification to the given user. 22 55 pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) { 23 56 notification := Notification{ 24 57 user_id: user_id
+125 -15
src/database/post.v
··· 1 1 module database 2 2 3 3 import time 4 - import entity { Post, Like, LikeCache } 4 + import db.pg 5 + import entity { Post, User, Like, LikeCache } 6 + import util 5 7 6 - // get a post by its id, returns none if it does not exist 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. 7 20 pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post { 8 21 posts := sql app.db { 9 22 select from Post where id == id limit 1 ··· 14 27 return posts[0] 15 28 } 16 29 17 - // get a post by its author and timestamp, returns none if it does not exist 30 + // get_post_by_author_and_timestamp gets a post by its author and timestamp, 31 + // returns none if it does not exist 18 32 pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 19 33 posts := sql app.db { 20 34 select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 ··· 25 39 return posts[0] 26 40 } 27 41 28 - // get a list of posts given a tag. this performs sql string operations and 29 - // probably is not very efficient, use sparingly. 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. 30 45 pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post { 31 46 posts := sql app.db { 32 47 select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset ··· 34 49 return posts 35 50 } 36 51 37 - // returns a list of all pinned posts 52 + // get_pinned_posts returns a list of all pinned posts. 38 53 pub fn (app &DatabaseAccess) get_pinned_posts() []Post { 39 54 posts := sql app.db { 40 55 select from Post where pinned == true ··· 42 57 return posts 43 58 } 44 59 45 - // returns a list of the ten most recent posts. 60 + // get_recent_posts returns a list of the ten most recent posts. 46 61 pub fn (app &DatabaseAccess) get_recent_posts() []Post { 47 62 posts := sql app.db { 48 63 select from Post order by posted_at desc limit 10 ··· 50 65 return posts 51 66 } 52 67 53 - // returns a list of the ten most liked posts. 68 + // get_popular_posts returns a list of the ten most liked posts. 54 69 // TODO: make this time-gated (i.e, top ten liked posts of the day) 55 70 pub fn (app &DatabaseAccess) get_popular_posts() []Post { 56 - cached_likes := sql app.db { 57 - select from LikeCache order by likes desc limit 10 58 - } or { [] } 59 - posts := cached_likes.map(fn [app] (it LikeCache) Post { 60 - return app.get_post_by_id(it.post_id) or { 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 { 61 74 eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)') 62 75 return Post{} 63 76 } ··· 65 78 return posts 66 79 } 67 80 68 - // returns a list of all posts from a user in descending order of date 69 - pub fn (app &DatabaseAccess) get_posts_from_user(user_id int) []Post { 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 { 70 93 posts := sql app.db { 71 94 select from Post where author_id == user_id order by posted_at desc 72 95 } or { [] } 73 96 return posts 74 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 + }
+15 -1
src/database/site.v
··· 3 3 import entity { Site } 4 4 5 5 pub fn (app &DatabaseAccess) get_or_create_site_config() Site { 6 - configs := sql app.db { 6 + mut configs := sql app.db { 7 7 select from Site 8 8 } or { [] } 9 9 if configs.len == 0 { ··· 12 12 sql app.db { 13 13 insert site_config into Site 14 14 } or { panic('failed to create site config (${err})') } 15 + configs = sql app.db { 16 + select from Site 17 + } or { [] } 15 18 } else if configs.len > 1 { 16 19 // this should never happen 17 20 panic('there are multiple site configs') 18 21 } 19 22 return configs[0] 20 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 + }
+113 -35
src/database/user.v
··· 1 1 module database 2 2 3 - import entity { User, Notification, Like, Post } 3 + import entity { User, Notification, Like, LikeCache, Post } 4 + import util 5 + import db.pg 4 6 5 - // creates a new user and returns their struct after creation. 7 + // new_user creates a new user and returns their struct after creation. 6 8 pub fn (app &DatabaseAccess) new_user(user User) ?User { 7 9 sql app.db { 8 10 insert user into User ··· 16 18 return app.get_user_by_name(user.username) 17 19 } 18 20 19 - // updates the given user's username, returns true if this succeeded and false 20 - // otherwise. 21 + // set_username sets the given user's username, returns true if this succeeded 22 + // and false otherwise. 21 23 pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool { 22 24 sql app.db { 23 25 update User set username = new_username where id == user_id ··· 28 30 return true 29 31 } 30 32 31 - // updates the given user's password, returns true if this succeeded and false 32 - // otherwise. 33 + // set_password sets the given user's password, returns true if this succeeded 34 + // and false otherwise. 33 35 pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool { 34 36 sql app.db { 35 37 update User set password = hashed_new_password where id == user_id ··· 40 42 return true 41 43 } 42 44 43 - // updates the given user's nickname, returns true if this succeeded and false 44 - // otherwise. 45 + // set_nickname sets the given user's nickname, returns true if this succeeded 46 + // and false otherwise. 45 47 pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool { 46 48 sql app.db { 47 49 update User set nickname = new_nickname where id == user_id ··· 52 54 return true 53 55 } 54 56 55 - // updates the given user's muted status, returns true if this succeeded and 56 - // false otherwise. 57 + // set_muted sets the given user's muted status, returns true if this succeeded 58 + // and false otherwise. 57 59 pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool { 58 60 sql app.db { 59 61 update User set muted = muted where id == user_id ··· 64 66 return true 65 67 } 66 68 67 - // updates the given user's theme url, returns true if this succeeded and false 68 - // otherwise. 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. 69 83 pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool { 70 84 sql app.db { 71 85 update User set theme = theme where id == user_id ··· 76 90 return true 77 91 } 78 92 79 - // updates the given user's pronouns, returns true if this succeeded and false 80 - // otherwise. 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. 81 107 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool { 82 108 sql app.db { 83 109 update User set pronouns = pronouns where id == user_id ··· 88 114 return true 89 115 } 90 116 91 - // updates the given user's bio, returns true if this succeeded and false 117 + // set_bio sets the given user's bio, returns true if this succeeded and false 92 118 // otherwise. 93 119 pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool { 94 120 sql app.db { ··· 100 126 return true 101 127 } 102 128 103 - // get a user by their username, returns none if the user was not found. 129 + // get_user_by_name gets a user by their username, returns none if the user was 130 + // not found. 104 131 pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User { 105 132 users := sql app.db { 106 133 select from User where username == username ··· 111 138 return users[0] 112 139 } 113 140 114 - // get a user by their id, returns none if the user was not found. 141 + // get_user_by_id gets a user by their id, returns none if the user was not 142 + // found. 115 143 pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User { 116 144 users := sql app.db { 117 145 select from User where id == id ··· 122 150 return users[0] 123 151 } 124 152 125 - // returns all users 153 + // get_users returns all users. 126 154 pub fn (app &DatabaseAccess) get_users() []User { 127 155 users := sql app.db { 128 156 select from User ··· 130 158 return users 131 159 } 132 160 133 - // returns true if a user likes the given post 161 + // does_user_like_post returns true if a user likes the given post. 134 162 pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool { 135 - likes := sql app.db { 136 - select from Like where user_id == user_id && post_id == post_id 137 - } or { [] } 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 { [] } 138 164 if likes.len > 1 { 139 165 // something is very wrong lol 140 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 166 + eprintln('does_user_like_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 141 167 } else if likes.len == 0 { 142 168 return false 143 169 } 144 - return likes.first().is_like 170 + return util.or_throw(likes.first().vals[1]).bool() 145 171 } 146 172 147 - // returns true if a user dislikes the given post 173 + // does_user_dislike_post returns true if a user dislikes the given post. 148 174 pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool { 149 - likes := sql app.db { 150 - select from Like where user_id == user_id && post_id == post_id 151 - } or { [] } 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 { [] } 152 176 if likes.len > 1 { 153 177 // something is very wrong lol 154 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 178 + eprintln('does_user_dislike_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 155 179 } else if likes.len == 0 { 156 180 return false 157 181 } 158 - return !likes.first().is_like 182 + return !util.or_throw(likes.first().vals[1]).bool() 159 183 } 160 184 161 - // returns true if a user likes or dislikes the given post 185 + // does_user_like_or_dislike_post returns true if a user likes *or* dislikes the 186 + // given post. 162 187 pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool { 163 - likes := sql app.db { 164 - select from Like where user_id == user_id && post_id == post_id 165 - } or { [] } 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 { [] } 166 189 if likes.len > 1 { 167 190 // something is very wrong lol 168 - eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})') 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})') 169 192 } 170 193 return likes.len == 1 171 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 }
+34
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: ··· 12 14 body string 13 15 14 16 pinned bool 17 + nsfw bool 15 18 16 19 posted_at time.Time = time.now() 17 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 + }
+86 -35
src/main.v
··· 6 6 import entity 7 7 import os 8 8 import webapp { App, Context, StringValidator } 9 + import beep_sql 10 + import util 11 + 12 + @[inline] 13 + fn connect(mut app App) { 14 + println('-> connecting to database...') 15 + app.db = pg.connect(pg.Config{ 16 + host: app.config.postgres.host 17 + dbname: app.config.postgres.db 18 + user: app.config.postgres.user 19 + password: app.config.postgres.password 20 + port: app.config.postgres.port 21 + }) or { 22 + panic('failed to connect to database: ${err}') 23 + } 24 + } 9 25 10 - fn init_db(db pg.DB) ! { 11 - sql db { 26 + @[inline] 27 + fn init_db(mut app App) { 28 + println('-> initializing database') 29 + sql app.db { 12 30 create table entity.Site 13 31 create table entity.User 14 32 create table entity.Post 15 33 create table entity.Like 16 34 create table entity.LikeCache 17 35 create table entity.Notification 18 - }! 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 19 55 } 20 56 21 57 fn main() { 22 - config := webapp.load_config_from(os.args[1]) 58 + mut stopwatch := util.Stopwatch.new() 59 + 60 + mut app := &App{ 61 + config: if os.args.len > 1 { 62 + webapp.load_config_from(os.args[1]) 63 + } else if os.exists('config.real.maple') { 64 + webapp.load_config_from('config.real.maple') 65 + } else { 66 + panic('no config found nor specified!') 67 + } 68 + buildinfo: if os.exists('buildinfo.maple') { 69 + webapp.load_buildinfo_from('buildinfo.maple') 70 + } else { 71 + webapp.BuildInfo{} 72 + } 73 + } 74 + 75 + // connect to database 76 + util.time_it( 77 + it: fn [mut app] () { 78 + connect(mut app) 79 + } 80 + name: 'connect to db' 81 + log: true 82 + ) 83 + defer { app.db.close() or { panic(err) } } 23 84 24 - println('-> connecting to db...') 25 - mut db := pg.connect(pg.Config{ 26 - host: config.postgres.host 27 - dbname: config.postgres.db 28 - user: config.postgres.user 29 - password: config.postgres.password 30 - port: config.postgres.port 31 - })! 32 - println('<- connected') 85 + // initialize database 86 + util.time_it(it: fn [mut app] () { 87 + init_db(mut app) 88 + }, name: 'init db', log: true) 33 89 34 - defer { 35 - db.close() 36 - } 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 + ) 37 98 38 - mut app := &App{ 39 - config: config 40 - db: db 41 - auth: auth.new(db) 42 - } 99 + // add authenticator 100 + app.auth = auth.new(app.db) 43 101 44 - // vfmt off 45 - app.validators.username = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 46 - app.validators.password = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern) 47 - app.validators.nickname = StringValidator.new(config.user.nickname_min_len, config.user.nickname_max_len, config.user.nickname_pattern) 48 - app.validators.user_bio = StringValidator.new(config.user.bio_min_len, config.user.bio_max_len, config.user.bio_pattern) 49 - app.validators.pronouns = StringValidator.new(config.user.pronouns_min_len, config.user.pronouns_max_len, config.user.pronouns_pattern) 50 - app.validators.post_title = StringValidator.new(config.post.title_min_len, config.post.title_max_len, config.post.title_pattern) 51 - app.validators.post_body = StringValidator.new(config.post.body_min_len, config.post.body_max_len, config.post.body_pattern) 52 - // vfmt on 102 + // load validators 103 + load_validators(mut app) 53 104 105 + // mount static things 54 106 app.mount_static_folder_at(app.config.static_path, '/static')! 55 107 56 - println('-> initializing database...') 57 - init_db(db)! 58 - println('<- done') 59 - 60 108 // make the website config, if it does not exist 61 109 app.get_or_create_site_config() 62 110 63 - if config.dev_mode { 111 + if app.config.dev_mode { 64 112 println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m') 65 113 } 114 + 115 + stop := stopwatch.stop() 116 + println('-> took ${stop} to start app') 66 117 67 118 veb.run[App, Context](mut app, app.config.http.port) 68 119 }
+52
src/static/js/form.js
··· 1 + async function _submit(event, element) 2 + { 3 + event.preventDefault(); 4 + 5 + /* debug */ 6 + console.log(`submitting form:`); 7 + console.log(element) 8 + console.log(`destination: ${element.action}`); 9 + const formdata = new FormData(element); 10 + console.log(`data:`); 11 + console.log(formdata); 12 + 13 + try 14 + { 15 + await fetch(element.action, { 16 + method: "POST", 17 + headers: { 18 + "Content-Type": "application/x-www-form-urlencoded", 19 + }, 20 + body: new URLSearchParams(new FormData(element)), 21 + }).then(async response => { 22 + console.log(response); 23 + const ok = response.status == 200; 24 + const text = await response.text(); 25 + notify(text, ok ? 'ok' : 'error'); /* /static/js/notify.js */ 26 + if (ok) 27 + { 28 + if (element.hasAttribute("beep-redirect")) 29 + window.location.href = element.getAttribute("beep-redirect"); 30 + else if (element.hasAttribute('beep-redirect-js')) 31 + window.location.href = eval(element.getAttribute("beep-redirect-js"))( 32 + response, 33 + text 34 + ); 35 + } 36 + }); 37 + } 38 + catch (error) 39 + { 40 + console.error(error.message); 41 + } 42 + } 43 + 44 + const e = document.getElementsByTagName('form'); 45 + for (let i = 0 ; i < e.length ; i++) 46 + { 47 + const element = e.item(i); 48 + if (element.method == 'post') 49 + { 50 + element.onsubmit = event => _submit(event, element); 51 + } 52 + }
+18
src/static/js/notify.js
··· 1 + const errors = document.getElementById('errors') 2 + 3 + const notify = (msg, level = 'ok') => { 4 + const p = document.createElement('p'); 5 + p.classList.add(level); 6 + 7 + const button = document.createElement('button'); 8 + button.innerText = 'X'; 9 + button.style.display = 'inline'; 10 + button.onclick = () => errors.removeChild(p); 11 + 12 + const span = document.createElement('span'); 13 + span.innerText = `${level != 'ok' ? `${level}: ` : ''}${msg}`; 14 + 15 + p.appendChild(button); 16 + p.appendChild(span); 17 + errors.appendChild(p); 18 + }
+31
src/static/js/password.js
··· 1 + const add_password_checkers = (password_id, confirm_id, match_id) => { 2 + const password = document.getElementById(password_id); 3 + const confirm_password = document.getElementById(confirm_id); 4 + const matches = document.getElementById(match_id); 5 + 6 + const a = () => { 7 + matches.innerText = password.value==confirm_password.value ? "yes" : "no"; 8 + }; 9 + password.addEventListener('input', a); 10 + confirm_password.addEventListener('input', a); 11 + 12 + const view_password = document.getElementById(`view-${password_id}`); 13 + const view_confirm_password = document.getElementById(`view-${confirm_id}`); 14 + 15 + const b = (elm, btn) => { 16 + return _ => { 17 + if (elm.getAttribute('type') == 'password') 18 + { 19 + elm.setAttribute('type', 'text'); 20 + btn.value = 'hide'; 21 + } 22 + else 23 + { 24 + elm.setAttribute('type', 'password') 25 + btn.value = 'show'; 26 + } 27 + }; 28 + }; 29 + view_password.addEventListener('click', b(password, view_password)); 30 + view_confirm_password.addEventListener('click', b(confirm_password, view_confirm_password)); 31 + }
+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 + }
+111 -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 5 60 6 - const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g) 61 + // give the body a loading """animation""" while we let the fetches cook 62 + element.innerText = 'loading...' 63 + 64 + const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g) 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 84 // tags 27 85 else if (match[0][0] == '#') { ··· 33 91 link.href = `/tag/${tag}` 34 92 link.innerText = '#' + tag 35 93 cache[match[0]] = link.outerHTML 36 - element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 94 + html = html.replace(match[0], link.outerHTML) 37 95 } 38 96 // post reference 39 97 else if (match[0][0] == '*') { 40 98 if (cache.hasOwnProperty(match[0])) { 41 - element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]]) 99 + html = html.replace(match[0], cache[match[0]]) 42 100 continue 43 101 } 44 - (await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 45 - if (s == 'no such post') { 46 - return 47 - } 48 - const link = document.createElement('a') 49 - link.href = `/post/${match[0].substring(2, match[0].length - 1)}` 50 - link.innerText = '*' + s 51 - cache[match[0]] = link.outerHTML 52 - element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 53 - }) 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) 54 108 } 55 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}` 137 + } 138 + } 139 + 140 + element.innerHTML = html 56 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>
+5 -4
src/templates/index.html
··· 8 8 9 9 <div> 10 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 13 @for post in pinned_posts 14 14 @include 'components/post_small.html' 15 15 @end 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'
+2 -2
src/templates/login.html
··· 11 11 <p>you are already logged in as @{user.get_name()}!</p> 12 12 <a href="/api/user/logout">log out</a> 13 13 @else 14 - <form action="/api/user/login" method="post"> 14 + <form action="/api/user/login" method="post" beep-redirect="/me"> 15 15 <label for="username">username:</label> 16 16 <input 17 17 type="text" ··· 39 39 @end 40 40 </div> 41 41 42 - @include 'partial/footer.html' 42 + @include 'partial/footer.html'
+1 -52
src/templates/new_post.html
··· 8 8 @else 9 9 <h2>make a post...</h2> 10 10 @end 11 - 12 - <div> 13 - <form action="/api/post/new_post" method="post"> 14 - @if replying 15 - <input 16 - type="number" 17 - name="replying_to" 18 - id="replying_to" 19 - required aria-required 20 - readonly aria-readonly 21 - hidden aria-hidden 22 - value="@replying_to" 23 - > 24 - <input 25 - type="text" 26 - name="title" 27 - id="title" 28 - value="reply to @{replying_to_user.get_name()}" 29 - required aria-required 30 - readonly aria-readonly 31 - hidden aria-hidden 32 - > 33 - @else 34 - <input 35 - type="text" 36 - name="title" 37 - id="title" 38 - minlength="@app.config.post.title_min_len" 39 - maxlength="@app.config.post.title_max_len" 40 - pattern="@app.config.post.title_pattern" 41 - placeholder="title" 42 - required aria-required 43 - > 44 - @end 45 - 46 - <br> 47 - <textarea 48 - name="body" 49 - id="body" 50 - minlength="@app.config.post.body_min_len" 51 - maxlength="@app.config.post.body_max_len" 52 - rows="10" 53 - cols="30" 54 - placeholder="in reply to @{replying_to_user.get_name()}..." 55 - required 56 - ></textarea> 57 - 58 - <br> 59 - 60 - <input type="submit" value="post!"> 61 - </form> 62 - </div> 11 + @include 'components/new_post.html' 63 12 @else 64 13 <p>uh oh, you need to be logged in to see this page</p> 65 14 @end
+5 -3
src/templates/partial/footer.html
··· 1 1 </main> 2 2 3 3 <footer> 4 - @if ctx.is_logged_in() 5 4 <p> 5 + @if ctx.is_logged_in() 6 6 <a href="/settings">settings</a> 7 7 @if user.admin 8 8 - ··· 10 10 @end 11 11 - 12 12 <a href="/logout">log out</a> 13 + - 14 + @end 15 + <a href="/about">about</a> 13 16 </p> 14 - @end 15 17 16 - <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 18 + <p>powered by <a href="https://tangled.org/emmeline.girlkisser.top/beep">beep</a></p> 17 19 </footer> 18 20 19 21 </body>
+22 -13
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="viewport" content="width=device-width, initial-scale=1" /> 6 7 <meta name="description" content="" /> 7 - <link rel="icon" href="/favicon.png" /> 8 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 8 + 9 9 <title>@ctx.title</title> 10 10 11 11 @include 'assets/style.html' 12 12 13 - @if ctx.is_logged_in() && user.theme != none 14 - <link rel="stylesheet" href="@user.get_theme()"> 13 + @if ctx.is_logged_in() && user.theme != '' 14 + <link rel="stylesheet" href="@user.theme"> 15 15 @else if app.config.instance.default_theme != '' 16 16 <link rel="stylesheet" href="@app.config.instance.default_theme"> 17 17 @endif 18 18 19 19 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 20 + 21 + @if ctx.is_logged_in() && user.css != '' 22 + <style>@{user.css}</style> 23 + @else 24 + <style>@{app.config.instance.default_css}</style> 25 + @end 26 + 27 + <script src="/static/js/notify.js" defer></script> 28 + <script src="/static/js/form.js" defer></script> 20 29 </head> 21 30 22 31 <body> 23 32 24 33 <header> 34 + @if ctx.is_logged_in() 35 + <a href="/me">@@@user.get_name()</a> 36 + - 37 + @end 38 + 25 39 @if app.config.dev_mode 26 40 <span><strong>dev mode</strong></span> 27 41 - ··· 31 45 - 32 46 33 47 @if ctx.is_logged_in() 34 - <a href="/me">profile</a> 48 + <a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a> 35 49 - 36 - <a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a> 50 + <a href="/search">search</a> 37 51 @else 38 52 <a href="/login">log in</a> 39 53 <span>or</span> ··· 42 56 </header> 43 57 44 58 <main> 45 - <!-- TODO: fix this lol --> 46 - @if ctx.form_error != '' 47 - <div> 48 - <p><strong>error:</strong> @ctx.form_error</p> 49 - </div> 50 - @end 59 + <div id="errors"></div>
+65 -39
src/templates/post.html
··· 12 12 @else 13 13 replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a> 14 14 @end 15 + @if post.nsfw 16 + <span class="nsfw-indicator">(<em>nsfw</em>)</span> 17 + @end 15 18 </h2> 19 + 20 + <hr> 21 + 22 + @if post.nsfw 23 + <details> 24 + <summary>click to show post (nsfw)</summary> 25 + <pre id="post-@{post.id}">@post.body</pre> 26 + </details> 27 + @else 16 28 <pre id="post-@{post.id}">@post.body</pre> 29 + @end 30 + 31 + <hr> 32 + 17 33 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 18 34 <p><em>posted at: @post.posted_at</em></p> 19 35 20 - @if ctx.is_logged_in() 36 + @if ctx.is_logged_in() && !user.automated 37 + <br> 21 38 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 - @end 23 - 24 - @if ctx.is_logged_in() && post.author_id == user.id 25 - <p><a href="/post/@{post.id}/edit">edit post</a></p> 26 - @end 27 - 28 - @if ctx.is_logged_in() 29 39 <br> 30 40 <div> 31 41 <button onclick="like(@post.id)"> ··· 42 52 dislike 43 53 @end 44 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> 45 69 </div> 46 70 @end 47 71 ··· 51 75 52 76 @if post.author_id == user.id 53 77 <h4>manage post:</h4> 54 - @else if user.admin 55 - <h4>admin powers:</h4> 56 - @end 57 78 58 - <form action="/api/post/delete" method="post"> 59 - <input 60 - type="number" 61 - name="id" 62 - id="id" 63 - placeholder="post id" 64 - value="@post.id" 65 - required 66 - readonly 67 - hidden 68 - aria-hidden 69 - > 70 - <input type="submit" value="delete"> 71 - </form> 79 + <p><a href="/post/@{post.id}/edit">edit</a></p> 80 + @end 72 81 73 82 @if user.admin 74 - <form action="/api/post/pin" method="post"> 75 - <input 76 - type="number" 77 - name="id" 78 - id="id" 79 - placeholder="post id" 80 - value="@post.id" 81 - required 82 - readonly 83 - hidden 84 - aria-hidden 85 - > 86 - <input type="submit" value="pin"> 87 - </form> 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> 88 114 @end 89 115 90 116 </div>
+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'
+68 -10
src/templates/settings.html
··· 1 1 @include 'partial/header.html' 2 2 3 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 + 4 7 <h1>user settings:</h1> 5 8 6 9 <form action="/api/user/set_bio" method="post"> 7 - <label for="bio">bio:</label> 10 + <label for="bio">bio: (<span id="bio_chars">0/@{app.config.user.bio_max_len}</span>)</label> 8 11 <br> 9 12 <textarea 10 13 name="bio" ··· 13 16 rows="10" 14 17 minlength="@app.config.user.bio_min_len" 15 18 maxlength="@app.config.user.bio_max_len" 16 - required aria-required 17 19 >@user.bio</textarea> 18 20 <br> 19 21 <input type="submit" value="save"> ··· 22 24 <hr> 23 25 24 26 <form action="/api/user/set_pronouns" method="post"> 25 - <label for="pronouns">pronouns:</label> 27 + <label for="pronouns">pronouns: (<span id="pronouns_chars">0/@{app.config.user.pronouns_max_len}</span>)</label> 26 28 <input 27 29 type="text" 28 30 name="pronouns" ··· 31 33 maxlength="@app.config.user.pronouns_max_len" 32 34 pattern="@app.config.user.pronouns_pattern" 33 35 value="@user.pronouns" 34 - required aria-required 35 36 > 36 37 <input type="submit" value="save"> 37 38 </form> ··· 39 40 <hr> 40 41 41 42 <form action="/api/user/set_nickname" method="post"> 42 - <label for="nickname">nickname:</label> 43 + <label for="nickname">nickname: (<span id="nickname_chars">0/@{app.config.user.nickname_max_len}</span>)</label> 43 44 <input 44 45 type="text" 45 46 name="nickname" ··· 48 49 minlength="@app.config.user.nickname_min_len" 49 50 maxlength="@app.config.user.nickname_max_len" 50 51 value="@{user.nickname or { '' }}" 51 - required aria-required 52 52 > 53 53 <input type="submit" value="save"> 54 54 </form> ··· 57 57 <input type="submit" value="reset nickname"> 58 58 </form> 59 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 + 60 66 @if app.config.instance.allow_changing_theme 61 67 <hr> 62 68 63 69 <form action="/api/user/set_theme" method="post"> 64 70 <label for="url">theme:</label> 65 - <input type="url" name="url" id="url" value="@{user.theme or { '' }}"> 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> 66 81 <input type="submit" value="save"> 67 82 </form> 68 83 @end ··· 86 101 87 102 <hr> 88 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 + 89 124 <details> 90 125 <summary>dangerous settings (click to reveal)</summary> 91 126 127 + <br> 128 + 92 129 <details> 93 130 <summary>change password (click to reveal)</summary> 94 - <form action="/api/user/set_password" method="post"> 131 + <form action="/api/user/set_password" method="post" beep-redirect="/login"> 95 132 <p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p> 96 133 <label for="current_password">current password:</label> 97 134 <input ··· 104 141 required aria-required 105 142 autocomplete="off" aria-autocomplete="off" 106 143 > 107 - <label for="new_password">new password:</label> 144 + <br> 145 + <label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label> 108 146 <input 109 147 type="password" 110 148 name="new_password" ··· 115 153 required aria-required 116 154 autocomplete="off" aria-autocomplete="off" 117 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> 118 170 <input type="submit" value="save"> 119 171 </form> 120 172 </details> 121 173 174 + <br> 175 + 122 176 <details> 123 177 <summary>account deletion (click to reveal)</summary> 124 - <form action="/api/user/delete" autocomplete="off"> 178 + <form action="/api/user/delete" autocomplete="off" beep-redirect="/"> 125 179 <input 126 180 type="number" 127 181 name="id" ··· 155 209 </form> 156 210 </details> 157 211 </details> 212 + 213 + <script> 214 + add_password_checkers('new_password', 'confirm_password', 'passwords-match'); 215 + </script> 158 216 159 217 @else 160 218 <p>uh oh, you need to be logged in to view this page!</p>
+29 -38
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> 26 + @if !user.automated 27 + @include 'components/new_post.html' 28 + <hr> 29 + @end 30 + @end 22 31 32 + @if viewing.bio != '' 23 33 <div> 24 - <form action="/api/post/new_post" method="post"> 25 - <h2>new post:</h2> 26 - 27 - <input 28 - type="text" 29 - name="title" 30 - id="title" 31 - minlength="@app.config.post.title_min_len" 32 - maxlength="@app.config.post.title_max_len" 33 - pattern="@app.config.post.title_pattern" 34 - placeholder="title" 35 - required aria-required 36 - > 37 - <br> 38 - 39 - <textarea 40 - name="body" 41 - id="body" 42 - minlength="@app.config.post.body_min_len" 43 - maxlength="@app.config.post.body_max_len" 44 - rows="10" 45 - cols="30" 46 - placeholder="body" 47 - required aria-required 48 - ></textarea> 49 - <br> 50 - 51 - <input type="submit" value="post!"> 52 - </form> 34 + <h2>bio:</h2> 35 + <pre id="bio">@viewing.bio</pre> 53 36 </div> 37 + <hr> 54 38 @end 55 39 56 - @if viewing.bio != '' 40 + @if app.logged_in_as(mut ctx, viewing.id) 57 41 <div> 58 - <h2>bio:</h2> 59 - <pre id="bio">@viewing.bio</pre> 42 + <p><a href="/me/saved">saved posts</a></p> 43 + <p><a href="/me/saved_for_later">saved for later</a></p> 60 44 </div> 45 + <hr> 61 46 @end 62 47 63 48 <div> 64 - <h2>posts:</h2> 65 - @for post in app.get_posts_from_user(viewing.id) 49 + <h2>recent posts:</h2> 50 + @if posts.len > 0 51 + @for post in posts 66 52 @include 'components/post_small.html' 67 53 @end 54 + @else 55 + <p>no posts!</p> 56 + @end 68 57 </div> 69 58 70 59 @if ctx.is_logged_in() && user.admin 60 + <hr> 61 + 71 62 <div> 72 63 <h2>admin powers:</h2> 73 64 <form action="/api/user/set_muted" method="post">
+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 + }
+341 -259
src/webapp/api.v
··· 2 2 3 3 import veb 4 4 import auth 5 - import entity { Like, LikeCache, Post, Site, User, Notification } 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!' 6 14 7 15 ////// user ////// 8 16 17 + struct HcaptchaResponse { 18 + pub: 19 + success bool 20 + error_codes []string @[json: 'error-codes'] 21 + } 22 + 9 23 @['/api/user/register'; post] 10 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 + 11 47 if app.get_user_by_name(username) != none { 12 - ctx.error('username taken') 13 - return ctx.redirect('/register') 48 + return ctx.server_error('username taken') 14 49 } 15 50 16 51 // validate username 17 52 if !app.validators.username.validate(username) { 18 - ctx.error('invalid username') 19 - return ctx.redirect('/register') 53 + return ctx.server_error('invalid username') 20 54 } 21 55 22 56 // validate password 23 57 if !app.validators.password.validate(password) { 24 - ctx.error('invalid password') 25 - return ctx.redirect('/register') 58 + return ctx.server_error('invalid password') 59 + } 60 + 61 + if password != ctx.form['confirm-password'] { 62 + return ctx.server_error('passwords do not match') 26 63 } 27 64 28 65 salt := auth.generate_salt() ··· 37 74 } 38 75 39 76 if x := app.new_user(user) { 40 - app.send_notification_to( 41 - x.id, 42 - app.config.welcome.summary.replace('%s', x.get_name()), 43 - app.config.welcome.body.replace('%s', x.get_name()) 44 - ) 45 - token := app.auth.add_token(x.id, ctx.ip()) or { 46 - eprintln(err) 47 - ctx.error('could not create token for user with id ${x.id}') 48 - return ctx.redirect('/') 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') 49 82 } 50 83 ctx.set_cookie( 51 84 name: 'token' ··· 55 88 path: '/' 56 89 ) 57 90 } else { 58 - eprintln('could not log into newly-created user: ${user}') 59 - ctx.error('could not log into newly-created user.') 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.') 60 93 } 61 94 62 - return ctx.redirect('/') 95 + return ctx.ok('user registered') 63 96 } 64 97 65 98 @['/api/user/set_username'; post] 66 99 fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result { 67 100 user := app.whoami(mut ctx) or { 68 - ctx.error('you are not logged in!') 69 - return ctx.redirect('/login') 101 + return ctx.unauthorized(not_logged_in_msg) 70 102 } 71 103 72 104 if app.get_user_by_name(new_username) != none { 73 - ctx.error('username taken') 74 - return ctx.redirect('/settings') 105 + return ctx.server_error('username taken') 75 106 } 76 107 77 108 // validate username 78 109 if !app.validators.username.validate(new_username) { 79 - ctx.error('invalid username') 80 - return ctx.redirect('/settings') 110 + return ctx.server_error('invalid username') 81 111 } 82 112 83 113 if !app.set_username(user.id, new_username) { 84 - ctx.error('failed to update username') 114 + return ctx.server_error('failed to update username') 85 115 } 86 116 87 - return ctx.redirect('/settings') 117 + return ctx.ok('username updated') 88 118 } 89 119 90 120 @['/api/user/set_password'; post] 91 121 fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result { 92 122 user := app.whoami(mut ctx) or { 93 - ctx.error('you are not logged in!') 94 - return ctx.redirect('/login') 123 + return ctx.unauthorized(not_logged_in_msg) 95 124 } 96 125 97 126 if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) { 98 - ctx.error('current_password is incorrect') 99 - return ctx.redirect('/settings') 127 + return ctx.server_error('current_password is incorrect') 100 128 } 101 129 102 130 // validate password 103 131 if !app.validators.password.validate(new_password) { 104 - ctx.error('invalid password') 105 - return ctx.redirect('/settings') 132 + return ctx.server_error('invalid password') 106 133 } 107 134 108 - // invalidate tokens 109 - app.auth.delete_tokens_for_user(user.id) or { 110 - eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})') 111 - return ctx.redirect('/settings') 135 + if new_password != ctx.form['confirm_password'] { 136 + return ctx.server_error('passwords do not match') 112 137 } 113 138 114 139 hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt) 115 - 116 140 if !app.set_password(user.id, hashed_new_password) { 117 - ctx.error('failed to update password') 141 + return ctx.server_error('failed to update password') 118 142 } 119 143 120 - return ctx.redirect('/login') 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') 121 158 } 122 159 123 160 @['/api/user/login'; post] 124 161 fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result { 125 162 user := app.get_user_by_name(username) or { 126 - ctx.error('invalid credentials') 127 - return ctx.redirect('/login') 163 + return ctx.server_error('invalid credentials') 128 164 } 129 165 130 166 if !auth.compare_password_with_hash(password, user.password_salt, user.password) { 131 - ctx.error('invalid credentials') 132 - return ctx.redirect('/login') 167 + return ctx.server_error('invalid credentials') 133 168 } 134 169 135 - token := app.auth.add_token(user.id, ctx.ip()) or { 170 + token := app.auth.add_token(user.id) or { 136 171 eprintln('failed to add token on log in: ${err}') 137 - ctx.error('could not create token for user with id ${user.id}') 138 - return ctx.redirect('/login') 172 + return ctx.server_error('could not create token for user with id ${user.id}') 139 173 } 140 174 141 175 ctx.set_cookie( ··· 146 180 path: '/' 147 181 ) 148 182 149 - return ctx.redirect('/') 183 + return ctx.ok('logged in') 150 184 } 151 185 152 - @['/api/user/logout'] 186 + @['/api/user/logout'; post] 153 187 fn (mut app App) api_user_logout(mut ctx Context) veb.Result { 154 188 if token := ctx.get_cookie('token') { 155 - if user := app.get_user_by_token(ctx, token) { 156 - app.auth.delete_tokens_for_ip(ctx.ip()) or { 189 + if user := app.get_user_by_token(token) { 190 + // app.auth.delete_tokens_for_ip(ctx.ip()) or { 191 + // eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 192 + // return ctx.redirect('/login') 193 + // } 194 + app.auth.delete_tokens_for_value(token) or { 157 195 eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})') 158 - return ctx.redirect('/login') 159 196 } 160 197 } else { 161 198 eprintln('failed to get user for token for logout') ··· 172 209 path: '/' 173 210 ) 174 211 175 - return ctx.redirect('/login') 212 + return ctx.ok('logged out') 176 213 } 177 214 178 - @['/api/user/full_logout'] 215 + @['/api/user/full_logout'; post] 179 216 fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result { 180 217 if token := ctx.get_cookie('token') { 181 - if user := app.get_user_by_token(ctx, token) { 218 + if user := app.get_user_by_token(token) { 182 219 app.auth.delete_tokens_for_user(user.id) or { 183 220 eprintln('failed to yeet tokens for ${user.id}') 184 - return ctx.redirect('/login') 185 221 } 186 222 } else { 187 223 eprintln('failed to get user for token for full_logout') ··· 198 234 path: '/' 199 235 ) 200 236 201 - return ctx.redirect('/login') 237 + return ctx.ok('logged out') 202 238 } 203 239 204 240 @['/api/user/set_nickname'; post] 205 241 fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result { 206 242 user := app.whoami(mut ctx) or { 207 - ctx.error('you are not logged in!') 208 - return ctx.redirect('/login') 243 + return ctx.unauthorized(not_logged_in_msg) 209 244 } 210 245 211 246 mut clean_nickname := ?string(nickname.trim_space()) ··· 215 250 216 251 // validate 217 252 if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) { 218 - ctx.error('invalid nickname') 219 - return ctx.redirect('/me') 253 + return ctx.server_error('invalid nickname') 220 254 } 221 255 222 256 if !app.set_nickname(user.id, clean_nickname) { 223 257 eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})') 224 - return ctx.redirect('/me') 258 + return ctx.server_error('failed to update nickname') 225 259 } 226 260 227 - return ctx.redirect('/me') 261 + return ctx.ok('updated nickname') 228 262 } 229 263 230 264 @['/api/user/set_muted'; post] 231 265 fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result { 232 266 user := app.whoami(mut ctx) or { 233 - ctx.error('you are not logged in!') 234 - return ctx.redirect('/login') 267 + return ctx.unauthorized(not_logged_in_msg) 235 268 } 236 269 237 270 to_mute := app.get_user_by_id(id) or { 238 - ctx.error('no such user') 239 - return ctx.redirect('/') 271 + return ctx.server_error('no such user') 240 272 } 241 273 242 274 if user.admin { 243 275 if !app.set_muted(to_mute.id, muted) { 244 - ctx.error('failed to change mute status') 245 - return ctx.redirect('/user/${to_mute.username}') 276 + return ctx.server_error('failed to change mute status') 246 277 } 247 - return ctx.redirect('/user/${to_mute.username}') 278 + return ctx.ok('muted user') 248 279 } else { 249 - ctx.error('insufficient permissions!') 250 280 eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})') 251 - return ctx.redirect('/user/${to_mute.username}') 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 :(') 252 299 } 253 300 } 254 301 255 302 @['/api/user/set_theme'; post] 256 303 fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result { 257 304 if !app.config.instance.allow_changing_theme { 258 - ctx.error('this instance disallows changing themes :(') 259 - return ctx.redirect('/me') 305 + return ctx.server_error('this instance disallows changing themes :(') 260 306 } 261 307 262 308 user := app.whoami(mut ctx) or { 263 - ctx.error('you are not logged in!') 264 - return ctx.redirect('/login') 309 + return ctx.unauthorized(not_logged_in_msg) 265 310 } 266 311 267 312 mut theme := ?string(none) 268 - if url.trim_space() != '' { 313 + if url.trim_space() == '' { 314 + theme = app.config.instance.default_theme 315 + } else { 269 316 theme = url.trim_space() 270 317 } 271 318 272 319 if !app.set_theme(user.id, theme) { 273 - ctx.error('failed to change theme') 274 - return ctx.redirect('/me') 320 + return ctx.server_error('failed to change theme') 321 + } 322 + 323 + return ctx.ok('theme updated') 324 + } 325 + 326 + @['/api/user/set_css'; post] 327 + fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { 328 + if !app.config.instance.allow_changing_theme { 329 + return ctx.server_error('this instance disallows changing themes :(') 275 330 } 276 331 277 - return ctx.redirect('/me') 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') 278 343 } 279 344 280 345 @['/api/user/set_pronouns'; post] 281 346 fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result { 282 347 user := app.whoami(mut ctx) or { 283 - ctx.error('you are not logged in!') 284 - return ctx.redirect('/login') 348 + return ctx.unauthorized(not_logged_in_msg) 285 349 } 286 350 287 351 clean_pronouns := pronouns.trim_space() 288 352 if !app.validators.pronouns.validate(clean_pronouns) { 289 - ctx.error('invalid pronouns') 290 - return ctx.redirect('/me') 353 + return ctx.server_error('invalid pronouns') 291 354 } 292 355 293 356 if !app.set_pronouns(user.id, clean_pronouns) { 294 - ctx.error('failed to change pronouns') 295 - return ctx.redirect('/me') 357 + return ctx.server_error('failed to change pronouns') 296 358 } 297 359 298 - return ctx.redirect('/me') 360 + return ctx.ok('pronouns updated') 299 361 } 300 362 301 363 @['/api/user/set_bio'; post] 302 364 fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result { 303 365 user := app.whoami(mut ctx) or { 304 - ctx.error('you are not logged in!') 305 - return ctx.redirect('/login') 366 + return ctx.unauthorized(not_logged_in_msg) 306 367 } 307 368 308 369 clean_bio := bio.trim_space() 309 370 if !app.validators.user_bio.validate(clean_bio) { 310 - ctx.error('invalid bio') 311 - return ctx.redirect('/me') 371 + return ctx.server_error('invalid bio') 312 372 } 313 373 314 374 if !app.set_bio(user.id, clean_bio) { 315 375 eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})') 316 - return ctx.redirect('/me') 376 + return ctx.server_error('failed to update bio') 317 377 } 318 378 319 - return ctx.redirect('/me') 379 + return ctx.ok('bio updated') 320 380 } 321 381 322 - @['/api/user/get_name'] 382 + @['/api/user/get_name'; get] 323 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 + } 324 389 user := app.get_user_by_name(username) or { return ctx.server_error('no such user') } 325 390 return ctx.text(user.get_name()) 326 391 } 327 392 328 - /// user/notification /// 329 - 330 - @['/api/user/notification/clear'] 331 - fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 332 - if !ctx.is_logged_in() { 333 - ctx.error('you are not logged in!') 334 - return ctx.redirect('/login') 335 - } 336 - sql app.db { 337 - delete from Notification where id == id 338 - } or { 339 - ctx.error('failed to delete notification') 340 - return ctx.redirect('/inbox') 341 - } 342 - return ctx.redirect('/inbox') 343 - } 344 - 345 - @['/api/user/notification/clear_all'] 346 - fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 347 - user := app.whoami(mut ctx) or { 348 - ctx.error('you are not logged in!') 349 - return ctx.redirect('/login') 350 - } 351 - sql app.db { 352 - delete from Notification where user_id == user.id 353 - } or { 354 - ctx.error('failed to delete notifications') 355 - return ctx.redirect('/inbox') 356 - } 357 - return ctx.redirect('/inbox') 358 - } 359 - 360 - @['/api/user/delete'] 393 + @['/api/user/delete'; post] 361 394 fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result { 362 395 user := app.whoami(mut ctx) or { 363 - ctx.error('you are not logged in!') 364 - return ctx.redirect('/login') 396 + return ctx.unauthorized(not_logged_in_msg) 365 397 } 366 398 367 - println('attempting to delete ${id} as ${user.id}') 368 - 369 399 if user.admin || user.id == id { 370 - // yeet 371 - sql app.db { 372 - delete from User where id == id 373 - delete from Like where user_id == id 374 - delete from Notification where user_id == id 375 - } or { 376 - ctx.error('failed to delete user: ${id}') 377 - return ctx.redirect('/') 378 - } 379 - 380 - // delete posts and their likes 381 - posts_from_this_user := sql app.db { 382 - select from Post where author_id == id 383 - } or { [] } 400 + println('attempting to delete ${id} as ${user.id}') 384 401 385 - for post in posts_from_this_user { 386 - sql app.db { 387 - delete from Like where post_id == post.id 388 - delete from LikeCache where post_id == post.id 389 - } or { 390 - eprintln('failed to delete like cache for post during user deletion: ${post.id}') 391 - } 392 - } 393 - 394 - sql app.db { 395 - delete from Post where author_id == id 396 - } or { 397 - eprintln('failed to delete posts by deleting user: ${user.id}') 402 + // yeet 403 + if !app.delete_user(user.id) { 404 + return ctx.server_error('failed to delete user: ${id}') 398 405 } 399 406 400 407 app.auth.delete_tokens_for_user(id) or { ··· 411 418 ) 412 419 } 413 420 println('deleted user ${id}') 421 + return ctx.ok('user deleted') 414 422 } else { 415 - ctx.error('be nice. deleting other users is off-limits.') 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') 416 461 } 417 462 418 - return ctx.redirect('/') 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') 419 475 } 420 476 421 477 ////// post ////// ··· 423 479 @['/api/post/new_post'; post] 424 480 fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 425 481 user := app.whoami(mut ctx) or { 426 - ctx.error('not logged in!') 427 - return ctx.redirect('/login') 482 + return ctx.unauthorized(not_logged_in_msg) 428 483 } 429 484 430 485 if user.muted { 431 - ctx.error('you are muted!') 432 - return ctx.redirect('/post/new') 486 + return ctx.server_error('you are muted!') 433 487 } 434 488 435 489 // validate title 436 490 if !app.validators.post_title.validate(title) { 437 - ctx.error('invalid title') 438 - return ctx.redirect('/post/new') 491 + return ctx.server_error('invalid title') 439 492 } 440 493 441 494 // validate body 442 495 if !app.validators.post_body.validate(body) { 443 - ctx.error('invalid body') 444 - return ctx.redirect('/post/new') 496 + return ctx.server_error('invalid body') 497 + } 498 + 499 + nsfw := 'nsfw' in ctx.form 500 + if nsfw && !app.config.post.allow_nsfw { 501 + return ctx.server_error('nsfw posts are not allowed on this instance') 445 502 } 446 503 447 504 mut post := Post{ 448 505 author_id: user.id 449 506 title: title 450 507 body: body 508 + nsfw: nsfw 451 509 } 452 510 453 511 if replying_to != 0 { 454 512 // check if replying post exists 455 513 app.get_post_by_id(replying_to) or { 456 - ctx.error('the post you are trying to reply to does not exist') 457 - return ctx.redirect('/post/new') 514 + return ctx.server_error('the post you are trying to reply to does not exist') 458 515 } 459 516 post.replying_to = replying_to 460 517 } 461 518 462 - sql app.db { 463 - insert post into Post 464 - } or { 465 - ctx.error('failed to post!') 519 + if !app.add_post(post) { 466 520 println('failed to post: ${post} from user ${user.id}') 467 - return ctx.redirect('/post/new') 521 + return ctx.server_error('failed to post') 468 522 } 469 523 524 + //TODO: Can I not just get the ID directly?? This method feels dicey at best. 470 525 // find the post's id to process mentions with 471 526 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 472 527 app.process_post_mentions(x) 473 - return ctx.redirect('/post/${x.id}') 528 + return ctx.ok('posted. id=${x.id}') 474 529 } else { 475 - ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 476 - return ctx.redirect('/me') 530 + eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}') 531 + return ctx.server_error('failed to get post ID, this error should never happen') 477 532 } 478 533 } 479 534 480 535 @['/api/post/delete'; post] 481 536 fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result { 482 537 user := app.whoami(mut ctx) or { 483 - ctx.error('not logged in!') 484 - return ctx.redirect('/login') 538 + return ctx.unauthorized(not_logged_in_msg) 485 539 } 486 540 487 541 post := app.get_post_by_id(id) or { 488 - ctx.error('post does not exist') 489 - return ctx.redirect('/') 542 + return ctx.server_error('post does not exist') 490 543 } 491 544 492 545 if user.admin || user.id == post.author_id { 493 - sql app.db { 494 - delete from Post where id == id 495 - delete from Like where post_id == id 496 - } or { 497 - ctx.error('failed to delete post') 498 - eprintln('failed to delete post: ${id}') 499 - return ctx.redirect('/') 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') 500 549 } 501 550 println('deleted post: ${id}') 502 - return ctx.redirect('/') 551 + return ctx.ok('post deleted') 503 552 } else { 504 - ctx.error('insufficient permissions!') 505 553 eprintln('insufficient perms to delete post: ${id} (${user.id})') 506 - return ctx.redirect('/') 554 + return ctx.unauthorized('insufficient permissions') 507 555 } 508 556 } 509 557 510 - @['/api/post/like'] 558 + @['/api/post/like'; post] 511 559 fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result { 512 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 560 + user := app.whoami(mut ctx) or { 561 + return ctx.unauthorized(not_logged_in_msg) 562 + } 513 563 514 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 564 + post := app.get_post_by_id(id) or { 565 + return ctx.server_error('post does not exist') 566 + } 515 567 516 568 if app.does_user_like_post(user.id, post.id) { 517 - sql app.db { 518 - delete from Like where user_id == user.id && post_id == post.id 519 - // yeet the old cached like value 520 - delete from LikeCache where post_id == post.id 521 - } or { 569 + if !app.unlike_post(post.id, user.id) { 522 570 eprintln('user ${user.id} failed to unlike post ${id}') 523 571 return ctx.server_error('failed to unlike post') 524 572 } ··· 526 574 } else { 527 575 // remove the old dislike, if it exists 528 576 if app.does_user_dislike_post(user.id, post.id) { 529 - sql app.db { 530 - delete from Like where user_id == user.id && post_id == post.id 531 - } or { 577 + if !app.unlike_post(post.id, user.id) { 532 578 eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it') 533 579 } 534 580 } ··· 538 584 post_id: post.id 539 585 is_like: true 540 586 } 541 - sql app.db { 542 - insert like into Like 543 - // yeet the old cached like value 544 - delete from LikeCache where post_id == post.id 545 - } or { 587 + if !app.add_like(like) { 546 588 eprintln('user ${user.id} failed to like post ${id}') 547 589 return ctx.server_error('failed to like post') 548 590 } ··· 550 592 } 551 593 } 552 594 553 - @['/api/post/dislike'] 595 + @['/api/post/dislike'; post] 554 596 fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result { 555 - user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 597 + user := app.whoami(mut ctx) or { 598 + return ctx.unauthorized(not_logged_in_msg) 599 + } 556 600 557 - post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') } 601 + post := app.get_post_by_id(id) or { 602 + return ctx.server_error('post does not exist') 603 + } 558 604 559 605 if app.does_user_dislike_post(user.id, post.id) { 560 - sql app.db { 561 - delete from Like where user_id == user.id && post_id == post.id 562 - // yeet the old cached like value 563 - delete from LikeCache where post_id == post.id 564 - } or { 565 - eprintln('user ${user.id} failed to unlike post ${id}') 566 - return ctx.server_error('failed to unlike post') 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') 567 609 } 568 610 return ctx.ok('undisliked post') 569 611 } else { 570 612 // remove the old like, if it exists 571 613 if app.does_user_like_post(user.id, post.id) { 572 - sql app.db { 573 - delete from Like where user_id == user.id && post_id == post.id 574 - } or { 614 + if !app.unlike_post(post.id, user.id) { 575 615 eprintln('user ${user.id} failed to remove like on post ${id} when disliking it') 576 616 } 577 617 } ··· 581 621 post_id: post.id 582 622 is_like: false 583 623 } 584 - sql app.db { 585 - insert like into Like 586 - // yeet the old cached like value 587 - delete from LikeCache where post_id == post.id 588 - } or { 624 + if !app.add_like(like) { 589 625 eprintln('user ${user.id} failed to dislike post ${id}') 590 626 return ctx.server_error('failed to dislike post') 591 627 } ··· 593 629 } 594 630 } 595 631 596 - @['/api/post/get_title'] 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] 597 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 + } 598 671 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 599 672 return ctx.text(post.title) 600 673 } ··· 602 675 @['/api/post/edit'; post] 603 676 fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result { 604 677 user := app.whoami(mut ctx) or { 605 - ctx.error('not logged in!') 606 - return ctx.redirect('/login') 678 + return ctx.unauthorized(not_logged_in_msg) 607 679 } 608 680 post := app.get_post_by_id(id) or { 609 - ctx.error('no such post') 610 - return ctx.redirect('/') 681 + return ctx.server_error('no such post') 611 682 } 612 683 if post.author_id != user.id { 613 - ctx.error('insufficient permissions') 614 - return ctx.redirect('/') 684 + return ctx.unauthorized('insufficient permissions') 615 685 } 616 686 617 - sql app.db { 618 - update Post set body = body, title = title where id == id 619 - } or { 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) { 620 694 eprintln('failed to update post') 621 - ctx.error('failed to update post') 622 - return ctx.redirect('/') 695 + return ctx.server_error('failed to update post') 623 696 } 624 697 625 - return ctx.redirect('/post/${id}') 698 + return ctx.ok('posted edited') 626 699 } 627 700 628 701 @['/api/post/pin'; post] 629 702 fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 630 703 user := app.whoami(mut ctx) or { 631 - ctx.error('not logged in!') 632 - return ctx.redirect('/login') 704 + return ctx.unauthorized(not_logged_in_msg) 633 705 } 634 706 635 707 if user.admin { 636 - sql app.db { 637 - update Post set pinned = true where id == id 638 - } or { 708 + if !app.pin_post(id) { 639 709 eprintln('failed to pin post: ${id}') 640 - ctx.error('failed to pin post') 641 - return ctx.redirect('/post/${id}') 710 + return ctx.server_error('failed to pin post') 642 711 } 643 - return ctx.redirect('/post/${id}') 712 + return ctx.ok('post pinned') 644 713 } else { 645 - ctx.error('insufficient permissions!') 646 714 eprintln('insufficient perms to pin post: ${id} (${user.id})') 647 - return ctx.redirect('/') 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})') 648 733 } 734 + posts := app.search_for_posts(query, limit, offset) 735 + return ctx.json[[]PostSearchResult](posts) 649 736 } 650 737 651 738 ////// site ////// ··· 653 740 @['/api/site/set_motd'; post] 654 741 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result { 655 742 user := app.whoami(mut ctx) or { 656 - ctx.error('not logged in!') 657 - return ctx.redirect('/login') 743 + return ctx.unauthorized(not_logged_in_msg) 658 744 } 659 745 660 746 if user.admin { 661 - sql app.db { 662 - update Site set motd = motd where id == 1 663 - } or { 664 - ctx.error('failed to set motd') 747 + if !app.set_motd(motd) { 665 748 eprintln('failed to set motd: ${motd}') 666 - return ctx.redirect('/') 749 + return ctx.server_error('failed to set motd') 667 750 } 668 751 println('set motd to: ${motd}') 669 - return ctx.redirect('/') 752 + return ctx.ok('motd updated') 670 753 } else { 671 - ctx.error('insufficient permissions!') 672 754 eprintln('insufficient perms to set motd to: ${motd} (${user.id})') 673 - return ctx.redirect('/') 755 + return ctx.unauthorized('insufficient permissions') 674 756 } 675 757 }
+25 -27
src/webapp/app.v
··· 11 11 veb.StaticHandler 12 12 DatabaseAccess 13 13 pub: 14 - config Config 14 + config Config 15 + buildinfo BuildInfo 16 + built_at string = @BUILD_TIMESTAMP 17 + v_hash string = @VHASH 15 18 pub mut: 16 19 auth auth.Auth[pg.DB] 17 20 validators struct { ··· 26 29 } 27 30 } 28 31 29 - // get a user by their token, returns none if the user was not found. 30 - pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User { 31 - user_token := app.auth.find_token(token, ctx.ip()) or { 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 { 32 36 eprintln('no such user corresponding to token') 33 37 return none 34 38 } 35 39 return app.get_user_by_id(user_token.user_id) 36 40 } 37 41 38 - // returns the current logged in user, or none if the user is not logged in. 42 + // whoami returns the current logged in user, or none if the user is not logged 43 + // in. 39 44 pub fn (app &App) whoami(mut ctx Context) ?User { 40 45 token := ctx.get_cookie('token') or { return none }.trim_space() 41 46 if token == '' { 42 47 return none 43 48 } 44 - if user := app.get_user_by_token(ctx, token) { 49 + if user := app.get_user_by_token(token) { 45 50 if user.username == '' || user.id == 0 { 46 51 eprintln('a user had a token for the blank user') 47 52 // Clear token ··· 69 74 } 70 75 } 71 76 72 - // get a user representing an unknown user 73 - pub fn (app &App) get_unknown_user() User { 74 - return User{ 75 - username: 'unknown' 76 - } 77 - } 78 - 79 - // get a post representing an unknown post 80 - pub fn (app &App) get_unknown_post() Post { 81 - return Post{ 82 - title: 'unknown' 83 - } 84 - } 85 - 86 - // returns true if the user is logged in as the provided user id. 77 + // logged_in_as returns true if the user is logged in as the provided user id. 87 78 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 88 79 if !ctx.is_logged_in() { 89 80 return false ··· 91 82 return app.whoami(mut ctx) or { return false }.id == id 92 83 } 93 84 94 - // get the site's message of the day. 85 + // get_motd returns the site's message of the day. 95 86 @[inline] 96 87 pub fn (app &App) get_motd() string { 97 88 site := app.get_or_create_site_config() 98 89 return site.motd 99 90 } 100 91 101 - // get the notification count for a given user, formatted for usage on the 102 - // frontend. 92 + // get_notification_count_for_frontend returns the notification count for a 93 + // given user, formatted for usage on the frontend. 103 94 pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 104 95 count := app.get_notification_count(user_id, limit) 105 96 if count == 0 { ··· 111 102 } 112 103 } 113 104 114 - // processes a post's body to send notifications for mentions or replies. 105 + // process_post_mentions parses a post's body to send notifications for mentions 106 + // or replies. 115 107 pub fn (app &App) process_post_mentions(post &Post) { 116 108 author := app.get_user_by_id(post.author_id) or { 117 109 eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') ··· 134 126 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 135 127 return 136 128 } 137 - matches := re.find_all_str(post.body) 138 - for mat in matches { 129 + matches := re.find_all(post.body) 130 + for i := 0 ; i < matches.len ; i += 2 { 131 + mat := post.body[matches[i]..matches[i+1]] 132 + // skip escaped mentions 133 + if matches[i] != 0 && post.body[matches[i] - 1] == `\\` { 134 + continue 135 + } 136 + 139 137 println('found mentioned user: ${mat}') 140 138 username := mat#[2..-1] 141 139 user := app.get_user_by_name(username) or {
+44
src/webapp/config.v
··· 2 2 3 3 import emmathemartian.maple 4 4 5 + // Config stores constant site-wide configuration data. 5 6 pub struct Config { 6 7 pub mut: 7 8 dev_mode bool ··· 11 12 name string 12 13 welcome string 13 14 default_theme string 15 + default_css string 14 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 15 24 } 16 25 http struct { 17 26 pub mut: ··· 25 34 password string 26 35 db string 27 36 } 37 + hcaptcha struct { 38 + pub mut: 39 + enabled bool 40 + secret string 41 + site_key string 42 + } 28 43 post struct { 29 44 pub mut: 30 45 title_min_len int ··· 33 48 body_min_len int 34 49 body_max_len int 35 50 body_pattern string 51 + allow_nsfw bool 36 52 } 37 53 user struct { 38 54 pub mut: ··· 70 86 config.instance.name = loaded_instance.get('name').to_str() 71 87 config.instance.welcome = loaded_instance.get('welcome').to_str() 72 88 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 89 + config.instance.default_css = loaded_instance.get('default_css').to_str() 73 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() 74 98 75 99 loaded_http := loaded.get('http') 76 100 config.http.port = loaded_http.get('port').to_int() ··· 82 106 config.postgres.password = loaded_postgres.get('password').to_str() 83 107 config.postgres.db = loaded_postgres.get('db').to_str() 84 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 + 85 114 loaded_post := loaded.get('post') 86 115 config.post.title_min_len = loaded_post.get('title_min_len').to_int() 87 116 config.post.title_max_len = loaded_post.get('title_max_len').to_int() ··· 89 118 config.post.body_min_len = loaded_post.get('body_min_len').to_int() 90 119 config.post.body_max_len = loaded_post.get('body_max_len').to_int() 91 120 config.post.body_pattern = loaded_post.get('body_pattern').to_str() 121 + config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool() 92 122 93 123 loaded_user := loaded.get('user') 94 124 config.user.username_min_len = loaded_user.get('username_min_len').to_int() ··· 113 143 114 144 return config 115 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 + }
+74 -3
src/webapp/pages.v
··· 4 4 import entity { User } 5 5 6 6 fn (mut app App) index(mut ctx Context) veb.Result { 7 + if !app.config.instance.public_data { 8 + _ := app.whoami(mut ctx) or { 9 + ctx.error('not logged in') 10 + return ctx.redirect('/login') 11 + } 12 + } 13 + 7 14 ctx.title = app.config.instance.name 8 15 user := app.whoami(mut ctx) or { User{} } 9 16 recent_posts := app.get_recent_posts() ··· 33 40 return ctx.redirect('/user/${user.username}') 34 41 } 35 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 + 36 65 fn (mut app App) settings(mut ctx Context) veb.Result { 37 66 user := app.whoami(mut ctx) or { 38 67 ctx.error('not logged in') ··· 69 98 70 99 @['/user/:username'] 71 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 + 72 108 user := app.whoami(mut ctx) or { User{} } 73 109 viewing := app.get_user_by_name(username) or { 74 110 ctx.error('user not found') 75 111 return ctx.redirect('/') 76 112 } 77 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 + 78 121 return $veb.html('../templates/user.html') 79 122 } 80 123 81 124 @['/post/:post_id'] 82 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 + 83 133 post := app.get_post_by_id(post_id) or { 84 134 ctx.error('no such post') 85 135 return ctx.redirect('/') ··· 91 141 mut replying_to_user := app.get_unknown_user() 92 142 93 143 if post.replying_to != none { 94 - replying_to_post = app.get_post_by_id(post.replying_to) or { 95 - app.get_unknown_post() 96 - } 144 + replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() } 97 145 replying_to_user = app.get_user_by_id(replying_to_post.author_id) or { 98 146 app.get_unknown_user() 99 147 } ··· 173 221 ctx.title = '${app.config.instance.name} - #${tag}' 174 222 return $veb.html('../templates/tag.html') 175 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 + }
+19 -3
src/webapp/validation.v
··· 2 2 3 3 import regex 4 4 5 - // handles validation of user-input fields 5 + // StringValidator handles validation of user-input fields. 6 6 pub struct StringValidator { 7 7 pub: 8 8 min_len int ··· 10 10 pattern regex.RE 11 11 } 12 12 13 + // validate validates a given string and returns true if it succeeded and false 14 + // otherwise. 13 15 @[inline] 14 - pub fn (validator StringValidator) validate(str string) bool { 15 - return str.len > validator.min_len && str.len < validator.max_len 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 16 30 && validator.pattern.matches_string(str) 17 31 } 18 32 33 + // StringValidator.new creates a new StringValidator with the given min, max, 34 + // and pattern. 19 35 pub fn StringValidator.new(min int, max int, pattern string) StringValidator { 20 36 mut re := regex.new() 21 37 re.compile_opt(pattern) or { panic(err) }
+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'