+18
.dockerignore
+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
+3
.editorconfig
+12
-17
.gitignore
+12
-17
.gitignore
···
1
-
# binaries
2
-
main
3
-
clockwork
4
-
beep
5
-
*.exe
6
-
*.exe~
7
-
*.so
8
-
*.dylib
9
-
*.dll
10
-
bin/
1
+
# Binaries
2
+
/beep
3
+
/build/
4
+
/scripts/fetchbuildinfo
11
5
12
-
# editor/system specific metadata
6
+
# Editor/system specific metadata
13
7
.DS_Store
14
-
.idea/
15
8
.vscode/
16
-
*.iml
17
9
18
-
# secrets
10
+
# Secrets
19
11
/config.real.maple
20
12
.env
21
13
22
-
# local v and clockwork install (from gitpod stuffs)
14
+
# Build data
15
+
/buildinfo.maple
16
+
17
+
# Local V and Clockwork install (Gitpod)
18
+
/clockwork
23
19
/v/
24
-
/clockwork/
25
20
26
-
# quick notes i keep while developing
21
+
# Quick notes I keep while developing
27
22
/stickynote.md
+43
Dockerfile
+43
Dockerfile
···
1
+
FROM debian:trixie-slim
2
+
3
+
# Create beep group and user
4
+
RUN <<EOF
5
+
set -eux
6
+
groupadd -r beep
7
+
useradd -r -g beep beep -d /beep -s /bin/sh
8
+
install -vd -o beep -g beep -m 1777 /beep
9
+
EOF
10
+
11
+
# Install base packages. These might already be installed by the image.
12
+
RUN <<EOF
13
+
set -eux
14
+
apt update
15
+
apt install -y --no-install-recommends \
16
+
ca-certificates build-essential git libpq-dev
17
+
EOF
18
+
19
+
# Install V
20
+
RUN <<EOF
21
+
set -eux
22
+
git clone --depth=1 https://github.com/vlang/v /opt/v
23
+
cd /opt/v
24
+
make
25
+
ln -s /opt/v/v /usr/local/bin/v
26
+
EOF
27
+
28
+
USER beep
29
+
WORKDIR /beep
30
+
COPY . .
31
+
32
+
# Install beep
33
+
RUN <<EOF
34
+
set -eux
35
+
# git clone --depth=1 https://tangled.org/emmeline.girlkisser.top/beep .
36
+
mkdir -p ~/.vmodules/emmathemartian/maple
37
+
git clone --depth=1 https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple
38
+
v -cflags "-O3 -flto" . # compiling with -prod causes ORM errors.
39
+
EOF
40
+
41
+
STOPSIGNAL SIGINT
42
+
EXPOSE 8008
43
+
CMD ["./beep"]
+40
-11
build.maple
+40
-11
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
46
-
task:run.watch = {
47
-
description = 'Watch/run beep'
48
-
category = 'run'
49
-
run = '${v} -d veb_livereload watch run ${v_main} config.maple'
53
+
// Ngrok
54
+
55
+
task:ngrok = {
56
+
description = 'Open an ngrok tunnel for testing.'
57
+
category = 'misc'
58
+
run = 'ngrok http http://localhost:8008'
50
59
}
51
60
52
-
task:run.watch.real = {
53
-
description = 'Watch/run beep using config.real.maple'
54
-
category = 'run'
55
-
run = '${v} watch run ${v_main} config.real.maple'
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'
56
65
}
57
66
67
+
// Run
68
+
58
69
task:run = {
59
70
description = 'Run beep'
60
71
category = 'run'
72
+
depends = [':fetch-build-info']
61
73
run = '${v} run ${v_main} config.maple'
62
74
}
63
75
64
76
task:run.real = {
65
77
description = 'Run beep using config.real.maple'
66
78
category = 'run'
67
-
run = '${v} 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'
68
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
69
98
70
99
task:cloc = {
71
100
description = 'Get the lines of code for beep!'
+36
compose.yml
+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
+53
-12
config.maple
+53
-12
config.maple
···
1
+
// Toggles developer mode; when true, allows access to the admin panel for all users.
1
2
dev_mode = false
3
+
// Path to the static directory. You shouldn't ever need to change this.
2
4
static_path = 'src/static'
3
5
6
+
// General instance settings
4
7
instance = {
8
+
// Instance version. This is shown on the about page.
9
+
version = '2025.12'
10
+
11
+
// Set this to '' if your instance is closed source. This is shown on the about page.
12
+
source = 'https://tangled.org/emmeline.girlkisser.top/beep'
13
+
14
+
// Source for your V compiler. Unless you're using a fork of V, you shouldn't need to change this.
15
+
v_source = 'https://github.com/vlang/v'
16
+
17
+
// The instance's name, used for the page titles and on the homepage.
5
18
name = 'beep'
19
+
// The welcome message to show on the homepage.
6
20
welcome = 'welcome to beep!'
7
21
8
-
default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css'
22
+
// TODO: Move default_theme and allow_changing_theme to user settings
23
+
// Default theme applied for all users.
24
+
default_theme = '/static/themes/default.css'
25
+
// Default custom CSS applied for all users.
26
+
default_css = ''
27
+
// Whether or not users should be able to change their theme.
9
28
allow_changing_theme = true
10
29
11
-
// instance version
12
-
version = '2025.01'
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 = ''
13
34
14
-
// set this to '' if your instance is closed source (twt)
15
-
source = 'https://github.com/emmathemartian/beep'
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 = ''
16
40
}
17
41
18
42
http = {
19
43
port = 8008
20
44
}
21
45
46
+
// Database settings.
22
47
postgres = {
23
-
host = 'localhost'
48
+
// Name of database container in compose.yml
49
+
host = 'beep-database'
24
50
port = 5432
25
51
user = 'beep'
26
-
password = 'beep'
52
+
password = 'beep' // TODO: Read from .env
27
53
db = 'beep'
28
54
}
29
55
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.
30
64
post = {
31
65
title_min_len = 1
32
66
title_max_len = 50
33
-
title_pattern = '(.|\s)*'
67
+
title_pattern = '.*'
34
68
35
69
body_min_len = 1
36
70
body_max_len = 1000
37
-
body_pattern = '(.|\s)*'
71
+
body_pattern = '.*'
72
+
73
+
// Whether or not posts can be marked as NSFW.
74
+
allow_nsfw = true
38
75
}
39
76
77
+
// User settings.
40
78
user = {
41
79
username_min_len = 3
42
80
username_max_len = 20
···
44
82
45
83
nickname_min_len = 1
46
84
nickname_max_len = 20
47
-
nickname_pattern = '(.|\s).*'
85
+
nickname_pattern = '.*'
48
86
49
87
password_min_len = 12
50
88
password_max_len = 72
···
52
90
53
91
pronouns_min_len = 0
54
92
pronouns_max_len = 30
55
-
pronouns_pattern = '(.|\s)*'
93
+
pronouns_pattern = '.*'
56
94
57
95
bio_min_len = 0
58
96
bio_max_len = 200
59
-
bio_pattern = '(.|\s)*'
97
+
bio_pattern = '.*'
60
98
}
61
99
100
+
// Welcome notification settings.
62
101
welcome = {
102
+
// Title of the notification.
63
103
summary = 'welcome!'
104
+
// Notification body text. %s is replaced with the user's name.
64
105
body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D'
65
106
}
+3
doc/database_spec.md
+3
doc/database_spec.md
···
20
20
| `admin` | bool | controls whether or not this user is an admin |
21
21
| `automated` | bool | controls whether or not this user is automated |
22
22
| `theme` | ?string | controls per-user css themes |
23
+
| `css` | ?string | controls per-user css |
23
24
| `bio` | string | bio for this user |
24
25
| `pronouns` | string | pronouns for this user |
25
26
| `created_at` | time.Time | a timestamp of when this user was made |
···
35
36
| `replying_to` | ?int | id of the post that this post is replying to |
36
37
| `title` | string | the title of this post |
37
38
| `body` | string | the body of this post |
39
+
| `pinned` | bool | if this post in globally pinned |
40
+
| `nsfw` | bool | if this post in marked as nsfw |
38
41
| `posted_at` | time.Time | a timestamp of when this post was made |
39
42
40
43
## `Like`
+12
doc/resources.md
+12
doc/resources.md
···
21
21
- https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like
22
22
- helped me develop the initial search system, which is subject to be
23
23
overhauled, but for now, this helped a lot.
24
+
- https://stackoverflow.com/questions/1237725/copying-postgresql-database-to-another-server
25
+
- database migrations
24
26
25
27
## sql security
26
28
···
32
34
- https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html#other-examples-of-safe-prepared-statements
33
35
- https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html#using-net-built-in-feature
34
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
+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/`
-2
doc/todo.md
-2
doc/todo.md
···
31
31
32
32
- [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc)
33
33
- [ ] user:follow other users (send notifications on new posts)
34
-
- [ ] site:webhooks
35
-
- could be used so that a github webhook can send a message when a new commit is pushed to beep!
36
34
- [ ] site:log new accounts, account deletions, etc etc in an admin-accessible site log
37
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
38
36
- [ ] site:implement a database keep-alive system
+14
etc/beep-db.service
+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
+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
+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
-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
+49
readme
···
1
+
2
+
beep
3
+
====
4
+
5
+
> *a legendary land of lowercase lovers.*
6
+
7
+
A self-hosted "social-media-oriented" mini-blogger.
8
+
9
+
Technically made because I wanted to mess around with RSS,
10
+
but I also wanted a teensy little blog/slow-paced-chat-app
11
+
for myself and my friends.
12
+
13
+
hosting
14
+
-------
15
+
16
+
[WARNING]
17
+
Do not compile with -prod. V's AST optimizations break
18
+
something in the ORM and cause assorted errors. Instead,
19
+
use `-cflags "-O3 -flto"`
20
+
21
+
$ git clone https://tangled.org/emmeline.girlkisser.top/beep
22
+
$ cd beep
23
+
$ cp config.maple config.real.maple
24
+
25
+
Edit config.real.maple to set ports, auth, etc.
26
+
27
+
`config.real.maple` also has settings to configure the
28
+
default theme, post length, username length, welcome
29
+
messages, etc etc.
30
+
31
+
[WARNING] DO NOT PUT SECRETS IN config.maple
32
+
config.maple is intended to be pushed to Git as a template
33
+
config for your instance. Instead, put your secrets in
34
+
config.real.maple, which is gitignored.
35
+
TODO: Read secrets from .env automatically.
36
+
37
+
With Docker:
38
+
$ docker compose up
39
+
40
+
Without Docker:
41
+
(assumes you already have a database somewhere)
42
+
$ v install EmmaTheMartian.Maple
43
+
$ v -cflags "-O3 -flto" .
44
+
$ ./beep
45
+
46
+
If `v install ...` fails then you can install Maple
47
+
manually:
48
+
$ mkdir -p ~/.vmodules/emmathemartian/maple
49
+
$ git clone https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple
-39
readme.md
-39
readme.md
···
1
-
# beep
2
-
3
-
> *a legendary land of lowercase lovers.*
4
-
5
-
a self-hosted "social-media-oriented" mini-blogger.
6
-
7
-
technically made because i wanted to mess around with rss, but i also wanted a
8
-
teensy little blog/slow-paced-chat-app for myself and my friends.
9
-
10
-
## hosting
11
-
12
-
you will need a postgresql database somewhere, along with v to compile beep:
13
-
14
-
copy the `config.maple` as `config.real.maple`
15
-
16
-
edit `config.real.maple` to set the url, port, username, password, and database
17
-
name.
18
-
19
-
> `config.real.maple` also has settings to configure the feel of your beep
20
-
> instance, post length, username length, welcome messages, etc etc.
21
-
22
-
> **do not put your secrets in `config.maple`**. it is intended to be pushed to
23
-
> git as a "template config." instead, use `config.real.maple` if you plan to
24
-
> push anywhere. it is gitignored already, meaning you do not have to fear about
25
-
> your secrets not being kept a secret.
26
-
27
-
```sh
28
-
git clone https://github.com/emmathemartian/beep
29
-
cd beep
30
-
v -prod .
31
-
./beep config.real.maple
32
-
```
33
-
34
-
then go to the configured url to view (default is `http://localhost:8008`).
35
-
36
-
if you do not have a database, you can either self-host a postgresql database on
37
-
your machine, or you can find a free one online. i use and like
38
-
[neon.tech](https://neon.tech), their free plan is pretty comfortable for a
39
-
small beep instance!
+15
scripts/fetchbuildinfo.vsh
+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
+12
-7
src/auth/auth.v
···
1
1
// From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v
2
2
// The original file's source is licensed under MIT.
3
3
4
+
// ~~
4
5
// This fork re-introduces the `ip` field of each token for additional security,
5
6
// along with delete_tokens_for_ip
7
+
// ~~
8
+
// IP has been removed since IPs can change randomly and it causes you to need
9
+
// to relog wayyyy more often. I'm keeping this fork just in case I do need to
10
+
// change the auth system in the future.
6
11
7
12
module auth
8
13
···
22
27
id int @[primary; sql: serial]
23
28
user_id int
24
29
value string
25
-
ip string
26
30
}
27
31
28
32
pub fn new[T](db T) Auth[T] {
···
35
39
}
36
40
}
37
41
38
-
pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string {
42
+
pub fn (mut app Auth[T]) add_token(user_id int) !string {
39
43
mut uuid := rand.uuid_v4()
40
44
token := Token{
41
45
user_id: user_id
42
46
value: uuid
43
-
ip: ip
44
47
}
45
48
sql app.db {
46
49
insert token into Token
···
48
51
return uuid
49
52
}
50
53
51
-
pub fn (app &Auth[T]) find_token(value string, ip string) ?Token {
54
+
pub fn (app &Auth[T]) find_token(value string) ?Token {
52
55
tokens := sql app.db {
53
-
select from Token where value == value && ip == ip limit 1
56
+
select from Token where value == value limit 1
54
57
} or { []Token{} }
55
58
if tokens.len == 0 {
56
59
return none
···
58
61
return tokens.first()
59
62
}
60
63
64
+
// logs out of all devices
61
65
pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! {
62
66
sql app.db {
63
67
delete from Token where user_id == user_id
64
68
}!
65
69
}
66
70
67
-
pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! {
71
+
// logs out of one device
72
+
pub fn (mut app Auth[T]) delete_tokens_for_value(value string) ! {
68
73
sql app.db {
69
-
delete from Token where ip == ip
74
+
delete from Token where value == value
70
75
}!
71
76
}
72
77
+16
-5
src/database/post.v
+16
-5
src/database/post.v
···
109
109
110
110
// update_post updates the given post's title and body with the given title and
111
111
// body, returns true if this succeeds and false otherwise.
112
-
pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string) bool {
112
+
pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string, new_nsfw bool) bool {
113
113
sql app.db {
114
-
update Post set body = new_body, title = new_title where id == post_id
114
+
update Post set body = new_body, title = new_title, nsfw = new_nsfw where id == post_id
115
115
} or {
116
116
return false
117
117
}
···
164
164
// todo: levenshtein distance, query options/filters (user:beep, !excluded-text,
165
165
// etc)
166
166
pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult {
167
-
queried_posts := app.db.exec_param_many('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or {
167
+
queried_posts := app.db.exec_param_many_result('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or {
168
168
eprintln('search_for_posts error in app.db.error: ${err}')
169
+
pg.Result{}
170
+
}
171
+
posts := queried_posts.rows.map(fn [queried_posts] (it pg.Row) Post {
172
+
return Post.from_row(queried_posts, it)
173
+
})
174
+
return PostSearchResult.from_post_list(app, posts)
175
+
}
176
+
177
+
// get_post_count gets the number of posts in the database.
178
+
pub fn (app &DatabaseAccess) get_post_count() int {
179
+
n := app.db.exec('SELECT COUNT(id) FROM "Post"') or {
180
+
eprintln('get_post_count error in app.db.error: ${err}')
169
181
[]
170
182
}
171
-
posts := queried_posts.map(|it| Post.from_row(it))
172
-
return PostSearchResult.from_post_list(app, posts)
183
+
return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() }
173
184
}
+4
-1
src/database/site.v
+4
-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')
+27
-3
src/database/user.v
+27
-3
src/database/user.v
···
2
2
3
3
import entity { User, Notification, Like, LikeCache, Post }
4
4
import util
5
+
import db.pg
5
6
6
7
// new_user creates a new user and returns their struct after creation.
7
8
pub fn (app &DatabaseAccess) new_user(user User) ?User {
···
84
85
update User set theme = theme where id == user_id
85
86
} or {
86
87
eprintln('failed to update theme url for ${user_id}')
88
+
return false
89
+
}
90
+
return true
91
+
}
92
+
93
+
// set_css sets the given user's custom CSS, returns true if this succeeded and
94
+
// false otherwise.
95
+
pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool {
96
+
sql app.db {
97
+
update User set css = css where id == user_id
98
+
} or {
99
+
eprintln('failed to update css for ${user_id}')
87
100
return false
88
101
}
89
102
return true
···
216
229
// search_for_users searches for posts matching the given query.
217
230
// todo: query options/filters, such as created-after:<date>, created-before:<date>, etc
218
231
pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User {
219
-
queried_users := app.db.exec_param_many('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or {
232
+
queried_users := app.db.exec_param_many_result('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or {
220
233
eprintln('search_for_users error in app.db.error: ${err}')
234
+
pg.Result{}
235
+
}
236
+
users := queried_users.rows.map(fn [queried_users] (it pg.Row) User {
237
+
return User.from_row(queried_users, it)
238
+
})
239
+
return users
240
+
}
241
+
242
+
// get_user_count gets the number of registered users in the database.
243
+
pub fn (app &DatabaseAccess) get_user_count() int {
244
+
n := app.db.exec('SELECT COUNT(id) FROM "User"') or {
245
+
eprintln('get_user_count error in app.db.error: ${err}')
221
246
[]
222
247
}
223
-
users := queried_users.map(|it| User.from_row(it))
224
-
return users
248
+
return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() }
225
249
}
+20
-9
src/entity/post.v
+20
-9
src/entity/post.v
···
14
14
body string
15
15
16
16
pinned bool
17
+
nsfw bool
17
18
18
19
posted_at time.Time = time.now()
19
20
}
···
21
22
// Post.from_row creates a post object from the given database row.
22
23
// see src/database/post.v#search_for_posts for usage.
23
24
@[inline]
24
-
pub fn Post.from_row(row pg.Row) Post {
25
+
pub fn Post.from_row(res pg.Result, row pg.Row) Post {
26
+
// curry some arguments for cleanliness
27
+
c := fn [res, row] (key string) ?string {
28
+
return util.get_row_col(res, row, key)
29
+
}
30
+
ct := fn [res, row] (key string) string {
31
+
return util.get_row_col_or_throw(res, row, key)
32
+
}
33
+
25
34
// this throws a cgen error when put in Post{}
26
35
//todo: report this
27
-
posted_at := time.parse(util.or_throw[string](row.vals[6])) or { panic(err) }
36
+
posted_at := time.parse(ct('posted_at')) or { panic(err) }
37
+
nsfw := util.map_or_throw[string, bool](ct('nsfw'), |it| it.bool())
28
38
29
39
return Post{
30
-
id: util.or_throw[string](row.vals[0]).int()
31
-
author_id: util.or_throw[string](row.vals[1]).int()
32
-
replying_to: if row.vals[2] == none { ?int(none) } else {
33
-
util.map_or_throw[string, int](row.vals[2], |it| it.int())
40
+
id: ct('id').int()
41
+
author_id: ct('author_id').int()
42
+
replying_to: if c('replying_to') == none { none } else {
43
+
util.map_or_throw[string, int](ct('replying_to'), |it| it.int())
34
44
}
35
-
title: util.or_throw[string](row.vals[3])
36
-
body: util.or_throw[string](row.vals[4])
37
-
pinned: util.map_or_throw[string, bool](row.vals[5], |it| it.bool())
45
+
title: ct('title')
46
+
body: ct('body')
47
+
pinned: util.map_or_throw[string, bool](ct('pinned'), |it| it.bool())
48
+
nsfw: nsfw
38
49
posted_at: posted_at
39
50
}
40
51
}
+20
-11
src/entity/user.v
+20
-11
src/entity/user.v
···
18
18
automated bool
19
19
20
20
theme string
21
+
css string
21
22
22
23
bio string
23
24
pronouns string
···
44
45
// User.from_row creates a user object from the given database row.
45
46
// see src/database/user.v#search_for_users for usage.
46
47
@[inline]
47
-
pub fn User.from_row(row pg.Row) User {
48
+
pub fn User.from_row(res pg.Result, row pg.Row) User {
49
+
// curry some arguments for cleanliness
50
+
c := fn [res, row] (key string) ?string {
51
+
return util.get_row_col(res, row, key)
52
+
}
53
+
ct := fn [res, row] (key string) string {
54
+
return util.get_row_col_or_throw(res, row, key)
55
+
}
56
+
48
57
// this throws a cgen error when put in User{}
49
58
//todo: report this
50
-
created_at := time.parse(util.or_throw[string](row.vals[10])) or { panic(err) }
59
+
created_at := time.parse(ct('created_at')) or { panic(err) }
51
60
52
61
return User{
53
-
id: util.or_throw[string](row.vals[0]).int()
54
-
username: util.or_throw[string](row.vals[1])
55
-
nickname: if row.vals[2] == none { ?string(none) } else {
56
-
util.or_throw[string](row.vals[2])
62
+
id: ct('id').int()
63
+
username: ct('username')
64
+
nickname: if c('nickname') == none { none } else {
65
+
ct('nickname')
57
66
}
58
67
password: 'haha lol, nope'
59
68
password_salt: 'haha lol, nope'
60
-
muted: util.map_or_throw[string, bool](row.vals[5], |it| it.bool())
61
-
admin: util.map_or_throw[string, bool](row.vals[6], |it| it.bool())
62
-
theme: util.or_throw[string](row.vals[7])
63
-
bio: util.or_throw[string](row.vals[8])
64
-
pronouns: util.or_throw[string](row.vals[9])
69
+
muted: util.map_or_throw[string, bool](util.get_row_col(res, row, 'muted'), |it| it.bool())
70
+
admin: util.map_or_throw[string, bool](util.get_row_col(res, row, 'admin'), |it| it.bool())
71
+
theme: ct('theme')
72
+
bio: ct('bio')
73
+
pronouns: ct('pronouns')
65
74
created_at: created_at
66
75
}
67
76
}
+34
-21
src/main.v
+34
-21
src/main.v
···
9
9
import beep_sql
10
10
import util
11
11
12
-
pub const version = '25.01.0'
13
-
14
12
@[inline]
15
13
fn connect(mut app App) {
16
14
println('-> connecting to database...')
···
43
41
44
42
@[inline]
45
43
fn load_validators(mut app App) {
46
-
app.validators.username = StringValidator.new(app.config.user.username_min_len,app.config.user.username_max_len,app.config.user.username_pattern)
47
-
app.validators.password = StringValidator.new(app.config.user.password_min_len,app.config.user.password_max_len,app.config.user.password_pattern)
48
-
app.validators.nickname = StringValidator.new(app.config.user.nickname_min_len,app.config.user.nickname_max_len,app.config.user.nickname_pattern)
49
-
app.validators.user_bio = StringValidator.new(app.config.user.bio_min_len,app.config.user.bio_max_len,app.config.user.bio_pattern)
50
-
app.validators.pronouns = StringValidator.new(app.config.user.pronouns_min_len,app.config.user.pronouns_max_len,app.config.user.pronouns_pattern)
51
-
app.validators.post_title = StringValidator.new(app.config.post.title_min_len,app.config.post.title_max_len,app.config.post.title_pattern)
52
-
app.validators.post_body = StringValidator.new(app.config.post.body_min_len,app.config.post.body_max_len,app.config.post.body_pattern)
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
53
55
}
54
56
55
57
fn main() {
56
58
mut stopwatch := util.Stopwatch.new()
57
59
58
-
config := webapp.load_config_from(os.args[1])
59
-
mut app := &App{ config: config }
60
+
mut app := &App{
61
+
config: if os.args.len > 1 {
62
+
webapp.load_config_from(os.args[1])
63
+
} else if os.exists('config.real.maple') {
64
+
webapp.load_config_from('config.real.maple')
65
+
} else {
66
+
panic('no config found nor specified!')
67
+
}
68
+
buildinfo: if os.exists('buildinfo.maple') {
69
+
webapp.load_buildinfo_from('buildinfo.maple')
70
+
} else {
71
+
webapp.BuildInfo{}
72
+
}
73
+
}
60
74
61
75
// connect to database
62
76
util.time_it(
···
66
80
name: 'connect to db'
67
81
log: true
68
82
)
83
+
defer { app.db.close() or { panic(err) } }
69
84
70
-
defer { app.db.close() }
71
-
72
-
// add authenticator
73
-
app.auth = auth.new(app.db)
85
+
// initialize database
86
+
util.time_it(it: fn [mut app] () {
87
+
init_db(mut app)
88
+
}, name: 'init db', log: true)
74
89
75
90
// load sql files kept in beep_sql/
76
91
util.time_it(
···
81
96
log: true
82
97
)
83
98
99
+
// add authenticator
100
+
app.auth = auth.new(app.db)
101
+
84
102
// load validators
85
103
load_validators(mut app)
86
104
87
105
// mount static things
88
106
app.mount_static_folder_at(app.config.static_path, '/static')!
89
107
90
-
// initialize database
91
-
util.time_it(it: fn [mut app] () {
92
-
init_db(mut app)
93
-
}, name: 'init db', log: true)
94
-
95
108
// make the website config, if it does not exist
96
109
app.get_or_create_site_config()
97
110
98
-
if config.dev_mode {
111
+
if app.config.dev_mode {
99
112
println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m')
100
113
}
101
114
+52
src/static/js/form.js
+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
+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
+31
src/static/js/password.js
···
1
+
const add_password_checkers = (password_id, confirm_id, match_id) => {
2
+
const password = document.getElementById(password_id);
3
+
const confirm_password = document.getElementById(confirm_id);
4
+
const matches = document.getElementById(match_id);
5
+
6
+
const a = () => {
7
+
matches.innerText = password.value==confirm_password.value ? "yes" : "no";
8
+
};
9
+
password.addEventListener('input', a);
10
+
confirm_password.addEventListener('input', a);
11
+
12
+
const view_password = document.getElementById(`view-${password_id}`);
13
+
const view_confirm_password = document.getElementById(`view-${confirm_id}`);
14
+
15
+
const b = (elm, btn) => {
16
+
return _ => {
17
+
if (elm.getAttribute('type') == 'password')
18
+
{
19
+
elm.setAttribute('type', 'text');
20
+
btn.value = 'hide';
21
+
}
22
+
else
23
+
{
24
+
elm.setAttribute('type', 'password')
25
+
btn.value = 'show';
26
+
}
27
+
};
28
+
};
29
+
view_password.addEventListener('click', b(password, view_password));
30
+
view_confirm_password.addEventListener('click', b(confirm_password, view_confirm_password));
31
+
}
+6
-2
src/static/js/render_body.js
+6
-2
src/static/js/render_body.js
···
61
61
// give the body a loading """animation""" while we let the fetches cook
62
62
element.innerText = 'loading...'
63
63
64
-
const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g)
64
+
const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g)
65
65
const cache = {}
66
66
for (const match of matches) {
67
+
// escaped
68
+
if (match[0][0] == '\\') {
69
+
html = html.replace(match[0], match[0].replace('\\', ''))
70
+
}
67
71
// mention
68
-
if (match[0][0] == '@') {
72
+
else if (match[0][0] == '@') {
69
73
if (cache.hasOwnProperty(match[0])) {
70
74
html = html.replace(match[0], cache[match[0]])
71
75
continue
+26
-2
src/static/style.css
+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
+239
src/static/themes/default.css
···
1
+
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@100..900&family=Oxygen+Mono&display=swap');
2
+
3
+
:root {
4
+
/* palette */
5
+
/* greys */
6
+
--p-black: #333333;
7
+
--p-grey0: #414141;
8
+
--p-grey1: #4a4a4a;
9
+
--p-grey2: #4f4f4f;
10
+
--p-grey3: #5c5c5c;
11
+
--p-grey4: #5f5f5f;
12
+
--p-white: #e7e7e7;
13
+
/* rainbow */
14
+
--p-red: #faa; /* == light red */
15
+
--p-orange: #fa7;
16
+
--p-yellow: #ffa; /* == light-orange */
17
+
--p-teal: #7fa;
18
+
--p-green: #af7;
19
+
--p-blue: #7af;
20
+
--p-purple: #a7f;
21
+
--p-pink: #f7a;
22
+
/* light rainbow */
23
+
--p-light-red: #faa;
24
+
--p-light-blue: #aaf;
25
+
--p-light-green: #afa;
26
+
--p-light-orange: #ffa; /* == yellow */
27
+
--p-light-purple: #faf;
28
+
--p-light-blue: #aff;
29
+
30
+
/* colours */
31
+
--c-bg: var(--p-black);
32
+
--c-panel-bg: var(--p-grey0);
33
+
--c-panel-border: var(--p-grey2);
34
+
--c-panel2-bg: var(--p-grey1);
35
+
--c-panel2-border: var(--p-grey3);
36
+
--c-panel3-bg: var(--p-grey2);
37
+
--c-panel3-border: var(--p-grey4);
38
+
--c-fg: var(--p-white);
39
+
--c-nsfw-border: var(--p-orange);
40
+
--c-link: var(--p-blue);
41
+
--c-link-hover: var(--p-light-blue);
42
+
--c-accent: var(--p-light-green);
43
+
--c-notify-ok: var(--p-light-green);
44
+
--c-notify-error: var(--p-light-red);
45
+
46
+
/* text */
47
+
--t-font: 'Onest', Arial, serif;
48
+
--t-post-font: Garamond, 'Times New Roman', var(--t-font);
49
+
--t-mono-font: 'Oxygen Mono', monospace;
50
+
--t-h-font: 'Oxygen Mono', var(--t-post-font);
51
+
--t-font-weight: 400;
52
+
--t-font-style: normal;
53
+
--t-font-size: 20px;
54
+
55
+
/* layout */
56
+
--l-body-padding: 16px;
57
+
--l-body-gap: 12px;
58
+
--l-body-width: 75vw;
59
+
--l-border-width: 2px;
60
+
--l-border-style: solid;
61
+
--l-border-radius: 0px;
62
+
}
63
+
64
+
html {
65
+
padding: 0;
66
+
offset: 0;
67
+
margin: 0;
68
+
69
+
width: 100vw;
70
+
overflow-x: hidden;
71
+
72
+
display: flex;
73
+
flex-direction: column;
74
+
align-items: center;
75
+
76
+
background-color: var(--c-bg);
77
+
color: var(--c-fg);
78
+
79
+
font-family: var(--t-font);
80
+
font-weight: var(--t-font-weight);
81
+
font-style: var(--t-font-style);
82
+
font-size: var(--t-font-size);
83
+
}
84
+
85
+
body {
86
+
padding: var(--l-body-padding) 0 var(--l-body-padding) 0;
87
+
offset: 0;
88
+
margin: 0;
89
+
width: var(--l-body-width);
90
+
}
91
+
92
+
header {
93
+
padding-bottom: var(--l-body-padding);
94
+
}
95
+
96
+
footer {
97
+
padding-top: var(--l-body-padding);
98
+
}
99
+
100
+
main {
101
+
padding: var(--l-body-padding);
102
+
background-color: var(--c-panel-bg);
103
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel-border);
104
+
border-radius: var(--l-border-radius);
105
+
106
+
display: flex;
107
+
flex-direction: column;
108
+
gap: var(--l-body-gap);
109
+
}
110
+
111
+
form {
112
+
display: flex;
113
+
flex-direction: column;
114
+
gap: var(--l-body-gap);
115
+
}
116
+
117
+
button:hover {
118
+
cursor: pointer;
119
+
}
120
+
121
+
input,
122
+
textarea,
123
+
button {
124
+
background-color: var(--c-panel-bg);
125
+
color: var(--c-fg);
126
+
127
+
border: var(--l-border-width) var(--l-border-style) var(--c-accent);
128
+
border-radius: var(--l-border-radius);
129
+
padding: 6px;
130
+
131
+
font-family: var(--t-font);
132
+
}
133
+
134
+
input:hover,
135
+
textarea:hover,
136
+
button:hover {
137
+
border-color: var(--c-fg);
138
+
}
139
+
140
+
input:focus,
141
+
textarea:focus,
142
+
button:focus {
143
+
background-color: var(--c-accent);
144
+
color: var(--c-bg);
145
+
}
146
+
147
+
h1, h2, h3, h4, h5, h6, p {
148
+
margin: 0;
149
+
}
150
+
151
+
h1, header, footer {
152
+
font-family: var(--t-h-font);
153
+
}
154
+
155
+
a {
156
+
color: var(--c-link);
157
+
transition: 0.15s linear color;
158
+
}
159
+
160
+
a:hover {
161
+
color: var(--c-link-hover);
162
+
}
163
+
164
+
hr {
165
+
width: 100%;
166
+
}
167
+
168
+
pre {
169
+
font-family: var(--t-mono-font);
170
+
}
171
+
172
+
.post {
173
+
border: none;
174
+
border-left: var(--l-border-width) var(--l-border-style) var(--c-fg);
175
+
}
176
+
177
+
.post>pre {
178
+
font-family: var(--t-post-font);
179
+
}
180
+
181
+
.post + .post,
182
+
.notification + .notification {
183
+
margin-top: 18px;
184
+
}
185
+
186
+
form:not(.form-inline),
187
+
#recent-posts,
188
+
#pinned-posts {
189
+
padding: 16px 24px 16px 24px;
190
+
background-color: var(--c-panel2-bg);
191
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel2-border);
192
+
border-radius: var(--l-border-radius);
193
+
}
194
+
195
+
#errors:empty {
196
+
display: none;
197
+
visibility: hidden;
198
+
}
199
+
200
+
#errors {
201
+
display: flex;
202
+
flex-direction: column;
203
+
gap: var(--l-body-gap);
204
+
}
205
+
206
+
#errors>p {
207
+
background-color: var(--c-panel3-bg);
208
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel3-border);
209
+
border-radius: var(--l-border-radius);
210
+
211
+
padding: 8px;
212
+
width: calc(100% - 16px);
213
+
214
+
display: inline-flex;
215
+
align-items: center;
216
+
justify-content: center;
217
+
gap: 12px;
218
+
}
219
+
220
+
#errors>p>button {
221
+
border-color: inherit;
222
+
flex-grow: 0;
223
+
}
224
+
225
+
#errors>p>button:hover {
226
+
border-color: var(--c-fg);
227
+
}
228
+
229
+
#errors>p>span {
230
+
flex-grow: 1;
231
+
}
232
+
233
+
#errors>p.ok {
234
+
border-color: var(--c-notify-ok);
235
+
}
236
+
237
+
#errors>p.error {
238
+
border-color: var(--c-notify-error);
239
+
}
+19
-5
src/templates/about.html
+19
-5
src/templates/about.html
···
3
3
<h1>about this instance</h1>
4
4
5
5
<div>
6
+
<p><strong>general:</strong></p>
6
7
<p>name: @{app.config.instance.name}</p>
7
-
<p>version: @{app.config.instance.version} (commit: @{app.commit})</p>
8
-
<p>built at @{app.built_at} (<span id="built_at">date n/a</span>)</p>
9
-
<p>built using @{app.v_hash}</p>
8
+
<p>version: @{app.config.instance.version}</p>
9
+
<p>public: @{app.config.instance.public_data}</p>
10
+
@if app.config.instance.owner_username != ''
11
+
<p>owner: <a href="/user/@{app.config.instance.owner_username}">@{app.config.instance.owner_username}</a></p>
12
+
@end
13
+
14
+
<br>
15
+
<p><strong>stats:</strong></p>
16
+
<p>users: @{app.get_user_count()}</p>
17
+
<p>posts: @{app.get_post_count()}</p>
10
18
11
19
@if app.config.instance.source != ''
12
-
<p>source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p>
20
+
<br>
21
+
<p><strong>nerd info:</strong></p>
22
+
<p>beep source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p>
23
+
<p>beep commit: <code><a href="@{app.config.instance.source}/commit/@{app.buildinfo.commit}">@{app.buildinfo.commit}</a></code></p>
24
+
<p>V source: <a href="@{app.config.instance.v_source}">@{app.config.instance.v_source}</a></p>
25
+
<p>V commit: <code><a href="@{app.config.instance.v_source}/commit/@{app.v_hash}">@{app.v_hash}</a></code></p>
26
+
<p>built at <span id="built_at">date n/a</span> (unix: <code>@{app.built_at}</code>)</p>
13
27
@end
14
28
</div>
15
29
···
17
31
document.getElementById('built_at').innerText = new Date(@{app.built_at} * 1000).toLocaleString()
18
32
</script>
19
33
20
-
@include 'partial/footer.html'
34
+
@include 'partial/footer.html'
+82
src/templates/components/new_post.html
+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
+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
+16
-4
src/templates/components/post_small.html
···
1
1
<div class="post post-small">
2
-
<p><a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: <span class="post-title">@post.title</span></p>
3
-
@if post.body.len > 50
4
-
<p>@{post.body[..50]}...</p>
2
+
<p>
3
+
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
4
+
<span class="post-title">@post.title</span>
5
+
@if post.nsfw
6
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
7
+
@end
8
+
</p>
9
+
10
+
@if post.nsfw
11
+
<p>view full to see post body</p>
5
12
@else
6
-
<p>@post.body</p>
13
+
@if post.body.len > 50
14
+
<pre id="post-@{post.id}">@{post.body[..50]}...</pre>
15
+
@else
16
+
<pre id="post-@{post.id}">@post.body</pre>
17
+
@end
7
18
@end
19
+
8
20
<p>likes: @{app.get_net_likes_for_post(post.id)} | posted at: @post.posted_at | <a href="/post/@post.id">view full post</a></p>
9
21
</div>
+24
-7
src/templates/edit.html
+24
-7
src/templates/edit.html
···
7
7
<h1>edit post</h1>
8
8
9
9
<div class="post post-full">
10
-
<form action="/api/post/edit" method="post">
10
+
<form action="/api/post/edit" method="post" beep-redirect="/post/@post.id">
11
11
<input
12
12
type="number"
13
13
name="id"
···
47
47
>@post.body</textarea>
48
48
<br>
49
49
50
+
@if app.config.post.allow_nsfw
51
+
<div>
52
+
<label for="nsfw">is nsfw:</label>
53
+
<input
54
+
type="checkbox"
55
+
name="nsfw"
56
+
id="nsfw"
57
+
@if post.nsfw
58
+
checked aria-checked
59
+
@end
60
+
/>
61
+
</div>
62
+
<br>
63
+
@else
64
+
<input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden />
65
+
@end
66
+
50
67
<input type="submit" value="save">
51
68
</form>
52
-
53
-
<script>
54
-
add_character_counter('title', 'title_chars', @{app.config.post.title_max_len})
55
-
add_character_counter('body', 'body_chars', @{app.config.post.body_max_len})
56
-
</script>
57
69
</div>
58
70
59
71
<hr>
60
72
61
73
<div>
62
74
<h2>danger zone:</h2>
63
-
<form action="/api/post/delete" method="post">
75
+
<form action="/api/post/delete" method="post" beep-redirect="/">
64
76
<input
65
77
type="number"
66
78
name="id"
···
74
86
<input type="submit" value="delete">
75
87
</form>
76
88
</div>
89
+
90
+
<script>
91
+
add_character_counter('title', 'title_chars', @{app.config.post.title_max_len})
92
+
add_character_counter('body', 'body_chars', @{app.config.post.body_max_len})
93
+
</script>
77
94
78
95
@include 'partial/footer.html'
+10
-3
src/templates/inbox.html
+10
-3
src/templates/inbox.html
···
9
9
@if notifications.len == 0
10
10
<p>your inbox is empty!</p>
11
11
@else
12
-
<a href="/api/user/notification/clear_all">clear all</a>
12
+
<form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox">
13
+
<button>clear all</button>
14
+
</form>
13
15
<hr>
14
16
@for notification in notifications.reverse()
15
17
<div class="notification">
16
-
<p><strong>@notification.summary</strong></p>
18
+
<div style="display: flex; flex-direction: row; align-items: center; gap: 12px;">
19
+
<p><strong>@notification.summary</strong></p>
20
+
<form action="/api/user/notification/clear" method="post" beep-redirect="/inbox" class="form-inline" style="display: inline;">
21
+
<input type="number" value="@{notification.id}" name="id" required aria-required hidden aria-hidden readonly aria-readonly />
22
+
<button style="display: inline;">clear</button>
23
+
</form>
24
+
</div>
17
25
<pre id="notif-@{notification.id}">@notification.body</pre>
18
-
<a href="/api/user/notification/clear?id=@{notification.id}">clear</a>
19
26
<script>
20
27
render_body('notif-@{notification.id}')
21
28
</script>
+4
-4
src/templates/index.html
+4
-4
src/templates/index.html
···
8
8
9
9
<div>
10
10
@if pinned_posts.len > 0
11
-
<h2>pinned posts:</h2>
12
-
<div>
11
+
<div id="pinned-posts">
12
+
<h2>pinned posts:</h2>
13
13
@for post in pinned_posts
14
14
@include 'components/post_small.html'
15
15
@end
···
17
17
<br>
18
18
@end
19
19
20
-
<h2>recent posts:</h2>
21
-
<div>
20
+
<div id="recent-posts">
21
+
<h2>recent posts:</h2>
22
22
@if recent_posts.len > 0
23
23
@for post in recent_posts
24
24
@include 'components/post_small.html'
+2
-2
src/templates/login.html
+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
+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
+13
-9
src/templates/partial/header.html
+13
-9
src/templates/partial/header.html
···
1
1
<!DOCTYPE html>
2
-
<html>
2
+
<html lang="en">
3
3
4
4
<head>
5
5
<meta charset="utf-8" />
6
-
<meta name="description" content="" />
7
-
<link rel="icon" href="/favicon.png" />
8
6
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
+
<meta name="description" content="" />
8
+
9
9
<title>@ctx.title</title>
10
10
11
11
@include 'assets/style.html'
···
17
17
@endif
18
18
19
19
<link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32">
20
+
21
+
@if ctx.is_logged_in() && user.css != ''
22
+
<style>@{user.css}</style>
23
+
@else
24
+
<style>@{app.config.instance.default_css}</style>
25
+
@end
26
+
27
+
<script src="/static/js/notify.js" defer></script>
28
+
<script src="/static/js/form.js" defer></script>
20
29
</head>
21
30
22
31
<body>
···
47
56
</header>
48
57
49
58
<main>
50
-
<!-- TODO: fix this lol -->
51
-
@if ctx.form_error != ''
52
-
<div>
53
-
<p><strong>error:</strong> @ctx.form_error</p>
54
-
</div>
55
-
@end
59
+
<div id="errors"></div>
+48
-17
src/templates/post.html
+48
-17
src/templates/post.html
···
12
12
@else
13
13
replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a>
14
14
@end
15
+
@if post.nsfw
16
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
17
+
@end
15
18
</h2>
19
+
20
+
<hr>
21
+
22
+
@if post.nsfw
23
+
<details>
24
+
<summary>click to show post (nsfw)</summary>
25
+
<pre id="post-@{post.id}">@post.body</pre>
26
+
</details>
27
+
@else
16
28
<pre id="post-@{post.id}">@post.body</pre>
29
+
@end
30
+
31
+
<hr>
32
+
17
33
<p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p>
18
34
<p><em>posted at: @post.posted_at</em></p>
19
35
20
36
@if ctx.is_logged_in() && !user.automated
37
+
<br>
21
38
<p><a href="/post/@{post.id}/reply">reply</a></p>
22
39
<br>
23
40
<div>
···
58
75
59
76
@if post.author_id == user.id
60
77
<h4>manage post:</h4>
61
-
@else if user.admin
62
-
<h4>admin powers:</h4>
63
-
@end
64
78
65
-
@if post.author_id == user.id
66
79
<p><a href="/post/@{post.id}/edit">edit</a></p>
67
80
@end
68
81
69
82
@if user.admin
70
-
<form action="/api/post/pin" method="post">
71
-
<input
72
-
type="number"
73
-
name="id"
74
-
id="id"
75
-
placeholder="post id"
76
-
value="@post.id"
77
-
required aria-required
78
-
readonly aria-readonly
79
-
hidden aria-hidden
80
-
>
81
-
<input type="submit" value="pin">
82
-
</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>
83
114
@end
84
115
85
116
</div>
+32
-3
src/templates/register.html
+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'
+3
src/templates/saved_posts.html
+3
src/templates/saved_posts.html
···
16
16
<p>
17
17
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
18
18
<a href="/post/@post.id">@post.title</a>
19
+
@if post.nsfw
20
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
21
+
@end
19
22
<button onclick="save(@post.id)" style="display: inline-block;">unsave</button>
20
23
</p>
21
24
</div>
+3
src/templates/saved_posts_for_later.html
+3
src/templates/saved_posts_for_later.html
···
16
16
<p>
17
17
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
18
18
<a href="/post/@post.id">@post.title</a>
19
+
@if post.nsfw
20
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
21
+
@end
19
22
<button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button>
20
23
</p>
21
24
</div>
+8
src/templates/search.html
+8
src/templates/search.html
···
67
67
post_link.innerText = result.post.title
68
68
p.appendChild(post_link)
69
69
70
+
if (result.post.nsfw)
71
+
{
72
+
const nsfw_indicator = document.createElement('span')
73
+
nsfw_indicator.classList.add('nsfw-indicator')
74
+
nsfw_indicator.innerHTML = '(<em>nsfw</em>)';
75
+
p.appendChild(nsfw_indicator)
76
+
}
77
+
70
78
element.appendChild(p)
71
79
results.appendChild(element)
72
80
}
+44
-17
src/templates/settings.html
+44
-17
src/templates/settings.html
···
2
2
3
3
@if ctx.is_logged_in()
4
4
<script src="/static/js/text_area_counter.js"></script>
5
+
<script src="/static/js/password.js"></script>
5
6
6
7
<h1>user settings:</h1>
7
8
···
15
16
rows="10"
16
17
minlength="@app.config.user.bio_min_len"
17
18
maxlength="@app.config.user.bio_max_len"
18
-
required aria-required
19
19
>@user.bio</textarea>
20
20
<br>
21
21
<input type="submit" value="save">
···
33
33
maxlength="@app.config.user.pronouns_max_len"
34
34
pattern="@app.config.user.pronouns_pattern"
35
35
value="@user.pronouns"
36
-
required aria-required
37
36
>
38
37
<input type="submit" value="save">
39
38
</form>
···
50
49
minlength="@app.config.user.nickname_min_len"
51
50
maxlength="@app.config.user.nickname_max_len"
52
51
value="@{user.nickname or { '' }}"
53
-
required aria-required
54
52
>
55
53
<input type="submit" value="save">
56
54
</form>
···
70
68
71
69
<form action="/api/user/set_theme" method="post">
72
70
<label for="url">theme:</label>
73
-
<input type="url" name="url" id="url" value="@user.theme">
71
+
<input type="text" name="url" id="url" value="@user.theme">
72
+
<input type="submit" value="save">
73
+
</form>
74
+
75
+
<hr>
76
+
77
+
<form action="/api/user/set_css" method="post">
78
+
<label for="css">custom css:</label>
79
+
<br>
80
+
<textarea type="text" name="css" id="css" style="font: monospace;">@user.css</textarea>
74
81
<input type="submit" value="save">
75
82
</form>
76
83
@end
···
95
102
<hr>
96
103
97
104
<form action="/api/user/set_automated" method="post">
98
-
<label for="is_automated">is automated:</label>
99
-
<input
100
-
type="checkbox"
101
-
name="is_automated"
102
-
id="is_automated"
103
-
value="true"
104
-
@if user.automated
105
-
checked aria-checked
106
-
@end
107
-
>
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>
108
117
<input type="submit" value="save">
109
118
<p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p>
110
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>
···
119
128
120
129
<details>
121
130
<summary>change password (click to reveal)</summary>
122
-
<form action="/api/user/set_password" method="post">
131
+
<form action="/api/user/set_password" method="post" beep-redirect="/login">
123
132
<p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p>
124
133
<label for="current_password">current password:</label>
125
134
<input
···
133
142
autocomplete="off" aria-autocomplete="off"
134
143
>
135
144
<br>
136
-
<label for="new_password">new password:</label>
145
+
<label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label>
137
146
<input
138
147
type="password"
139
148
name="new_password"
···
144
153
required aria-required
145
154
autocomplete="off" aria-autocomplete="off"
146
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>
147
170
<input type="submit" value="save">
148
171
</form>
149
172
</details>
···
152
175
153
176
<details>
154
177
<summary>account deletion (click to reveal)</summary>
155
-
<form action="/api/user/delete" autocomplete="off">
178
+
<form action="/api/user/delete" autocomplete="off" beep-redirect="/">
156
179
<input
157
180
type="number"
158
181
name="id"
···
186
209
</form>
187
210
</details>
188
211
</details>
212
+
213
+
<script>
214
+
add_password_checkers('new_password', 'confirm_password', 'passwords-match');
215
+
</script>
189
216
190
217
@else
191
218
<p>uh oh, you need to be logged in to view this page!</p>
+1
-42
src/templates/user.html
+1
-42
src/templates/user.html
···
23
23
24
24
@if app.logged_in_as(mut ctx, viewing.id)
25
25
<p>this is you!</p>
26
-
27
26
@if !user.automated
28
-
<script src="/static/js/text_area_counter.js"></script>
29
-
<div>
30
-
<form action="/api/post/new_post" method="post">
31
-
<h2>new post:</h2>
32
-
33
-
<p id="title_chars">0/@{app.config.post.title_max_len}</p>
34
-
<input
35
-
type="text"
36
-
name="title"
37
-
id="title"
38
-
minlength="@app.config.post.title_min_len"
39
-
maxlength="@app.config.post.title_max_len"
40
-
pattern="@app.config.post.title_pattern"
41
-
placeholder="title"
42
-
required aria-required
43
-
autocomplete="off" aria-autocomplete="off"
44
-
>
45
-
<br>
46
-
47
-
<p id="body_chars">0/@{app.config.post.body_max_len}</p>
48
-
<textarea
49
-
name="body"
50
-
id="body"
51
-
minlength="@app.config.post.body_min_len"
52
-
maxlength="@app.config.post.body_max_len"
53
-
rows="10"
54
-
cols="30"
55
-
placeholder="body"
56
-
required aria-required
57
-
autocomplete="off" aria-autocomplete="off"
58
-
></textarea>
59
-
<br>
60
-
61
-
<input type="submit" value="post!">
62
-
</form>
63
-
64
-
<script>
65
-
add_character_counter('title', 'title_chars', @{app.config.post.title_max_len})
66
-
add_character_counter('body', 'body_chars', @{app.config.post.body_max_len})
67
-
</script>
68
-
</div>
27
+
@include 'components/new_post.html'
69
28
<hr>
70
29
@end
71
30
@end
+13
src/util/row.v
+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
+
}
+234
-186
src/webapp/api.v
+234
-186
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
6
import database { PostSearchResult }
7
+
import net.http
8
+
import json
7
9
8
10
// search_hard_limit is the maximum limit for a search query, used to prevent
9
11
// people from requesting searches with huge limits and straining the SQL server
10
-
pub const search_hard_limit := 50
12
+
pub const search_hard_limit = 50
13
+
pub const not_logged_in_msg = 'you are not logged in!'
11
14
12
15
////// user //////
13
16
17
+
struct HcaptchaResponse {
18
+
pub:
19
+
success bool
20
+
error_codes []string @[json: 'error-codes']
21
+
}
22
+
14
23
@['/api/user/register'; post]
15
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
+
16
47
if app.get_user_by_name(username) != none {
17
-
ctx.error('username taken')
18
-
return ctx.redirect('/register')
48
+
return ctx.server_error('username taken')
19
49
}
20
50
21
51
// validate username
22
52
if !app.validators.username.validate(username) {
23
-
ctx.error('invalid username')
24
-
return ctx.redirect('/register')
53
+
return ctx.server_error('invalid username')
25
54
}
26
55
27
56
// validate password
28
57
if !app.validators.password.validate(password) {
29
-
ctx.error('invalid password')
30
-
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')
31
63
}
32
64
33
65
salt := auth.generate_salt()
···
42
74
}
43
75
44
76
if x := app.new_user(user) {
45
-
app.send_notification_to(
46
-
x.id,
47
-
app.config.welcome.summary.replace('%s', x.get_name()),
48
-
app.config.welcome.body.replace('%s', x.get_name())
49
-
)
50
-
token := app.auth.add_token(x.id, ctx.ip()) or {
51
-
eprintln(err)
52
-
ctx.error('api_user_register: could not create token for user with id ${x.id}')
53
-
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')
54
82
}
55
83
ctx.set_cookie(
56
84
name: 'token'
···
61
89
)
62
90
} else {
63
91
eprintln('api_user_register: could not log into newly-created user: ${user}')
64
-
ctx.error('could not log into newly-created user.')
92
+
return ctx.server_error('could not log into newly-created user.')
65
93
}
66
94
67
-
return ctx.redirect('/')
95
+
return ctx.ok('user registered')
68
96
}
69
97
70
98
@['/api/user/set_username'; post]
71
99
fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result {
72
100
user := app.whoami(mut ctx) or {
73
-
ctx.error('you are not logged in!')
74
-
return ctx.redirect('/login')
101
+
return ctx.unauthorized(not_logged_in_msg)
75
102
}
76
103
77
104
if app.get_user_by_name(new_username) != none {
78
-
ctx.error('username taken')
79
-
return ctx.redirect('/settings')
105
+
return ctx.server_error('username taken')
80
106
}
81
107
82
108
// validate username
83
109
if !app.validators.username.validate(new_username) {
84
-
ctx.error('invalid username')
85
-
return ctx.redirect('/settings')
110
+
return ctx.server_error('invalid username')
86
111
}
87
112
88
113
if !app.set_username(user.id, new_username) {
89
-
ctx.error('failed to update username')
114
+
return ctx.server_error('failed to update username')
90
115
}
91
116
92
-
return ctx.redirect('/settings')
117
+
return ctx.ok('username updated')
93
118
}
94
119
95
120
@['/api/user/set_password'; post]
96
121
fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result {
97
122
user := app.whoami(mut ctx) or {
98
-
ctx.error('you are not logged in!')
99
-
return ctx.redirect('/login')
123
+
return ctx.unauthorized(not_logged_in_msg)
100
124
}
101
125
102
126
if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) {
103
-
ctx.error('current_password is incorrect')
104
-
return ctx.redirect('/settings')
127
+
return ctx.server_error('current_password is incorrect')
105
128
}
106
129
107
130
// validate password
108
131
if !app.validators.password.validate(new_password) {
109
-
ctx.error('invalid password')
110
-
return ctx.redirect('/settings')
132
+
return ctx.server_error('invalid password')
133
+
}
134
+
135
+
if new_password != ctx.form['confirm_password'] {
136
+
return ctx.server_error('passwords do not match')
111
137
}
112
138
113
139
hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt)
114
140
if !app.set_password(user.id, hashed_new_password) {
115
-
ctx.error('failed to update password')
116
-
return ctx.redirect('/settings')
141
+
return ctx.server_error('failed to update password')
117
142
}
118
143
119
144
// invalidate tokens and log out
120
145
app.auth.delete_tokens_for_user(user.id) or {
121
146
eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})')
122
-
return ctx.redirect('/settings')
147
+
return ctx.server_error('failed to delete tokens during password deletion')
123
148
}
124
149
ctx.set_cookie(
125
150
name: 'token'
···
129
154
path: '/'
130
155
)
131
156
132
-
return ctx.redirect('/login')
157
+
return ctx.ok('password updated')
133
158
}
134
159
135
160
@['/api/user/login'; post]
136
161
fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result {
137
162
user := app.get_user_by_name(username) or {
138
-
ctx.error('invalid credentials')
139
-
return ctx.redirect('/login')
163
+
return ctx.server_error('invalid credentials')
140
164
}
141
165
142
166
if !auth.compare_password_with_hash(password, user.password_salt, user.password) {
143
-
ctx.error('invalid credentials')
144
-
return ctx.redirect('/login')
167
+
return ctx.server_error('invalid credentials')
145
168
}
146
169
147
-
token := app.auth.add_token(user.id, ctx.ip()) or {
170
+
token := app.auth.add_token(user.id) or {
148
171
eprintln('failed to add token on log in: ${err}')
149
-
ctx.error('could not create token for user with id ${user.id}')
150
-
return ctx.redirect('/login')
172
+
return ctx.server_error('could not create token for user with id ${user.id}')
151
173
}
152
174
153
175
ctx.set_cookie(
···
158
180
path: '/'
159
181
)
160
182
161
-
return ctx.redirect('/')
183
+
return ctx.ok('logged in')
162
184
}
163
185
164
-
@['/api/user/logout']
186
+
@['/api/user/logout'; post]
165
187
fn (mut app App) api_user_logout(mut ctx Context) veb.Result {
166
188
if token := ctx.get_cookie('token') {
167
-
if user := app.get_user_by_token(ctx, token) {
168
-
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 {
169
195
eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})')
170
-
return ctx.redirect('/login')
171
196
}
172
197
} else {
173
198
eprintln('failed to get user for token for logout')
···
184
209
path: '/'
185
210
)
186
211
187
-
return ctx.redirect('/login')
212
+
return ctx.ok('logged out')
188
213
}
189
214
190
-
@['/api/user/full_logout']
215
+
@['/api/user/full_logout'; post]
191
216
fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result {
192
217
if token := ctx.get_cookie('token') {
193
-
if user := app.get_user_by_token(ctx, token) {
218
+
if user := app.get_user_by_token(token) {
194
219
app.auth.delete_tokens_for_user(user.id) or {
195
220
eprintln('failed to yeet tokens for ${user.id}')
196
-
return ctx.redirect('/login')
197
221
}
198
222
} else {
199
223
eprintln('failed to get user for token for full_logout')
···
210
234
path: '/'
211
235
)
212
236
213
-
return ctx.redirect('/login')
237
+
return ctx.ok('logged out')
214
238
}
215
239
216
240
@['/api/user/set_nickname'; post]
217
241
fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result {
218
242
user := app.whoami(mut ctx) or {
219
-
ctx.error('you are not logged in!')
220
-
return ctx.redirect('/login')
243
+
return ctx.unauthorized(not_logged_in_msg)
221
244
}
222
245
223
246
mut clean_nickname := ?string(nickname.trim_space())
···
227
250
228
251
// validate
229
252
if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
230
-
ctx.error('invalid nickname')
231
-
return ctx.redirect('/me')
253
+
return ctx.server_error('invalid nickname')
232
254
}
233
255
234
256
if !app.set_nickname(user.id, clean_nickname) {
235
257
eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
236
-
return ctx.redirect('/me')
258
+
return ctx.server_error('failed to update nickname')
237
259
}
238
260
239
-
return ctx.redirect('/me')
261
+
return ctx.ok('updated nickname')
240
262
}
241
263
242
264
@['/api/user/set_muted'; post]
243
265
fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result {
244
266
user := app.whoami(mut ctx) or {
245
-
ctx.error('you are not logged in!')
246
-
return ctx.redirect('/login')
267
+
return ctx.unauthorized(not_logged_in_msg)
247
268
}
248
269
249
270
to_mute := app.get_user_by_id(id) or {
250
-
ctx.error('no such user')
251
-
return ctx.redirect('/')
271
+
return ctx.server_error('no such user')
252
272
}
253
273
254
274
if user.admin {
255
275
if !app.set_muted(to_mute.id, muted) {
256
-
ctx.error('failed to change mute status')
257
-
return ctx.redirect('/user/${to_mute.username}')
276
+
return ctx.server_error('failed to change mute status')
258
277
}
259
-
return ctx.redirect('/user/${to_mute.username}')
278
+
return ctx.ok('muted user')
260
279
} else {
261
-
ctx.error('insufficient permissions!')
262
280
eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})')
263
-
return ctx.redirect('/user/${to_mute.username}')
281
+
return ctx.unauthorized('insufficient permissions')
264
282
}
265
283
}
266
284
267
285
@['/api/user/set_automated'; post]
268
286
fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result {
269
287
user := app.whoami(mut ctx) or {
270
-
ctx.error('you are not logged in!')
271
-
return ctx.redirect('/login')
288
+
return ctx.unauthorized(not_logged_in_msg)
272
289
}
273
290
274
291
if !app.set_automated(user.id, is_automated) {
275
-
ctx.error('failed to set automated status.')
292
+
return ctx.server_error('failed to set automated status.')
276
293
}
277
294
278
-
return ctx.redirect('/me')
295
+
if is_automated {
296
+
return ctx.ok('you\'re now a bot! :D')
297
+
} else {
298
+
return ctx.ok('you\'re no longer a bot :(')
299
+
}
279
300
}
280
301
281
302
@['/api/user/set_theme'; post]
282
303
fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
283
304
if !app.config.instance.allow_changing_theme {
284
-
ctx.error('this instance disallows changing themes :(')
285
-
return ctx.redirect('/me')
305
+
return ctx.server_error('this instance disallows changing themes :(')
286
306
}
287
307
288
308
user := app.whoami(mut ctx) or {
289
-
ctx.error('you are not logged in!')
290
-
return ctx.redirect('/login')
309
+
return ctx.unauthorized(not_logged_in_msg)
291
310
}
292
311
293
312
mut theme := ?string(none)
294
-
if url.trim_space() != '' {
313
+
if url.trim_space() == '' {
314
+
theme = app.config.instance.default_theme
315
+
} else {
295
316
theme = url.trim_space()
296
317
}
297
318
298
319
if !app.set_theme(user.id, theme) {
299
-
ctx.error('failed to change theme')
300
-
return ctx.redirect('/me')
320
+
return ctx.server_error('failed to change theme')
301
321
}
302
322
303
-
return ctx.redirect('/me')
323
+
return ctx.ok('theme updated')
324
+
}
325
+
326
+
@['/api/user/set_css'; post]
327
+
fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result {
328
+
if !app.config.instance.allow_changing_theme {
329
+
return ctx.server_error('this instance disallows changing themes :(')
330
+
}
331
+
332
+
user := app.whoami(mut ctx) or {
333
+
return ctx.unauthorized(not_logged_in_msg)
334
+
}
335
+
336
+
c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() }
337
+
338
+
if !app.set_css(user.id, c) {
339
+
return ctx.server_error('failed to change css')
340
+
}
341
+
342
+
return ctx.ok('css updated')
304
343
}
305
344
306
345
@['/api/user/set_pronouns'; post]
307
346
fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result {
308
347
user := app.whoami(mut ctx) or {
309
-
ctx.error('you are not logged in!')
310
-
return ctx.redirect('/login')
348
+
return ctx.unauthorized(not_logged_in_msg)
311
349
}
312
350
313
351
clean_pronouns := pronouns.trim_space()
314
352
if !app.validators.pronouns.validate(clean_pronouns) {
315
-
ctx.error('invalid pronouns')
316
-
return ctx.redirect('/me')
353
+
return ctx.server_error('invalid pronouns')
317
354
}
318
355
319
356
if !app.set_pronouns(user.id, clean_pronouns) {
320
-
ctx.error('failed to change pronouns')
321
-
return ctx.redirect('/me')
357
+
return ctx.server_error('failed to change pronouns')
322
358
}
323
359
324
-
return ctx.redirect('/me')
360
+
return ctx.ok('pronouns updated')
325
361
}
326
362
327
363
@['/api/user/set_bio'; post]
328
364
fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result {
329
365
user := app.whoami(mut ctx) or {
330
-
ctx.error('you are not logged in!')
331
-
return ctx.redirect('/login')
366
+
return ctx.unauthorized(not_logged_in_msg)
332
367
}
333
368
334
369
clean_bio := bio.trim_space()
335
370
if !app.validators.user_bio.validate(clean_bio) {
336
-
ctx.error('invalid bio')
337
-
return ctx.redirect('/me')
371
+
return ctx.server_error('invalid bio')
338
372
}
339
373
340
374
if !app.set_bio(user.id, clean_bio) {
341
375
eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
342
-
return ctx.redirect('/me')
376
+
return ctx.server_error('failed to update bio')
343
377
}
344
378
345
-
return ctx.redirect('/me')
379
+
return ctx.ok('bio updated')
346
380
}
347
381
348
-
@['/api/user/get_name']
382
+
@['/api/user/get_name'; get]
349
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
+
}
350
389
user := app.get_user_by_name(username) or { return ctx.server_error('no such user') }
351
390
return ctx.text(user.get_name())
352
391
}
353
392
354
-
@['/api/user/delete']
393
+
@['/api/user/delete'; post]
355
394
fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result {
356
395
user := app.whoami(mut ctx) or {
357
-
ctx.error('you are not logged in!')
358
-
return ctx.redirect('/login')
396
+
return ctx.unauthorized(not_logged_in_msg)
359
397
}
360
398
361
-
println('attempting to delete ${id} as ${user.id}')
399
+
if user.admin || user.id == id {
400
+
println('attempting to delete ${id} as ${user.id}')
362
401
363
-
if user.admin || user.id == id {
364
402
// yeet
365
403
if !app.delete_user(user.id) {
366
-
ctx.error('failed to delete user: ${id}')
367
-
return ctx.redirect('/')
404
+
return ctx.server_error('failed to delete user: ${id}')
368
405
}
369
406
370
407
app.auth.delete_tokens_for_user(id) or {
···
381
418
)
382
419
}
383
420
println('deleted user ${id}')
421
+
return ctx.ok('user deleted')
384
422
} else {
385
-
ctx.error('be nice. deleting other users is off-limits.')
423
+
return ctx.unauthorized('be nice. deleting other users is off-limits.')
386
424
}
387
-
388
-
return ctx.redirect('/')
389
425
}
390
426
391
427
@['/api/user/search'; get]
392
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) }
393
430
if limit >= search_hard_limit {
394
-
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
431
+
return ctx.server_error('limit exceeds hard limit (${search_hard_limit})')
395
432
}
396
433
users := app.search_for_users(query, limit, offset)
397
434
return ctx.json[[]User](users)
398
435
}
399
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
+
400
445
/// user/notification ///
401
446
402
-
@['/api/user/notification/clear']
447
+
@['/api/user/notification/clear'; post]
403
448
fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result {
404
449
user := app.whoami(mut ctx) or {
405
-
ctx.error('you are not logged in!')
406
-
return ctx.redirect('/login')
450
+
return ctx.unauthorized(not_logged_in_msg)
407
451
}
408
452
409
453
if notification := app.get_notification_by_id(id) {
410
454
if notification.user_id != user.id {
411
-
ctx.error('no such notification for user')
412
-
return ctx.redirect('/inbox')
413
-
} else {
414
-
if !app.delete_notification(id) {
415
-
ctx.error('failed to delete notification')
416
-
return ctx.redirect('/inbox')
417
-
}
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')
418
458
}
419
459
} else {
420
-
ctx.error('no such notification for user')
460
+
return ctx.server_error('no such notification for user')
421
461
}
422
462
423
-
return ctx.redirect('/inbox')
463
+
return ctx.ok('cleared notification')
424
464
}
425
465
426
-
@['/api/user/notification/clear_all']
466
+
@['/api/user/notification/clear_all'; post]
427
467
fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result {
428
468
user := app.whoami(mut ctx) or {
429
-
ctx.error('you are not logged in!')
430
-
return ctx.redirect('/login')
469
+
return ctx.unauthorized(not_logged_in_msg)
431
470
}
432
471
if !app.delete_notifications_for_user(user.id) {
433
-
ctx.error('failed to delete notifications')
434
-
return ctx.redirect('/inbox')
472
+
return ctx.server_error('failed to delete notifications')
435
473
}
436
-
return ctx.redirect('/inbox')
474
+
return ctx.ok('cleared notifications')
437
475
}
438
476
439
477
////// post //////
···
441
479
@['/api/post/new_post'; post]
442
480
fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result {
443
481
user := app.whoami(mut ctx) or {
444
-
ctx.error('not logged in!')
445
-
return ctx.redirect('/login')
482
+
return ctx.unauthorized(not_logged_in_msg)
446
483
}
447
484
448
485
if user.muted {
449
-
ctx.error('you are muted!')
450
-
return ctx.redirect('/post/new')
486
+
return ctx.server_error('you are muted!')
451
487
}
452
488
453
489
// validate title
454
490
if !app.validators.post_title.validate(title) {
455
-
ctx.error('invalid title')
456
-
return ctx.redirect('/post/new')
491
+
return ctx.server_error('invalid title')
457
492
}
458
493
459
494
// validate body
460
495
if !app.validators.post_body.validate(body) {
461
-
ctx.error('invalid body')
462
-
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')
463
502
}
464
503
465
504
mut post := Post{
466
505
author_id: user.id
467
506
title: title
468
507
body: body
508
+
nsfw: nsfw
469
509
}
470
510
471
511
if replying_to != 0 {
472
512
// check if replying post exists
473
513
app.get_post_by_id(replying_to) or {
474
-
ctx.error('the post you are trying to reply to does not exist')
475
-
return ctx.redirect('/post/new')
514
+
return ctx.server_error('the post you are trying to reply to does not exist')
476
515
}
477
516
post.replying_to = replying_to
478
517
}
479
518
480
519
if !app.add_post(post) {
481
-
ctx.error('failed to post!')
482
520
println('failed to post: ${post} from user ${user.id}')
483
-
return ctx.redirect('/post/new')
521
+
return ctx.server_error('failed to post')
484
522
}
485
523
524
+
//TODO: Can I not just get the ID directly?? This method feels dicey at best.
486
525
// find the post's id to process mentions with
487
526
if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) {
488
527
app.process_post_mentions(x)
489
-
return ctx.redirect('/post/${x.id}')
528
+
return ctx.ok('posted. id=${x.id}')
490
529
} else {
491
-
ctx.error('failed to get_post_by_timestamp_and_author for ${post}')
492
-
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')
493
532
}
494
533
}
495
534
496
535
@['/api/post/delete'; post]
497
536
fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result {
498
537
user := app.whoami(mut ctx) or {
499
-
ctx.error('not logged in!')
500
-
return ctx.redirect('/login')
538
+
return ctx.unauthorized(not_logged_in_msg)
501
539
}
502
540
503
541
post := app.get_post_by_id(id) or {
504
-
ctx.error('post does not exist')
505
-
return ctx.redirect('/')
542
+
return ctx.server_error('post does not exist')
506
543
}
507
544
508
545
if user.admin || user.id == post.author_id {
509
546
if !app.delete_post(post.id) {
510
-
ctx.error('failed to delete post')
511
-
eprintln('failed to delete post: ${id}')
512
-
return ctx.redirect('/')
547
+
eprintln('api_post_delete: failed to delete post: ${id}')
548
+
return ctx.server_error('failed to delete post')
513
549
}
514
550
println('deleted post: ${id}')
515
-
return ctx.redirect('/')
551
+
return ctx.ok('post deleted')
516
552
} else {
517
-
ctx.error('insufficient permissions!')
518
553
eprintln('insufficient perms to delete post: ${id} (${user.id})')
519
-
return ctx.redirect('/')
554
+
return ctx.unauthorized('insufficient permissions')
520
555
}
521
556
}
522
557
523
-
@['/api/post/like']
558
+
@['/api/post/like'; post]
524
559
fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result {
525
-
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
+
}
526
563
527
-
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
+
}
528
567
529
568
if app.does_user_like_post(user.id, post.id) {
530
569
if !app.unlike_post(post.id, user.id) {
···
553
592
}
554
593
}
555
594
556
-
@['/api/post/dislike']
595
+
@['/api/post/dislike'; post]
557
596
fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result {
558
-
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
+
}
559
600
560
-
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
+
}
561
604
562
605
if app.does_user_dislike_post(user.id, post.id) {
563
606
if !app.unlike_post(post.id, user.id) {
···
586
629
}
587
630
}
588
631
589
-
@['/api/post/save']
632
+
@['/api/post/save'; post]
590
633
fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result {
591
-
user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
634
+
user := app.whoami(mut ctx) or {
635
+
return ctx.unauthorized(not_logged_in_msg)
636
+
}
592
637
593
638
if app.get_post_by_id(id) != none {
594
639
if app.toggle_save_post(user.id, id) {
···
601
646
}
602
647
}
603
648
604
-
@['/api/post/save_for_later']
649
+
@['/api/post/save_for_later'; post]
605
650
fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result {
606
-
user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
651
+
user := app.whoami(mut ctx) or {
652
+
return ctx.unauthorized(not_logged_in_msg)
653
+
}
607
654
608
655
if app.get_post_by_id(id) != none {
609
656
if app.toggle_save_for_later_post(user.id, id) {
···
616
663
}
617
664
}
618
665
619
-
@['/api/post/get_title']
666
+
@['/api/post/get_title'; get]
620
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
+
}
621
671
post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
622
672
return ctx.text(post.title)
623
673
}
···
625
675
@['/api/post/edit'; post]
626
676
fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result {
627
677
user := app.whoami(mut ctx) or {
628
-
ctx.error('not logged in!')
629
-
return ctx.redirect('/login')
678
+
return ctx.unauthorized(not_logged_in_msg)
630
679
}
631
680
post := app.get_post_by_id(id) or {
632
-
ctx.error('no such post')
633
-
return ctx.redirect('/')
681
+
return ctx.server_error('no such post')
634
682
}
635
683
if post.author_id != user.id {
636
-
ctx.error('insufficient permissions')
637
-
return ctx.redirect('/')
684
+
return ctx.unauthorized('insufficient permissions')
638
685
}
639
686
640
-
if !app.update_post(id, title, body) {
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) {
641
694
eprintln('failed to update post')
642
-
ctx.error('failed to update post')
643
-
return ctx.redirect('/')
695
+
return ctx.server_error('failed to update post')
644
696
}
645
697
646
-
return ctx.redirect('/post/${id}')
698
+
return ctx.ok('posted edited')
647
699
}
648
700
649
701
@['/api/post/pin'; post]
650
702
fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result {
651
703
user := app.whoami(mut ctx) or {
652
-
ctx.error('not logged in!')
653
-
return ctx.redirect('/login')
704
+
return ctx.unauthorized(not_logged_in_msg)
654
705
}
655
706
656
707
if user.admin {
657
708
if !app.pin_post(id) {
658
709
eprintln('failed to pin post: ${id}')
659
-
ctx.error('failed to pin post')
660
-
return ctx.redirect('/post/${id}')
710
+
return ctx.server_error('failed to pin post')
661
711
}
662
-
return ctx.redirect('/post/${id}')
712
+
return ctx.ok('post pinned')
663
713
} else {
664
-
ctx.error('insufficient permissions!')
665
714
eprintln('insufficient perms to pin post: ${id} (${user.id})')
666
-
return ctx.redirect('/')
715
+
return ctx.unauthorized('insufficient permissions')
667
716
}
668
717
}
669
718
670
719
@['/api/post/get/<id>'; get]
671
720
fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
672
-
post := app.get_post_by_id(id) or {
673
-
return ctx.text('no such post')
721
+
if !app.config.instance.public_data {
722
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) }
674
723
}
724
+
post := app.get_post_by_id(id) or { return ctx.text('no such post') }
675
725
return ctx.json[Post](post)
676
726
}
677
727
678
728
@['/api/post/search'; get]
679
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) }
680
731
if limit >= search_hard_limit {
681
732
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
682
733
}
···
689
740
@['/api/site/set_motd'; post]
690
741
fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
691
742
user := app.whoami(mut ctx) or {
692
-
ctx.error('not logged in!')
693
-
return ctx.redirect('/login')
743
+
return ctx.unauthorized(not_logged_in_msg)
694
744
}
695
745
696
746
if user.admin {
697
747
if !app.set_motd(motd) {
698
-
ctx.error('failed to set motd')
699
748
eprintln('failed to set motd: ${motd}')
700
-
return ctx.redirect('/')
749
+
return ctx.server_error('failed to set motd')
701
750
}
702
751
println('set motd to: ${motd}')
703
-
return ctx.redirect('/')
752
+
return ctx.ok('motd updated')
704
753
} else {
705
-
ctx.error('insufficient permissions!')
706
754
eprintln('insufficient perms to set motd to: ${motd} (${user.id})')
707
-
return ctx.redirect('/')
755
+
return ctx.unauthorized('insufficient permissions')
708
756
}
709
757
}
+15
-9
src/webapp/app.v
+15
-9
src/webapp/app.v
···
11
11
veb.StaticHandler
12
12
DatabaseAccess
13
13
pub:
14
-
config Config
15
-
commit string = @VMODHASH
16
-
built_at string = @BUILD_TIMESTAMP
17
-
v_hash string = @VHASH
14
+
config Config
15
+
buildinfo BuildInfo
16
+
built_at string = @BUILD_TIMESTAMP
17
+
v_hash string = @VHASH
18
18
pub mut:
19
19
auth auth.Auth[pg.DB]
20
20
validators struct {
···
31
31
32
32
// get_user_by_token returns a user by their token, returns none if the user was
33
33
// not found.
34
-
pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User {
35
-
user_token := app.auth.find_token(token, ctx.ip()) or {
34
+
pub fn (app &App) get_user_by_token(token string) ?User {
35
+
user_token := app.auth.find_token(token) or {
36
36
eprintln('no such user corresponding to token')
37
37
return none
38
38
}
···
46
46
if token == '' {
47
47
return none
48
48
}
49
-
if user := app.get_user_by_token(ctx, token) {
49
+
if user := app.get_user_by_token(token) {
50
50
if user.username == '' || user.id == 0 {
51
51
eprintln('a user had a token for the blank user')
52
52
// Clear token
···
126
126
eprintln('failed to compile regex for process_post_mentions (err: ${err})')
127
127
return
128
128
}
129
-
matches := re.find_all_str(post.body)
130
-
for mat in matches {
129
+
matches := re.find_all(post.body)
130
+
for i := 0 ; i < matches.len ; i += 2 {
131
+
mat := post.body[matches[i]..matches[i+1]]
132
+
// skip escaped mentions
133
+
if matches[i] != 0 && post.body[matches[i] - 1] == `\\` {
134
+
continue
135
+
}
136
+
131
137
println('found mentioned user: ${mat}')
132
138
username := mat#[2..-1]
133
139
user := app.get_user_by_name(username) or {
+39
src/webapp/config.v
+39
src/webapp/config.v
···
12
12
name string
13
13
welcome string
14
14
default_theme string
15
+
default_css string
15
16
allow_changing_theme bool
16
17
version string
17
18
source string
19
+
v_source string
20
+
invite_only bool
21
+
invite_code string
22
+
public_data bool
23
+
owner_username string
18
24
}
19
25
http struct {
20
26
pub mut:
···
28
34
password string
29
35
db string
30
36
}
37
+
hcaptcha struct {
38
+
pub mut:
39
+
enabled bool
40
+
secret string
41
+
site_key string
42
+
}
31
43
post struct {
32
44
pub mut:
33
45
title_min_len int
···
36
48
body_min_len int
37
49
body_max_len int
38
50
body_pattern string
51
+
allow_nsfw bool
39
52
}
40
53
user struct {
41
54
pub mut:
···
73
86
config.instance.name = loaded_instance.get('name').to_str()
74
87
config.instance.welcome = loaded_instance.get('welcome').to_str()
75
88
config.instance.default_theme = loaded_instance.get('default_theme').to_str()
89
+
config.instance.default_css = loaded_instance.get('default_css').to_str()
76
90
config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool()
77
91
config.instance.version = loaded_instance.get('version').to_str()
78
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()
79
98
80
99
loaded_http := loaded.get('http')
81
100
config.http.port = loaded_http.get('port').to_int()
···
87
106
config.postgres.password = loaded_postgres.get('password').to_str()
88
107
config.postgres.db = loaded_postgres.get('db').to_str()
89
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
+
90
114
loaded_post := loaded.get('post')
91
115
config.post.title_min_len = loaded_post.get('title_min_len').to_int()
92
116
config.post.title_max_len = loaded_post.get('title_max_len').to_int()
···
94
118
config.post.body_min_len = loaded_post.get('body_min_len').to_int()
95
119
config.post.body_max_len = loaded_post.get('body_max_len').to_int()
96
120
config.post.body_pattern = loaded_post.get('body_pattern').to_str()
121
+
config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool()
97
122
98
123
loaded_user := loaded.get('user')
99
124
config.user.username_min_len = loaded_user.get('username_min_len').to_int()
···
118
143
119
144
return config
120
145
}
146
+
147
+
pub struct BuildInfo {
148
+
pub mut:
149
+
commit string
150
+
}
151
+
152
+
pub fn load_buildinfo_from(file_path string) BuildInfo {
153
+
loaded := maple.load_file(file_path) or { panic(err) }
154
+
mut buildinfo := BuildInfo{}
155
+
156
+
buildinfo.commit = loaded.get('commit').to_str()
157
+
158
+
return buildinfo
159
+
}
+35
-4
src/webapp/pages.v
+35
-4
src/webapp/pages.v
···
4
4
import entity { User }
5
5
6
6
fn (mut app App) index(mut ctx Context) veb.Result {
7
+
if !app.config.instance.public_data {
8
+
_ := app.whoami(mut ctx) or {
9
+
ctx.error('not logged in')
10
+
return ctx.redirect('/login')
11
+
}
12
+
}
13
+
7
14
ctx.title = app.config.instance.name
8
15
user := app.whoami(mut ctx) or { User{} }
9
16
recent_posts := app.get_recent_posts()
···
91
98
92
99
@['/user/:username']
93
100
fn (mut app App) user(mut ctx Context, username string) veb.Result {
101
+
if !app.config.instance.public_data {
102
+
_ := app.whoami(mut ctx) or {
103
+
ctx.error('not logged in')
104
+
return ctx.redirect('/login')
105
+
}
106
+
}
107
+
94
108
user := app.whoami(mut ctx) or { User{} }
95
109
viewing := app.get_user_by_name(username) or {
96
110
ctx.error('user not found')
···
98
112
}
99
113
ctx.title = '${app.config.instance.name} - ${user.get_name()}'
100
114
posts := app.get_posts_from_user(viewing.id, 10)
115
+
116
+
// needed for new_post component
117
+
replying := false
118
+
replying_to := 0
119
+
replying_to_user := User{}
120
+
101
121
return $veb.html('../templates/user.html')
102
122
}
103
123
104
124
@['/post/:post_id']
105
125
fn (mut app App) post(mut ctx Context, post_id int) veb.Result {
126
+
if !app.config.instance.public_data {
127
+
_ := app.whoami(mut ctx) or {
128
+
ctx.error('not logged in')
129
+
return ctx.redirect('/login')
130
+
}
131
+
}
132
+
106
133
post := app.get_post_by_id(post_id) or {
107
134
ctx.error('no such post')
108
135
return ctx.redirect('/')
···
114
141
mut replying_to_user := app.get_unknown_user()
115
142
116
143
if post.replying_to != none {
117
-
replying_to_post = app.get_post_by_id(post.replying_to) or {
118
-
app.get_unknown_post()
119
-
}
144
+
replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() }
120
145
replying_to_user = app.get_user_by_id(replying_to_post.author_id) or {
121
146
app.get_unknown_user()
122
147
}
···
209
234
210
235
@['/about']
211
236
fn (mut app App) about(mut ctx Context) veb.Result {
212
-
user := app.whoami(mut ctx) or { User{} }
237
+
user := app.whoami(mut ctx) or {
238
+
if !app.config.instance.public_data {
239
+
ctx.error('not logged in')
240
+
return ctx.redirect('/login')
241
+
}
242
+
User{}
243
+
}
213
244
ctx.title = '${app.config.instance.name} - about'
214
245
return $veb.html('../templates/about.html')
215
246
}
+14
-2
src/webapp/validation.v
+14
-2
src/webapp/validation.v
···
13
13
// validate validates a given string and returns true if it succeeded and false
14
14
// otherwise.
15
15
@[inline]
16
-
pub fn (validator StringValidator) validate(str string) bool {
17
-
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
18
30
&& validator.pattern.matches_string(str)
19
31
}
20
32