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