+18
.dockerignore
+18
.dockerignore
···
1
+
# Binaries
2
+
/beep
3
+
/build/
4
+
5
+
# Editor/system specific metadata
6
+
.DS_Store
7
+
.vscode/
8
+
9
+
# Secrets
10
+
/config.real.maple
11
+
.env
12
+
13
+
# Local V and Clockwork install (Gitpod)
14
+
/clockwork
15
+
/v/
16
+
17
+
# Quick notes I keep while developing
18
+
/stickynote.md
+3
.editorconfig
+3
.editorconfig
+13
-24
.gitignore
+13
-24
.gitignore
···
1
-
# Binaries for programs and plugins
2
-
main
3
-
clockwork
4
-
beep
5
-
*.exe
6
-
*.exe~
7
-
*.so
8
-
*.dylib
9
-
*.dll
1
+
# Binaries
2
+
/beep
3
+
/build/
4
+
/scripts/fetchbuildinfo
10
5
11
-
# Ignore binary output folders
12
-
bin/
13
-
14
-
# Ignore common editor/system specific metadata
6
+
# Editor/system specific metadata
15
7
.DS_Store
16
-
.idea/
17
8
.vscode/
18
-
*.iml
19
9
20
-
# ENV
10
+
# Secrets
11
+
/config.real.maple
21
12
.env
22
13
23
-
# vweb and database
24
-
*.db
14
+
# Build data
15
+
/buildinfo.maple
25
16
26
-
# Local V install
17
+
# Local V and Clockwork install (Gitpod)
18
+
/clockwork
27
19
/v/
28
20
29
-
# Local Clockwork install
30
-
/clockwork/
31
-
32
-
# "Real" config (contains secrets and such)
33
-
/config.real.maple
21
+
# Quick notes I keep while developing
22
+
/stickynote.md
+43
Dockerfile
+43
Dockerfile
···
1
+
FROM debian:trixie-slim
2
+
3
+
# Create beep group and user
4
+
RUN <<EOF
5
+
set -eux
6
+
groupadd -r beep
7
+
useradd -r -g beep beep -d /beep -s /bin/sh
8
+
install -vd -o beep -g beep -m 1777 /beep
9
+
EOF
10
+
11
+
# Install base packages. These might already be installed by the image.
12
+
RUN <<EOF
13
+
set -eux
14
+
apt update
15
+
apt install -y --no-install-recommends \
16
+
ca-certificates build-essential git libpq-dev
17
+
EOF
18
+
19
+
# Install V
20
+
RUN <<EOF
21
+
set -eux
22
+
git clone --depth=1 https://github.com/vlang/v /opt/v
23
+
cd /opt/v
24
+
make
25
+
ln -s /opt/v/v /usr/local/bin/v
26
+
EOF
27
+
28
+
USER beep
29
+
WORKDIR /beep
30
+
COPY . .
31
+
32
+
# Install beep
33
+
RUN <<EOF
34
+
set -eux
35
+
# git clone --depth=1 https://tangled.org/emmeline.girlkisser.top/beep .
36
+
mkdir -p ~/.vmodules/emmathemartian/maple
37
+
git clone --depth=1 https://github.com/emmathemartian/maple ~/.vmodules/emmathemartian/maple
38
+
v -cflags "-O3 -flto" . # compiling with -prod causes ORM errors.
39
+
EOF
40
+
41
+
STOPSIGNAL SIGINT
42
+
EXPOSE 8008
43
+
CMD ["./beep"]
+52
-4
build.maple
+52
-4
build.maple
···
1
1
plugins = [ 'v' ]
2
2
3
+
task::fetch-build-info = {
4
+
description = 'Fetch misc build information, mainly for the about page'
5
+
run = 'v scripts/fetchbuildinfo.vsh'
6
+
}
7
+
8
+
// Database
9
+
3
10
task:db.init = {
4
11
description = 'Initialize and start a local Postgres database via Docker'
5
12
category = 'db'
···
9
16
-e POSTGRES_USER=beep \
10
17
-e POSTGRES_PASSWORD=beep \
11
18
--mount source=beep-data,target=/var/lib/postgresql/data \
12
-
-p 5432:5432 \
13
-
postgres:15'
19
+
-p 127.0.0.1:5432:5432 \
20
+
postgres:17'
14
21
}
15
22
16
23
task:db.start = {
···
43
50
run = 'docker rm beep-database && docker volume rm beep-data'
44
51
}
45
52
53
+
// Ngrok
54
+
55
+
task:ngrok = {
56
+
description = 'Open an ngrok tunnel for testing.'
57
+
category = 'misc'
58
+
run = 'ngrok http http://localhost:8008'
59
+
}
60
+
61
+
task:ngrok.url = {
62
+
description = 'Open an ngrok tunnel for testing. Requires you to pass the ngrok URL as an argument.'
63
+
category = 'misc'
64
+
run = 'ngrok http --url=${args} http://localhost:8008'
65
+
}
66
+
67
+
// Run
68
+
46
69
task:run = {
47
70
description = 'Run beep'
48
71
category = 'run'
49
-
run = '${v} -d veb_livereload watch run ${v_main} config.maple'
72
+
depends = [':fetch-build-info']
73
+
run = '${v} run ${v_main} config.maple'
50
74
}
51
75
52
76
task:run.real = {
53
77
description = 'Run beep using config.real.maple'
54
78
category = 'run'
55
-
run = '${v} -d veb_livereload watch run ${v_main} config.real.maple'
79
+
depends = [':fetch-build-info']
80
+
run = '${v} run ${v_main}'
81
+
}
82
+
83
+
task:run.watch = {
84
+
description = 'Watch/run beep'
85
+
category = 'run'
86
+
depends = [':fetch-build-info']
87
+
run = '${v} -d veb_livereload watch run ${v_main} config.maple'
88
+
}
89
+
90
+
task:run.watch.real = {
91
+
description = 'Watch/run beep using config.real.maple'
92
+
category = 'run'
93
+
depends = [':fetch-build-info']
94
+
run = '${v} watch run ${v_main}'
95
+
}
96
+
97
+
// Misc
98
+
99
+
task:cloc = {
100
+
description = 'Get the lines of code for beep!'
101
+
category = 'misc'
102
+
//todo: contribute vlang support to cloc and use that here instead of it seeing all of our v code as verilog code
103
+
run = 'cloc ./src/'
56
104
}
+36
compose.yml
+36
compose.yml
···
1
+
volumes:
2
+
beep-data:
3
+
4
+
services:
5
+
beep-database:
6
+
image: postgres:17
7
+
container_name: beep-database
8
+
ports:
9
+
- 127.0.0.1:5432:5432
10
+
environment:
11
+
- POSTGRES_DB=beep
12
+
- POSTGRES_USER=beep
13
+
- POSTGRES_PASSWORD=beep # CHANGE THIS
14
+
volumes:
15
+
- beep-data:/var/lib/postgresql/data
16
+
restart: on-failure:3
17
+
healthcheck:
18
+
test: ["CMD", "pg_isready", "-d", "postgresql://localhost:5432", "-U", "beep"]
19
+
interval: 30s
20
+
timeout: 10s
21
+
retries: 5
22
+
23
+
beep:
24
+
build: .
25
+
container_name: beep
26
+
depends_on:
27
+
beep-database:
28
+
condition: service_healthy
29
+
restart: true
30
+
ports:
31
+
- 8008:8008
32
+
volumes:
33
+
- type: bind
34
+
source: ${PWD}/config.real.maple
35
+
target: /beep/config.real.maple
36
+
restart: on-failure:3
+56
-9
config.maple
+56
-9
config.maple
···
1
+
// Toggles developer mode; when true, allows access to the admin panel for all users.
1
2
dev_mode = false
3
+
// Path to the static directory. You shouldn't ever need to change this.
2
4
static_path = 'src/static'
3
5
6
+
// General instance settings
4
7
instance = {
8
+
// Instance version. This is shown on the about page.
9
+
version = '2025.12'
10
+
11
+
// Set this to '' if your instance is closed source. This is shown on the about page.
12
+
source = 'https://tangled.org/emmeline.girlkisser.top/beep'
13
+
14
+
// Source for your V compiler. Unless you're using a fork of V, you shouldn't need to change this.
15
+
v_source = 'https://github.com/vlang/v'
16
+
17
+
// The instance's name, used for the page titles and on the homepage.
5
18
name = 'beep'
19
+
// The welcome message to show on the homepage.
6
20
welcome = 'welcome to beep!'
7
21
8
-
default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css'
22
+
// TODO: Move default_theme and allow_changing_theme to user settings
23
+
// Default theme applied for all users.
24
+
default_theme = '/static/themes/default.css'
25
+
// Default custom CSS applied for all users.
26
+
default_css = ''
27
+
// Whether or not users should be able to change their theme.
9
28
allow_changing_theme = true
29
+
30
+
// Toggle to require that users have the invite code to register.
31
+
invite_only = false
32
+
// Invite code. You can change this at any time.
33
+
invite_code = ''
34
+
35
+
// Toggle to allow any non-logged-in user to view data (posts, users, etc)
36
+
public_data = false
37
+
38
+
// Owner's username. This is linked on the about page. Leave empty to disable.
39
+
owner_username = ''
10
40
}
11
41
12
42
http = {
13
43
port = 8008
14
44
}
15
45
46
+
// Database settings.
16
47
postgres = {
17
-
host = 'localhost'
48
+
// Name of database container in compose.yml
49
+
host = 'beep-database'
18
50
port = 5432
19
51
user = 'beep'
20
-
password = 'beep'
52
+
password = 'beep' // TODO: Read from .env
21
53
db = 'beep'
22
54
}
23
55
56
+
hcaptcha = {
57
+
// Toggles if hcaptcha is enabled.
58
+
enabled = false
59
+
secret = '' // TODO: Read from .env
60
+
site_key = ''
61
+
}
62
+
63
+
// Post settings.
24
64
post = {
25
65
title_min_len = 1
26
66
title_max_len = 50
27
-
title_pattern = '(.|\s)*'
67
+
title_pattern = '.*'
28
68
29
69
body_min_len = 1
30
70
body_max_len = 1000
31
-
body_pattern = '(.|\s)*'
71
+
body_pattern = '.*'
72
+
73
+
// Whether or not posts can be marked as NSFW.
74
+
allow_nsfw = true
32
75
}
33
76
77
+
// User settings.
34
78
user = {
35
79
username_min_len = 3
36
80
username_max_len = 20
···
38
82
39
83
nickname_min_len = 1
40
84
nickname_max_len = 20
41
-
nickname_pattern = '(.|\s).*'
85
+
nickname_pattern = '.*'
42
86
43
87
password_min_len = 12
44
88
password_max_len = 72
45
-
password_pattern = '(.|\s)+'
89
+
password_pattern = '.+'
46
90
47
91
pronouns_min_len = 0
48
92
pronouns_max_len = 30
49
-
pronouns_pattern = '(.|\s)*'
93
+
pronouns_pattern = '.*'
50
94
51
95
bio_min_len = 0
52
96
bio_max_len = 200
53
-
bio_pattern = '(.|\s)*'
97
+
bio_pattern = '.*'
54
98
}
55
99
100
+
// Welcome notification settings.
56
101
welcome = {
102
+
// Title of the notification.
57
103
summary = 'welcome!'
104
+
// Notification body text. %s is replaced with the user's name.
58
105
body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D'
59
106
}
+16
doc/database_spec.md
+16
doc/database_spec.md
···
18
18
| `password_salt` | string | salt for this user's password |
19
19
| `muted` | bool | controls whether or not this user can make posts |
20
20
| `admin` | bool | controls whether or not this user is an admin |
21
+
| `automated` | bool | controls whether or not this user is automated |
21
22
| `theme` | ?string | controls per-user css themes |
23
+
| `css` | ?string | controls per-user css |
22
24
| `bio` | string | bio for this user |
23
25
| `pronouns` | string | pronouns for this user |
24
26
| `created_at` | time.Time | a timestamp of when this user was made |
···
34
36
| `replying_to` | ?int | id of the post that this post is replying to |
35
37
| `title` | string | the title of this post |
36
38
| `body` | string | the body of this post |
39
+
| `pinned` | bool | if this post in globally pinned |
40
+
| `nsfw` | bool | if this post in marked as nsfw |
37
41
| `posted_at` | time.Time | a timestamp of when this post was made |
38
42
39
43
## `Like`
···
81
85
| `user_id` | int | the user that receives this notification |
82
86
| `summary` | string | the summary for this notification |
83
87
| `body` | string | the full text for this notification |
88
+
89
+
## `SavedPost`
90
+
91
+
> a list of saved posts for a user
92
+
93
+
| name | type | desc |
94
+
|-----------|------|--------------------------------------------------|
95
+
| `id` | int | identifier for this entry, this is mostly unused |
96
+
| `post_id` | int | the id of the post this entry relates to |
97
+
| `user_id` | int | the id of the user that saved this post |
98
+
| `saved` | bool | if this post is saved |
99
+
| `later` | bool | if this post is saved in "read later" |
+35
doc/resources.md
+35
doc/resources.md
···
9
9
## database design
10
10
11
11
- https://stackoverflow.com/questions/59505855/liked-posts-design-specifics
12
+
- my programmer brain automatically assumed "oh i can just store a list
13
+
in the user table!" turns out, that is a bad implementation.
14
+
- i do have scalability concerns with the current implementation, but i
15
+
can address those in the near future.
16
+
17
+
## sql
18
+
19
+
postgresql documentation: https://www.postgresql.org/docs/
20
+
21
+
- https://stackoverflow.com/questions/11144394/order-sql-by-strongest-like
22
+
- helped me develop the initial search system, which is subject to be
23
+
overhauled, but for now, this helped a lot.
24
+
- https://stackoverflow.com/questions/1237725/copying-postgresql-database-to-another-server
25
+
- database migrations
26
+
27
+
## sql security
28
+
29
+

30
+
31
+
source: xkcd, <https://xkcd.com/327/>
32
+
33
+
- sql injections
34
+
- https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html#other-examples-of-safe-prepared-statements
35
+
- https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html#using-net-built-in-feature
36
+
- https://www.slideshare.net/slideshow/sql-injection-myths-and-fallacies/3729931#3
37
+
38
+
## misc
39
+
40
+
- https://stackoverflow.blog/2021/12/28/what-i-wish-i-had-known-about-single-page-applications/
41
+
- i thought about turning beep into a single page application (spa),
42
+
then done a bit of research. this blog post pointed out a variety of
43
+
problems that the author had with their spa, and many of those problems
44
+
would be problems for beep too.
45
+
- tl;dr: this blog post gave me the warnings about an spa before i
46
+
wasted my time implementing it on beep.
+5
-6
doc/themes.md
+5
-6
doc/themes.md
···
30
30
31
31
## beep-specific
32
32
33
-
| name | source | css theme url |
34
-
|------|--------|---------------|
35
-
| | | |
36
-
37
-
> there is nothing here yet! do you want to be the one to change that?
33
+
| name | source | css theme url |
34
+
|---------|----------------------------------------------------|----------------------------|
35
+
| default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css |
38
36
39
37
## built-in
40
38
41
39
| name | based on (if applicable) | css theme url |
42
40
|-----------------------------|---------------------------------|---------------------------------|
41
+
| default | n/a | default.css |
43
42
| catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css |
44
43
| catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css |
45
44
| catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css |
···
48
47
> beep also features some built-in themes, some of which are based on the themes
49
48
> present in the "it just works" list!
50
49
51
-
> make sure to prefix the url with `<instance url>/static/themes/`
50
+
> make sure to prefix the url with `/static/themes/`
+46
-3
doc/todo.md
+46
-3
doc/todo.md
···
4
4
5
5
## in-progress
6
6
7
+
- [x] post:search for posts
8
+
- [ ] filters:
9
+
```
10
+
created-at:<date>
11
+
created-after:<date>
12
+
created-before:<date>
13
+
is:pinned
14
+
has-tag:<tag>
15
+
posted-by:<user>
16
+
!excluded-query
17
+
```
18
+
- [x] user:search for users
19
+
- [ ] filters:
20
+
```
21
+
created-at:<date>
22
+
created-after:<date>
23
+
created-before:<date>
24
+
is:admin
25
+
```
26
+
7
27
## planing
8
28
9
29
> p.s. when initially writing "planing," i made a typo. it should be "planning."
10
30
> however, i will not be fixing it, because it is funny.
11
31
12
-
- [ ] post:images (should have a config.maple toggle to enable/disable)
13
-
- [ ] post:saving (add the post to a list of saved posts that a user can view later)
32
+
- [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc)
33
+
- [ ] user:follow other users (send notifications on new posts)
34
+
- [ ] site:log new accounts, account deletions, etc etc in an admin-accessible site log
35
+
- this should be set up to only log things when an admin enables it in the site config, so as to only log when necessary
36
+
- [ ] site:implement a database keep-alive system
37
+
- i may also just need to change a setting in the database server to keep it alive, i am not sure yet.
38
+
- [ ] site:overhaul security
39
+
- i am not a database security specialist, and some of the methods below may be bad.
40
+
before approaching which security features i want implemented, i will be consulting some other people to be sure of which ones need to be and do not need to be implemented.
41
+
- [ ] row level security?
42
+
- [ ] roles/user groups?
43
+
- [ ] whitelist maps where possible?
44
+
- [ ] database firewall?
14
45
15
46
## ideas
16
47
17
48
- [ ] user:per-user post pins
18
49
- could be used as an alternative for a bio to include more information perhaps
50
+
- [ ] site:rss feed?
19
51
20
52
## done
21
53
···
32
64
- [x] post:editing
33
65
- [x] post:replies
34
66
- [x] post:tags ('hashtags')
67
+
- [x] post:embedded links (links added to a post will be embedded into the post
68
+
as images, music links, etc)
69
+
- should have special handling for spotify, apple music, youtube,
70
+
discord, and other common links. we want those ones to look fancy!
71
+
- [x] post:saving (add the post to a list of saved posts that a user can view later)
72
+
- [x] site:message of the day (admins can add a welcome message displayed on index.html)
73
+
- [x] misc:replace `SELECT *` with `SELECT <column>`
74
+
75
+
## graveyard
76
+
77
+
- [ ] ~~post:images (should have a config.maple toggle to enable/disable)~~
78
+
- replaced with post:embedded links
35
79
- [ ] ~~site:stylesheet (and a toggle for html-only mode)~~
36
80
- replaced with per-user optional stylesheets
37
-
- [x] site:message of the day (admins can add a welcome message displayed on index.html)
+14
etc/beep-db.service
+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
-35
readme.md
-35
readme.md
···
1
-
# beep
2
-
3
-
> *a legendary land of lowercase lovers.*
4
-
5
-
a self-hosted mini-blogger.
6
-
7
-
technically made because i wanted to mess around with rss, but i also wanted a
8
-
teensy little blog/slow-paced-chat-app for myself and my friends.
9
-
10
-
## hosting
11
-
12
-
you will need a postgresql database somewhere, along with v to compile beep:
13
-
14
-
edit `config.maple` to set the url, port, username, password, and database name.
15
-
16
-
> `config.maple` also has settings to configure the feel of your beep instance,
17
-
> including toggling images, post length, username length, etc etc.
18
-
19
-
> **do not** push your `config.maple`'s secrets to git!
20
-
> instead, use `config.real.maple` if you plan to push anywhere.
21
-
> it will be gitignored to keep your secrets a secret.
22
-
23
-
```sh
24
-
git clone https://github.com/emmathemartian/beep
25
-
cd beep
26
-
v -prod .
27
-
./beep config.maple
28
-
```
29
-
30
-
then go to the configured url to view (default is `http://localhost:8008`).
31
-
32
-
if you do not have a database, you can either self-host a postgresql database on
33
-
your machine, or you can find a free one online. i use and like
34
-
[neon.tech](https://neon.tech), their free plan is pretty comfortable for a
35
-
small beep instance!
+15
scripts/fetchbuildinfo.vsh
+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
+
})!
-700
src/api.v
-700
src/api.v
···
1
-
module main
2
-
3
-
import veb
4
-
import auth
5
-
import entity { Like, LikeCache, Post, Site, User, Notification }
6
-
7
-
////// user //////
8
-
9
-
@['/api/user/register'; post]
10
-
fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result {
11
-
if app.get_user_by_name(username) != none {
12
-
ctx.error('username taken')
13
-
return ctx.redirect('/register')
14
-
}
15
-
16
-
// validate username
17
-
if !app.validators.username.validate(username) {
18
-
ctx.error('invalid username')
19
-
return ctx.redirect('/register')
20
-
}
21
-
22
-
// validate password
23
-
if !app.validators.password.validate(password) {
24
-
ctx.error('invalid password')
25
-
return ctx.redirect('/register')
26
-
}
27
-
28
-
salt := auth.generate_salt()
29
-
mut user := User{
30
-
username: username
31
-
password: auth.hash_password_with_salt(password, salt)
32
-
password_salt: salt
33
-
}
34
-
35
-
if app.config.instance.default_theme != '' {
36
-
user.theme = app.config.instance.default_theme
37
-
}
38
-
39
-
sql app.db {
40
-
insert user into User
41
-
} or {
42
-
eprintln('failed to insert user ${user}')
43
-
return ctx.redirect('/')
44
-
}
45
-
46
-
println('reg: ${username}')
47
-
48
-
if x := app.get_user_by_name(username) {
49
-
app.send_notification_to(
50
-
x.id,
51
-
app.config.welcome.summary.replace('%s', x.get_name()),
52
-
app.config.welcome.body.replace('%s', x.get_name())
53
-
)
54
-
token := app.auth.add_token(x.id, ctx.ip()) or {
55
-
eprintln(err)
56
-
ctx.error('could not create token for user with id ${x.id}')
57
-
return ctx.redirect('/')
58
-
}
59
-
ctx.set_cookie(
60
-
name: 'token'
61
-
value: token
62
-
same_site: .same_site_none_mode
63
-
secure: true
64
-
path: '/'
65
-
)
66
-
} else {
67
-
eprintln('could not log into newly-created user: ${user}')
68
-
ctx.error('could not log into newly-created user.')
69
-
}
70
-
71
-
return ctx.redirect('/')
72
-
}
73
-
74
-
@['/api/user/set_username'; post]
75
-
fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result {
76
-
user := app.whoami(mut ctx) or {
77
-
ctx.error('you are not logged in!')
78
-
return ctx.redirect('/login')
79
-
}
80
-
81
-
if app.get_user_by_name(new_username) != none {
82
-
ctx.error('username taken')
83
-
return ctx.redirect('/settings')
84
-
}
85
-
86
-
// validate username
87
-
if !app.validators.username.validate(new_username) {
88
-
ctx.error('invalid username')
89
-
return ctx.redirect('/settings')
90
-
}
91
-
92
-
sql app.db {
93
-
update User set username = new_username where id == user.id
94
-
} or {
95
-
eprintln('failed to update username for ${user.id}')
96
-
ctx.error('failed to update username')
97
-
}
98
-
99
-
return ctx.redirect('/settings')
100
-
}
101
-
102
-
@['/api/user/set_password'; post]
103
-
fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result {
104
-
user := app.whoami(mut ctx) or {
105
-
ctx.error('you are not logged in!')
106
-
return ctx.redirect('/login')
107
-
}
108
-
109
-
if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) {
110
-
ctx.error('current_password is incorrect')
111
-
return ctx.redirect('/settings')
112
-
}
113
-
114
-
// validate password
115
-
if !app.validators.password.validate(new_password) {
116
-
ctx.error('invalid password')
117
-
return ctx.redirect('/settings')
118
-
}
119
-
120
-
// invalidate tokens
121
-
app.auth.delete_tokens_for_user(user.id) or {
122
-
eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})')
123
-
return ctx.redirect('/settings')
124
-
}
125
-
126
-
hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt)
127
-
128
-
sql app.db {
129
-
update User set password = hashed_new_password where id == user.id
130
-
} or {
131
-
eprintln('failed to update password for ${user.id}')
132
-
ctx.error('failed to update password')
133
-
}
134
-
135
-
return ctx.redirect('/login')
136
-
}
137
-
138
-
@['/api/user/login'; post]
139
-
fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result {
140
-
user := app.get_user_by_name(username) or {
141
-
ctx.error('invalid credentials')
142
-
return ctx.redirect('/login')
143
-
}
144
-
145
-
if !auth.compare_password_with_hash(password, user.password_salt, user.password) {
146
-
ctx.error('invalid credentials')
147
-
return ctx.redirect('/login')
148
-
}
149
-
150
-
token := app.auth.add_token(user.id, ctx.ip()) or {
151
-
eprintln('failed to add token on log in: ${err}')
152
-
ctx.error('could not create token for user with id ${user.id}')
153
-
return ctx.redirect('/login')
154
-
}
155
-
156
-
ctx.set_cookie(
157
-
name: 'token'
158
-
value: token
159
-
same_site: .same_site_none_mode
160
-
secure: true
161
-
path: '/'
162
-
)
163
-
164
-
return ctx.redirect('/')
165
-
}
166
-
167
-
@['/api/user/logout']
168
-
fn (mut app App) api_user_logout(mut ctx Context) veb.Result {
169
-
if token := ctx.get_cookie('token') {
170
-
if user := app.get_user_by_token(ctx, token) {
171
-
app.auth.delete_tokens_for_ip(ctx.ip()) or {
172
-
eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})')
173
-
return ctx.redirect('/login')
174
-
}
175
-
} else {
176
-
eprintln('failed to get user for token for logout')
177
-
}
178
-
} else {
179
-
eprintln('failed to get token cookie for logout')
180
-
}
181
-
182
-
ctx.set_cookie(
183
-
name: 'token'
184
-
value: ''
185
-
same_site: .same_site_none_mode
186
-
secure: true
187
-
path: '/'
188
-
)
189
-
190
-
return ctx.redirect('/login')
191
-
}
192
-
193
-
@['/api/user/full_logout']
194
-
fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result {
195
-
if token := ctx.get_cookie('token') {
196
-
if user := app.get_user_by_token(ctx, token) {
197
-
app.auth.delete_tokens_for_user(user.id) or {
198
-
eprintln('failed to yeet tokens for ${user.id}')
199
-
return ctx.redirect('/login')
200
-
}
201
-
} else {
202
-
eprintln('failed to get user for token for full_logout')
203
-
}
204
-
} else {
205
-
eprintln('failed to get token cookie for full_logout')
206
-
}
207
-
208
-
ctx.set_cookie(
209
-
name: 'token'
210
-
value: ''
211
-
same_site: .same_site_none_mode
212
-
secure: true
213
-
path: '/'
214
-
)
215
-
216
-
return ctx.redirect('/login')
217
-
}
218
-
219
-
@['/api/user/set_nickname'; post]
220
-
fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result {
221
-
user := app.whoami(mut ctx) or {
222
-
ctx.error('you are not logged in!')
223
-
return ctx.redirect('/login')
224
-
}
225
-
226
-
mut clean_nickname := ?string(nickname.trim_space())
227
-
if clean_nickname or { '' } == '' {
228
-
clean_nickname = none
229
-
}
230
-
231
-
// validate
232
-
if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
233
-
ctx.error('invalid nickname')
234
-
return ctx.redirect('/me')
235
-
}
236
-
237
-
sql app.db {
238
-
update User set nickname = clean_nickname where id == user.id
239
-
} or {
240
-
ctx.error('failed to change nickname')
241
-
eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
242
-
return ctx.redirect('/me')
243
-
}
244
-
245
-
return ctx.redirect('/me')
246
-
}
247
-
248
-
@['/api/user/set_muted'; post]
249
-
fn (mut app App) api_user_set_muted(mut ctx Context, muted bool) veb.Result {
250
-
user := app.whoami(mut ctx) or {
251
-
ctx.error('you are not logged in!')
252
-
return ctx.redirect('/login')
253
-
}
254
-
255
-
if user.admin || app.config.dev_mode {
256
-
sql app.db {
257
-
update User set muted = muted where id == user.id
258
-
} or {
259
-
ctx.error('failed to change mute status')
260
-
eprintln('failed to update mute status for ${user} (${user.muted} -> ${muted})')
261
-
return ctx.redirect('/user/${user.username}')
262
-
}
263
-
return ctx.redirect('/user/${user.username}')
264
-
} else {
265
-
ctx.error('insufficient permissions!')
266
-
eprintln('insufficient perms to update mute status for ${user} (${user.muted} -> ${muted})')
267
-
return ctx.redirect('/user/${user.username}')
268
-
}
269
-
}
270
-
271
-
@['/api/user/set_theme'; post]
272
-
fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
273
-
if !app.config.instance.allow_changing_theme {
274
-
ctx.error('this instance disallows changing themes :(')
275
-
return ctx.redirect('/me')
276
-
}
277
-
278
-
user := app.whoami(mut ctx) or {
279
-
ctx.error('you are not logged in!')
280
-
return ctx.redirect('/login')
281
-
}
282
-
283
-
mut theme := ?string(none)
284
-
if url.trim_space() != '' {
285
-
theme = url.trim_space()
286
-
}
287
-
288
-
sql app.db {
289
-
update User set theme = theme where id == user.id
290
-
} or {
291
-
ctx.error('failed to change theme')
292
-
eprintln('failed to update theme for ${user} (${user.theme} -> ${theme})')
293
-
return ctx.redirect('/me')
294
-
}
295
-
296
-
return ctx.redirect('/me')
297
-
}
298
-
299
-
@['/api/user/set_pronouns'; post]
300
-
fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result {
301
-
user := app.whoami(mut ctx) or {
302
-
ctx.error('you are not logged in!')
303
-
return ctx.redirect('/login')
304
-
}
305
-
306
-
clean_pronouns := pronouns.trim_space()
307
-
if !app.validators.pronouns.validate(clean_pronouns) {
308
-
ctx.error('invalid pronouns')
309
-
return ctx.redirect('/me')
310
-
}
311
-
312
-
sql app.db {
313
-
update User set pronouns = clean_pronouns where id == user.id
314
-
} or {
315
-
ctx.error('failed to change pronouns')
316
-
eprintln('failed to update pronouns for ${user} (${user.pronouns} -> ${clean_pronouns})')
317
-
return ctx.redirect('/me')
318
-
}
319
-
320
-
return ctx.redirect('/me')
321
-
}
322
-
323
-
@['/api/user/set_bio'; post]
324
-
fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result {
325
-
user := app.whoami(mut ctx) or {
326
-
ctx.error('you are not logged in!')
327
-
return ctx.redirect('/login')
328
-
}
329
-
330
-
clean_bio := bio.trim_space()
331
-
if !app.validators.user_bio.validate(clean_bio) {
332
-
ctx.error('invalid bio')
333
-
return ctx.redirect('/me')
334
-
}
335
-
336
-
sql app.db {
337
-
update User set bio = clean_bio where id == user.id
338
-
} or {
339
-
ctx.error('failed to change bio')
340
-
eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
341
-
return ctx.redirect('/me')
342
-
}
343
-
344
-
return ctx.redirect('/me')
345
-
}
346
-
347
-
@['/api/user/get_name']
348
-
fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result {
349
-
user := app.get_user_by_name(username) or { return ctx.server_error('no such user') }
350
-
return ctx.text(user.get_name())
351
-
}
352
-
353
-
/// user/notification ///
354
-
355
-
@['/api/user/notification/clear']
356
-
fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result {
357
-
if !ctx.is_logged_in() {
358
-
ctx.error('you are not logged in!')
359
-
return ctx.redirect('/login')
360
-
}
361
-
sql app.db {
362
-
delete from Notification where id == id
363
-
} or {
364
-
ctx.error('failed to delete notification')
365
-
return ctx.redirect('/inbox')
366
-
}
367
-
return ctx.redirect('/inbox')
368
-
}
369
-
370
-
@['/api/user/notification/clear_all']
371
-
fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result {
372
-
user := app.whoami(mut ctx) or {
373
-
ctx.error('you are not logged in!')
374
-
return ctx.redirect('/login')
375
-
}
376
-
sql app.db {
377
-
delete from Notification where user_id == user.id
378
-
} or {
379
-
ctx.error('failed to delete notifications')
380
-
return ctx.redirect('/inbox')
381
-
}
382
-
return ctx.redirect('/inbox')
383
-
}
384
-
385
-
@['/api/user/delete']
386
-
fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result {
387
-
user := app.whoami(mut ctx) or {
388
-
ctx.error('you are not logged in!')
389
-
return ctx.redirect('/login')
390
-
}
391
-
392
-
println('attempting to delete ${id} as ${user.id}')
393
-
394
-
if user.admin || user.id == id {
395
-
// yeet
396
-
sql app.db {
397
-
delete from User where id == id
398
-
delete from Like where user_id == id
399
-
delete from Notification where user_id == id
400
-
} or {
401
-
ctx.error('failed to delete user: ${id}')
402
-
return ctx.redirect('/')
403
-
}
404
-
405
-
// delete posts and their likes
406
-
posts_from_this_user := sql app.db {
407
-
select from Post where author_id == id
408
-
} or { [] }
409
-
410
-
for post in posts_from_this_user {
411
-
sql app.db {
412
-
delete from Like where post_id == post.id
413
-
delete from LikeCache where post_id == post.id
414
-
} or {
415
-
eprintln('failed to delete like cache for post during user deletion: ${post.id}')
416
-
}
417
-
}
418
-
419
-
sql app.db {
420
-
delete from Post where author_id == id
421
-
} or {
422
-
eprintln('failed to delete posts by deleting user: ${user.id}')
423
-
}
424
-
425
-
app.auth.delete_tokens_for_user(id) or {
426
-
eprintln('failed to delete tokens for user during deletion: ${id}')
427
-
}
428
-
// log out
429
-
if user.id == id {
430
-
ctx.set_cookie(
431
-
name: 'token'
432
-
value: ''
433
-
same_site: .same_site_none_mode
434
-
secure: true
435
-
path: '/'
436
-
)
437
-
}
438
-
println('deleted user ${id}')
439
-
} else {
440
-
ctx.error('be nice. deleting other users is off-limits.')
441
-
}
442
-
443
-
return ctx.redirect('/')
444
-
}
445
-
446
-
////// post //////
447
-
448
-
@['/api/post/new_post'; post]
449
-
fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result {
450
-
user := app.whoami(mut ctx) or {
451
-
ctx.error('not logged in!')
452
-
return ctx.redirect('/login')
453
-
}
454
-
455
-
if user.muted {
456
-
ctx.error('you are muted!')
457
-
return ctx.redirect('/post/new')
458
-
}
459
-
460
-
// validate title
461
-
if !app.validators.post_title.validate(title) {
462
-
ctx.error('invalid title')
463
-
return ctx.redirect('/post/new')
464
-
}
465
-
466
-
// validate body
467
-
if !app.validators.post_body.validate(body) {
468
-
ctx.error('invalid body')
469
-
return ctx.redirect('/post/new')
470
-
}
471
-
472
-
mut post := Post{
473
-
author_id: user.id
474
-
title: title
475
-
body: body
476
-
}
477
-
478
-
if replying_to != 0 {
479
-
// check if replying post exists
480
-
app.get_post_by_id(replying_to) or {
481
-
ctx.error('the post you are trying to reply to does not exist')
482
-
return ctx.redirect('/post/new')
483
-
}
484
-
post.replying_to = replying_to
485
-
}
486
-
487
-
sql app.db {
488
-
insert post into Post
489
-
} or {
490
-
ctx.error('failed to post!')
491
-
println('failed to post: ${post} from user ${user.id}')
492
-
return ctx.redirect('/post/new')
493
-
}
494
-
495
-
// find the post's id to process mentions with
496
-
if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) {
497
-
app.process_post_mentions(x)
498
-
return ctx.redirect('/post/${x.id}')
499
-
} else {
500
-
ctx.error('failed to get_post_by_timestamp_and_author for ${post}')
501
-
return ctx.redirect('/me')
502
-
}
503
-
}
504
-
505
-
@['/api/post/delete'; post]
506
-
fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result {
507
-
user := app.whoami(mut ctx) or {
508
-
ctx.error('not logged in!')
509
-
return ctx.redirect('/login')
510
-
}
511
-
512
-
post := app.get_post_by_id(id) or {
513
-
ctx.error('post does not exist')
514
-
return ctx.redirect('/')
515
-
}
516
-
517
-
if user.admin || user.id == post.author_id {
518
-
sql app.db {
519
-
delete from Post where id == id
520
-
delete from Like where post_id == id
521
-
} or {
522
-
ctx.error('failed to delete post')
523
-
eprintln('failed to delete post: ${id}')
524
-
return ctx.redirect('/')
525
-
}
526
-
println('deleted post: ${id}')
527
-
return ctx.redirect('/')
528
-
} else {
529
-
ctx.error('insufficient permissions!')
530
-
eprintln('insufficient perms to delete post: ${id} (${user.id})')
531
-
return ctx.redirect('/')
532
-
}
533
-
}
534
-
535
-
@['/api/post/like']
536
-
fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result {
537
-
user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
538
-
539
-
post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
540
-
541
-
if app.does_user_like_post(user.id, post.id) {
542
-
sql app.db {
543
-
delete from Like where user_id == user.id && post_id == post.id
544
-
// yeet the old cached like value
545
-
delete from LikeCache where post_id == post.id
546
-
} or {
547
-
eprintln('user ${user.id} failed to unlike post ${id}')
548
-
return ctx.server_error('failed to unlike post')
549
-
}
550
-
return ctx.ok('unliked post')
551
-
} else {
552
-
// remove the old dislike, if it exists
553
-
if app.does_user_dislike_post(user.id, post.id) {
554
-
sql app.db {
555
-
delete from Like where user_id == user.id && post_id == post.id
556
-
} or {
557
-
eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it')
558
-
}
559
-
}
560
-
561
-
like := Like{
562
-
user_id: user.id
563
-
post_id: post.id
564
-
is_like: true
565
-
}
566
-
sql app.db {
567
-
insert like into Like
568
-
// yeet the old cached like value
569
-
delete from LikeCache where post_id == post.id
570
-
} or {
571
-
eprintln('user ${user.id} failed to like post ${id}')
572
-
return ctx.server_error('failed to like post')
573
-
}
574
-
return ctx.ok('liked post')
575
-
}
576
-
}
577
-
578
-
@['/api/post/dislike']
579
-
fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result {
580
-
user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
581
-
582
-
post := app.get_post_by_id(id) or { return ctx.server_error('post does not exist') }
583
-
584
-
if app.does_user_dislike_post(user.id, post.id) {
585
-
sql app.db {
586
-
delete from Like where user_id == user.id && post_id == post.id
587
-
// yeet the old cached like value
588
-
delete from LikeCache where post_id == post.id
589
-
} or {
590
-
eprintln('user ${user.id} failed to unlike post ${id}')
591
-
return ctx.server_error('failed to unlike post')
592
-
}
593
-
return ctx.ok('undisliked post')
594
-
} else {
595
-
// remove the old like, if it exists
596
-
if app.does_user_like_post(user.id, post.id) {
597
-
sql app.db {
598
-
delete from Like where user_id == user.id && post_id == post.id
599
-
} or {
600
-
eprintln('user ${user.id} failed to remove like on post ${id} when disliking it')
601
-
}
602
-
}
603
-
604
-
like := Like{
605
-
user_id: user.id
606
-
post_id: post.id
607
-
is_like: false
608
-
}
609
-
sql app.db {
610
-
insert like into Like
611
-
// yeet the old cached like value
612
-
delete from LikeCache where post_id == post.id
613
-
} or {
614
-
eprintln('user ${user.id} failed to dislike post ${id}')
615
-
return ctx.server_error('failed to dislike post')
616
-
}
617
-
return ctx.ok('disliked post')
618
-
}
619
-
}
620
-
621
-
@['/api/post/get_title']
622
-
fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
623
-
post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
624
-
return ctx.text(post.title)
625
-
}
626
-
627
-
@['/api/post/edit'; post]
628
-
fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result {
629
-
user := app.whoami(mut ctx) or {
630
-
ctx.error('not logged in!')
631
-
return ctx.redirect('/login')
632
-
}
633
-
post := app.get_post_by_id(id) or {
634
-
ctx.error('no such post')
635
-
return ctx.redirect('/')
636
-
}
637
-
if post.author_id != user.id {
638
-
ctx.error('insufficient permissions')
639
-
return ctx.redirect('/')
640
-
}
641
-
642
-
sql app.db {
643
-
update Post set body = body, title = title where id == id
644
-
} or {
645
-
eprintln('failed to update post')
646
-
ctx.error('failed to update post')
647
-
return ctx.redirect('/')
648
-
}
649
-
650
-
return ctx.redirect('/post/${id}')
651
-
}
652
-
653
-
@['/api/post/pin'; post]
654
-
fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result {
655
-
user := app.whoami(mut ctx) or {
656
-
ctx.error('not logged in!')
657
-
return ctx.redirect('/login')
658
-
}
659
-
660
-
if user.admin {
661
-
sql app.db {
662
-
update Post set pinned = true where id == id
663
-
} or {
664
-
eprintln('failed to pin post: ${id}')
665
-
ctx.error('failed to pin post')
666
-
return ctx.redirect('/post/${id}')
667
-
}
668
-
return ctx.redirect('/post/${id}')
669
-
} else {
670
-
ctx.error('insufficient permissions!')
671
-
eprintln('insufficient perms to pin post: ${id} (${user.id})')
672
-
return ctx.redirect('/')
673
-
}
674
-
}
675
-
676
-
////// site //////
677
-
678
-
@['/api/site/set_motd'; post]
679
-
fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
680
-
user := app.whoami(mut ctx) or {
681
-
ctx.error('not logged in!')
682
-
return ctx.redirect('/login')
683
-
}
684
-
685
-
if user.admin {
686
-
sql app.db {
687
-
update Site set motd = motd where id == 1
688
-
} or {
689
-
ctx.error('failed to set motd')
690
-
eprintln('failed to set motd: ${motd}')
691
-
return ctx.redirect('/')
692
-
}
693
-
println('set motd to: ${motd}')
694
-
return ctx.redirect('/')
695
-
} else {
696
-
ctx.error('insufficient permissions!')
697
-
eprintln('insufficient perms to set motd to: ${motd} (${user.id})')
698
-
return ctx.redirect('/')
699
-
}
700
-
}
-358
src/app.v
-358
src/app.v
···
1
-
module main
2
-
3
-
import veb
4
-
import db.pg
5
-
import regex
6
-
import time
7
-
import auth
8
-
import entity { LikeCache, Like, Post, Site, User, Notification }
9
-
10
-
pub struct App {
11
-
veb.StaticHandler
12
-
pub:
13
-
config Config
14
-
pub mut:
15
-
db pg.DB
16
-
auth auth.Auth[pg.DB]
17
-
validators struct {
18
-
pub mut:
19
-
username StringValidator
20
-
password StringValidator
21
-
nickname StringValidator
22
-
pronouns StringValidator
23
-
user_bio StringValidator
24
-
post_title StringValidator
25
-
post_body StringValidator
26
-
}
27
-
}
28
-
29
-
pub fn (app &App) get_user_by_name(username string) ?User {
30
-
users := sql app.db {
31
-
select from User where username == username
32
-
} or { [] }
33
-
if users.len != 1 {
34
-
return none
35
-
}
36
-
return users[0]
37
-
}
38
-
39
-
pub fn (app &App) get_user_by_id(id int) ?User {
40
-
users := sql app.db {
41
-
select from User where id == id
42
-
} or { [] }
43
-
if users.len != 1 {
44
-
return none
45
-
}
46
-
return users[0]
47
-
}
48
-
49
-
pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User {
50
-
user_token := app.auth.find_token(token, ctx.ip()) or {
51
-
eprintln('no such user corresponding to token')
52
-
return none
53
-
}
54
-
return app.get_user_by_id(user_token.user_id)
55
-
}
56
-
57
-
pub fn (app &App) get_recent_posts() []Post {
58
-
posts := sql app.db {
59
-
select from Post order by posted_at desc limit 10
60
-
} or { [] }
61
-
return posts
62
-
}
63
-
64
-
pub fn (app &App) get_popular_posts() []Post {
65
-
cached_likes := sql app.db {
66
-
select from LikeCache order by likes desc limit 10
67
-
} or { [] }
68
-
posts := cached_likes.map(fn [app] (it LikeCache) Post {
69
-
return app.get_post_by_id(it.post_id) or {
70
-
eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)')
71
-
return Post{}
72
-
}
73
-
}).filter(it.id != 0)
74
-
return posts
75
-
}
76
-
77
-
pub fn (app &App) get_posts_from_user(user_id int) []Post {
78
-
posts := sql app.db {
79
-
select from Post where author_id == user_id order by posted_at desc
80
-
} or { [] }
81
-
return posts
82
-
}
83
-
84
-
pub fn (app &App) get_users() []User {
85
-
users := sql app.db {
86
-
select from User
87
-
} or { [] }
88
-
return users
89
-
}
90
-
91
-
pub fn (app &App) get_post_by_id(id int) ?Post {
92
-
posts := sql app.db {
93
-
select from Post where id == id limit 1
94
-
} or { [] }
95
-
if posts.len != 1 {
96
-
return none
97
-
}
98
-
return posts[0]
99
-
}
100
-
101
-
pub fn (app &App) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post {
102
-
posts := sql app.db {
103
-
select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1
104
-
} or { [] }
105
-
if posts.len == 0 {
106
-
return none
107
-
}
108
-
return posts[0]
109
-
}
110
-
111
-
pub fn (app &App) get_posts_with_tag(tag string, offset int) []Post {
112
-
posts := sql app.db {
113
-
select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset
114
-
} or { [] }
115
-
return posts
116
-
}
117
-
118
-
pub fn (app &App) get_pinned_posts() []Post {
119
-
posts := sql app.db {
120
-
select from Post where pinned == true
121
-
} or { [] }
122
-
return posts
123
-
}
124
-
125
-
pub fn (app &App) whoami(mut ctx Context) ?User {
126
-
token := ctx.get_cookie('token') or { return none }.trim_space()
127
-
if token == '' {
128
-
return none
129
-
}
130
-
if user := app.get_user_by_token(ctx, token) {
131
-
if user.username == '' || user.id == 0 {
132
-
eprintln('a user had a token for the blank user')
133
-
// Clear token
134
-
ctx.set_cookie(
135
-
name: 'token'
136
-
value: ''
137
-
same_site: .same_site_none_mode
138
-
secure: true
139
-
path: '/'
140
-
)
141
-
return none
142
-
}
143
-
return user
144
-
} else {
145
-
eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)')
146
-
// Clear token
147
-
ctx.set_cookie(
148
-
name: 'token'
149
-
value: ''
150
-
same_site: .same_site_none_mode
151
-
secure: true
152
-
path: '/'
153
-
)
154
-
return none
155
-
}
156
-
}
157
-
158
-
pub fn (app &App) get_unknown_user() User {
159
-
return User{
160
-
username: 'unknown'
161
-
}
162
-
}
163
-
164
-
pub fn (app &App) get_unknown_post() Post {
165
-
return Post{
166
-
title: 'unknown'
167
-
}
168
-
}
169
-
170
-
pub fn (app &App) logged_in_as(mut ctx Context, id int) bool {
171
-
if !ctx.is_logged_in() {
172
-
return false
173
-
}
174
-
return app.whoami(mut ctx) or { return false }.id == id
175
-
}
176
-
177
-
pub fn (app &App) does_user_like_post(user_id int, post_id int) bool {
178
-
likes := sql app.db {
179
-
select from Like where user_id == user_id && post_id == post_id
180
-
} or { [] }
181
-
if likes.len > 1 {
182
-
// something is very wrong lol
183
-
eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
184
-
} else if likes.len == 0 {
185
-
return false
186
-
}
187
-
return likes.first().is_like
188
-
}
189
-
190
-
pub fn (app &App) does_user_dislike_post(user_id int, post_id int) bool {
191
-
likes := sql app.db {
192
-
select from Like where user_id == user_id && post_id == post_id
193
-
} or { [] }
194
-
if likes.len > 1 {
195
-
// something is very wrong lol
196
-
eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
197
-
} else if likes.len == 0 {
198
-
return false
199
-
}
200
-
return !likes.first().is_like
201
-
}
202
-
203
-
pub fn (app &App) does_user_like_or_dislike_post(user_id int, post_id int) bool {
204
-
likes := sql app.db {
205
-
select from Like where user_id == user_id && post_id == post_id
206
-
} or { [] }
207
-
if likes.len > 1 {
208
-
// something is very wrong lol
209
-
eprintln('a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
210
-
}
211
-
return likes.len == 1
212
-
}
213
-
214
-
pub fn (app &App) get_net_likes_for_post(post_id int) int {
215
-
// check cache
216
-
cache := sql app.db {
217
-
select from LikeCache where post_id == post_id limit 1
218
-
} or { [] }
219
-
220
-
mut likes := 0
221
-
222
-
if cache.len != 1 {
223
-
println('calculating net likes for post: ${post_id}')
224
-
// calculate
225
-
db_likes := sql app.db {
226
-
select from Like where post_id == post_id
227
-
} or { [] }
228
-
229
-
for like in db_likes {
230
-
if like.is_like {
231
-
likes++
232
-
} else {
233
-
likes--
234
-
}
235
-
}
236
-
237
-
// cache
238
-
cached := LikeCache{
239
-
post_id: post_id
240
-
likes: likes
241
-
}
242
-
sql app.db {
243
-
insert cached into LikeCache
244
-
} or {
245
-
eprintln('failed to cache like: ${cached}')
246
-
return likes
247
-
}
248
-
} else {
249
-
likes = cache.first().likes
250
-
}
251
-
252
-
return likes
253
-
}
254
-
255
-
pub fn (app &App) get_or_create_site_config() Site {
256
-
configs := sql app.db {
257
-
select from Site
258
-
} or { [] }
259
-
if configs.len == 0 {
260
-
// make the site config
261
-
site_config := Site{}
262
-
sql app.db {
263
-
insert site_config into Site
264
-
} or { panic('failed to create site config (${err})') }
265
-
} else if configs.len > 1 {
266
-
// this should never happen
267
-
panic('there are multiple site configs')
268
-
}
269
-
return configs[0]
270
-
}
271
-
272
-
@[inline]
273
-
pub fn (app &App) get_motd() string {
274
-
site := app.get_or_create_site_config()
275
-
return site.motd
276
-
}
277
-
278
-
pub fn (app &App) get_notifications_for(user_id int) []Notification {
279
-
notifications := sql app.db {
280
-
select from Notification where user_id == user_id
281
-
} or { [] }
282
-
return notifications
283
-
}
284
-
285
-
pub fn (app &App) get_notification_count(user_id int, limit int) int {
286
-
notifications := sql app.db {
287
-
select from Notification where user_id == user_id limit limit
288
-
} or { [] }
289
-
return notifications.len
290
-
}
291
-
292
-
pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string {
293
-
count := app.get_notification_count(user_id, limit)
294
-
if count == 0 {
295
-
return ''
296
-
} else if count > limit {
297
-
return ' (${count}+)'
298
-
} else {
299
-
return ' (${count})'
300
-
}
301
-
}
302
-
303
-
pub fn (app &App) send_notification_to(user_id int, summary string, body string) {
304
-
notification := Notification{
305
-
user_id: user_id
306
-
summary: summary
307
-
body: body
308
-
}
309
-
sql app.db {
310
-
insert notification into Notification
311
-
} or {
312
-
eprintln('failed to send notification ${notification}')
313
-
}
314
-
}
315
-
316
-
// sends notifications to each user mentioned in a post
317
-
pub fn (app &App) process_post_mentions(post &Post) {
318
-
author := app.get_user_by_id(post.author_id) or {
319
-
eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
320
-
return
321
-
}
322
-
author_name := author.get_name()
323
-
324
-
// used so we do not send more than one notification per post
325
-
mut notified_users := []int{}
326
-
327
-
// notify who we replied to, if applicable
328
-
if post.replying_to != none {
329
-
if x := app.get_post_by_id(post.replying_to) {
330
-
app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})')
331
-
}
332
-
}
333
-
334
-
// find mentions
335
-
mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or {
336
-
eprintln('failed to compile regex for process_post_mentions (err: ${err})')
337
-
return
338
-
}
339
-
matches := re.find_all_str(post.body)
340
-
for mat in matches {
341
-
println('found mentioned user: ${mat}')
342
-
username := mat#[2..-1]
343
-
user := app.get_user_by_name(username) or {
344
-
continue
345
-
}
346
-
347
-
if user.id in notified_users || user.id == author.id {
348
-
continue
349
-
}
350
-
notified_users << user.id
351
-
352
-
app.send_notification_to(
353
-
user.id,
354
-
'${author_name} mentioned you!',
355
-
'you have been mentioned in this post: *(${post.id})'
356
-
)
357
-
}
358
-
}
+12
-7
src/auth/auth.v
+12
-7
src/auth/auth.v
···
1
1
// From: https://github.com/vlang/v/blob/1fae506900c79e3aafc00e08e1f861fc7cbf8012/vlib/veb/auth/auth.v
2
2
// The original file's source is licensed under MIT.
3
3
4
+
// ~~
4
5
// This fork re-introduces the `ip` field of each token for additional security,
5
6
// along with delete_tokens_for_ip
7
+
// ~~
8
+
// IP has been removed since IPs can change randomly and it causes you to need
9
+
// to relog wayyyy more often. I'm keeping this fork just in case I do need to
10
+
// change the auth system in the future.
6
11
7
12
module auth
8
13
···
22
27
id int @[primary; sql: serial]
23
28
user_id int
24
29
value string
25
-
ip string
26
30
}
27
31
28
32
pub fn new[T](db T) Auth[T] {
···
35
39
}
36
40
}
37
41
38
-
pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string {
42
+
pub fn (mut app Auth[T]) add_token(user_id int) !string {
39
43
mut uuid := rand.uuid_v4()
40
44
token := Token{
41
45
user_id: user_id
42
46
value: uuid
43
-
ip: ip
44
47
}
45
48
sql app.db {
46
49
insert token into Token
···
48
51
return uuid
49
52
}
50
53
51
-
pub fn (app &Auth[T]) find_token(value string, ip string) ?Token {
54
+
pub fn (app &Auth[T]) find_token(value string) ?Token {
52
55
tokens := sql app.db {
53
-
select from Token where value == value && ip == ip limit 1
56
+
select from Token where value == value limit 1
54
57
} or { []Token{} }
55
58
if tokens.len == 0 {
56
59
return none
···
58
61
return tokens.first()
59
62
}
60
63
64
+
// logs out of all devices
61
65
pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! {
62
66
sql app.db {
63
67
delete from Token where user_id == user_id
64
68
}!
65
69
}
66
70
67
-
pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! {
71
+
// logs out of one device
72
+
pub fn (mut app Auth[T]) delete_tokens_for_value(value string) ! {
68
73
sql app.db {
69
-
delete from Token where ip == ip
74
+
delete from Token where value == value
70
75
}!
71
76
}
72
77
+17
src/beep_sql/beep_sql.v
+17
src/beep_sql/beep_sql.v
···
1
+
module beep_sql
2
+
3
+
import os
4
+
import db.pg
5
+
6
+
fn load_procedures(mut db pg.DB) {
7
+
os.walk('src/beep_sql/procedures/', fn [mut db] (it string) {
8
+
println('-> loading procedure: ${it}')
9
+
db.exec(os.read_file(it) or { panic(err) }) or { panic(err) }
10
+
})
11
+
}
12
+
13
+
pub fn load(mut db pg.DB) {
14
+
println('-> loading sql code')
15
+
load_procedures(mut db)
16
+
println('<- done')
17
+
}
+12
src/beep_sql/procedures/search_posts.sql
+12
src/beep_sql/procedures/search_posts.sql
···
1
+
CREATE OR REPLACE FUNCTION search_for_posts (IN Query TEXT, IN Count INT, IN Index INT)
2
+
RETURNS SETOF "Post"
3
+
AS $$
4
+
SELECT *
5
+
FROM "Post"
6
+
WHERE title LIKE CONCAT('%', Query, '%') OR body LIKE CONCAT('%', Query, '%')
7
+
ORDER BY (CASE
8
+
WHEN title LIKE CONCAT('%', Query, '%') THEN 1
9
+
WHEN body LIKE CONCAT('%', Query, '%') THEN 2
10
+
END)
11
+
LIMIT Count OFFSET Index;
12
+
$$ LANGUAGE SQL;
+12
src/beep_sql/procedures/search_users.sql
+12
src/beep_sql/procedures/search_users.sql
···
1
+
CREATE OR REPLACE FUNCTION search_for_users (IN Query TEXT, IN Count INT, IN Index INT)
2
+
RETURNS SETOF "User"
3
+
AS $$
4
+
SELECT *
5
+
FROM "User"
6
+
WHERE username LIKE CONCAT('%', Query, '%') OR nickname LIKE CONCAT('%', Query, '%')
7
+
ORDER BY (CASE
8
+
WHEN username LIKE CONCAT('%', Query, '%') THEN 1
9
+
WHEN nickname LIKE CONCAT('%', Query, '%') THEN 2
10
+
END)
11
+
LIMIT Count OFFSET Index;
12
+
$$ LANGUAGE SQL;
-115
src/config.v
-115
src/config.v
···
1
-
module main
2
-
3
-
import emmathemartian.maple
4
-
5
-
pub struct Config {
6
-
pub mut:
7
-
dev_mode bool
8
-
static_path string
9
-
instance struct {
10
-
pub mut:
11
-
name string
12
-
welcome string
13
-
default_theme string
14
-
allow_changing_theme bool
15
-
}
16
-
http struct {
17
-
pub mut:
18
-
port int
19
-
}
20
-
postgres struct {
21
-
pub mut:
22
-
host string
23
-
port int
24
-
user string
25
-
password string
26
-
db string
27
-
}
28
-
post struct {
29
-
pub mut:
30
-
title_min_len int
31
-
title_max_len int
32
-
title_pattern string
33
-
body_min_len int
34
-
body_max_len int
35
-
body_pattern string
36
-
}
37
-
user struct {
38
-
pub mut:
39
-
username_min_len int
40
-
username_max_len int
41
-
username_pattern string
42
-
nickname_min_len int
43
-
nickname_max_len int
44
-
nickname_pattern string
45
-
password_min_len int
46
-
password_max_len int
47
-
password_pattern string
48
-
pronouns_min_len int
49
-
pronouns_max_len int
50
-
pronouns_pattern string
51
-
bio_min_len int
52
-
bio_max_len int
53
-
bio_pattern string
54
-
}
55
-
welcome struct {
56
-
pub mut:
57
-
summary string
58
-
body string
59
-
}
60
-
}
61
-
62
-
pub fn load_config_from(file_path string) Config {
63
-
loaded := maple.load_file(file_path) or { panic(err) }
64
-
mut config := Config{}
65
-
66
-
config.dev_mode = loaded.get('dev_mode').to_bool()
67
-
config.static_path = loaded.get('static_path').to_str()
68
-
69
-
loaded_instance := loaded.get('instance')
70
-
config.instance.name = loaded_instance.get('name').to_str()
71
-
config.instance.welcome = loaded_instance.get('welcome').to_str()
72
-
config.instance.default_theme = loaded_instance.get('default_theme').to_str()
73
-
config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool()
74
-
75
-
loaded_http := loaded.get('http')
76
-
config.http.port = loaded_http.get('port').to_int()
77
-
78
-
loaded_postgres := loaded.get('postgres')
79
-
config.postgres.host = loaded_postgres.get('host').to_str()
80
-
config.postgres.port = loaded_postgres.get('port').to_int()
81
-
config.postgres.user = loaded_postgres.get('user').to_str()
82
-
config.postgres.password = loaded_postgres.get('password').to_str()
83
-
config.postgres.db = loaded_postgres.get('db').to_str()
84
-
85
-
loaded_post := loaded.get('post')
86
-
config.post.title_min_len = loaded_post.get('title_min_len').to_int()
87
-
config.post.title_max_len = loaded_post.get('title_max_len').to_int()
88
-
config.post.title_pattern = loaded_post.get('title_pattern').to_str()
89
-
config.post.body_min_len = loaded_post.get('body_min_len').to_int()
90
-
config.post.body_max_len = loaded_post.get('body_max_len').to_int()
91
-
config.post.body_pattern = loaded_post.get('body_pattern').to_str()
92
-
93
-
loaded_user := loaded.get('user')
94
-
config.user.username_min_len = loaded_user.get('username_min_len').to_int()
95
-
config.user.username_max_len = loaded_user.get('username_max_len').to_int()
96
-
config.user.username_pattern = loaded_user.get('username_pattern').to_str()
97
-
config.user.nickname_min_len = loaded_user.get('nickname_min_len').to_int()
98
-
config.user.nickname_max_len = loaded_user.get('nickname_max_len').to_int()
99
-
config.user.nickname_pattern = loaded_user.get('nickname_pattern').to_str()
100
-
config.user.password_min_len = loaded_user.get('password_min_len').to_int()
101
-
config.user.password_max_len = loaded_user.get('password_max_len').to_int()
102
-
config.user.password_pattern = loaded_user.get('password_pattern').to_str()
103
-
config.user.pronouns_min_len = loaded_user.get('pronouns_min_len').to_int()
104
-
config.user.pronouns_max_len = loaded_user.get('pronouns_max_len').to_int()
105
-
config.user.pronouns_pattern = loaded_user.get('pronouns_pattern').to_str()
106
-
config.user.bio_min_len = loaded_user.get('bio_min_len').to_int()
107
-
config.user.bio_max_len = loaded_user.get('bio_max_len').to_int()
108
-
config.user.bio_pattern = loaded_user.get('bio_pattern').to_str()
109
-
110
-
loaded_welcome := loaded.get('welcome')
111
-
config.welcome.summary = loaded_welcome.get('summary').to_str()
112
-
config.welcome.body = loaded_welcome.get('body').to_str()
113
-
114
-
return config
115
-
}
-18
src/context.v
-18
src/context.v
···
1
-
module main
2
-
3
-
import veb
4
-
5
-
pub struct Context {
6
-
veb.Context
7
-
pub mut:
8
-
title string
9
-
}
10
-
11
-
pub fn (ctx &Context) is_logged_in() bool {
12
-
return ctx.get_cookie('token') or { '' } != ''
13
-
}
14
-
15
-
pub fn (mut ctx Context) unauthorized(msg string) veb.Result {
16
-
ctx.res.set_status(.unauthorized)
17
-
return ctx.send_response_to_client('text/plain', msg)
18
-
}
+25
src/database/database.v
+25
src/database/database.v
···
1
+
// **all** interactions with the database should be handled in this module.
2
+
module database
3
+
4
+
import db.pg
5
+
import entity { User, Post }
6
+
7
+
// DatabaseAccess handles all interactions with the database.
8
+
pub struct DatabaseAccess {
9
+
pub mut:
10
+
db pg.DB
11
+
}
12
+
13
+
// get_unknown_user returns a user representing an unknown user
14
+
pub fn (app &DatabaseAccess) get_unknown_user() User {
15
+
return User{
16
+
username: 'unknown'
17
+
}
18
+
}
19
+
20
+
// get_unknown_post returns a post representing an unknown post
21
+
pub fn (app &DatabaseAccess) get_unknown_post() Post {
22
+
return Post{
23
+
title: 'unknown'
24
+
}
25
+
}
+67
src/database/like.v
+67
src/database/like.v
···
1
+
module database
2
+
3
+
import entity { Like, LikeCache }
4
+
import util
5
+
6
+
// add_like adds a like to the database, returns true if this succeeds and false
7
+
// otherwise.
8
+
pub fn (app &DatabaseAccess) add_like(like &Like) bool {
9
+
sql app.db {
10
+
insert like into Like
11
+
// yeet the old cached like value
12
+
delete from LikeCache where post_id == like.post_id
13
+
} or {
14
+
return false
15
+
}
16
+
return true
17
+
}
18
+
19
+
// get_net_likes_for_post returns the net likes of the given post.
20
+
pub fn (app &DatabaseAccess) get_net_likes_for_post(post_id int) int {
21
+
// check cache
22
+
cache := app.db.exec_param('SELECT likes FROM "LikeCache" WHERE post_id = $1 LIMIT 1', post_id.str()) or { [] }
23
+
24
+
mut likes := 0
25
+
26
+
if cache.len != 1 {
27
+
println('calculating net likes for post: ${post_id}')
28
+
// calculate
29
+
db_likes := app.db.exec_param('SELECT is_like FROM "Like" WHERE post_id = $1', post_id.str()) or { [] }
30
+
for like in db_likes {
31
+
if util.or_throw(like.vals[0]).bool() {
32
+
likes++
33
+
} else {
34
+
likes--
35
+
}
36
+
}
37
+
38
+
// cache
39
+
cached := LikeCache{
40
+
post_id: post_id
41
+
likes: likes
42
+
}
43
+
sql app.db {
44
+
insert cached into LikeCache
45
+
} or {
46
+
eprintln('failed to cache like: ${cached}')
47
+
return likes
48
+
}
49
+
} else {
50
+
likes = util.or_throw(cache.first().vals[0]).int()
51
+
}
52
+
53
+
return likes
54
+
}
55
+
56
+
// unlike_post removes a (dis)like from the given post, returns true if this
57
+
// succeeds and false otherwise.
58
+
pub fn (app &DatabaseAccess) unlike_post(post_id int, user_id int) bool {
59
+
sql app.db {
60
+
delete from Like where user_id == user_id && post_id == post_id
61
+
// yeet the old cached like value
62
+
delete from LikeCache where post_id == post_id
63
+
} or {
64
+
return false
65
+
}
66
+
return true
67
+
}
+66
src/database/notification.v
+66
src/database/notification.v
···
1
+
module database
2
+
3
+
import entity { Notification }
4
+
5
+
// get_notification_by_id gets a notification by its given id, returns none if
6
+
// the notification does not exist.
7
+
pub fn (app &DatabaseAccess) get_notification_by_id(id int) ?Notification {
8
+
notifications := sql app.db {
9
+
select from Notification where id == id
10
+
} or { [] }
11
+
if notifications.len != 1 {
12
+
return none
13
+
}
14
+
return notifications[0]
15
+
}
16
+
17
+
// delete_notification deletes the given notification, returns true if this
18
+
// succeeded and false otherwise.
19
+
pub fn (app &DatabaseAccess) delete_notification(id int) bool {
20
+
sql app.db {
21
+
delete from Notification where id == id
22
+
} or {
23
+
return false
24
+
}
25
+
return true
26
+
}
27
+
28
+
// delete_notifications_for_user deletes all notifications for the given user,
29
+
// returns true if this succeeded and false otherwise.
30
+
pub fn (app &DatabaseAccess) delete_notifications_for_user(user_id int) bool {
31
+
sql app.db {
32
+
delete from Notification where user_id == user_id
33
+
} or {
34
+
return false
35
+
}
36
+
return true
37
+
}
38
+
39
+
// get_notifications_for gets a list of notifications for the given user.
40
+
pub fn (app &DatabaseAccess) get_notifications_for(user_id int) []Notification {
41
+
notifications := sql app.db {
42
+
select from Notification where user_id == user_id
43
+
} or { [] }
44
+
return notifications
45
+
}
46
+
47
+
// get_notification_count gets the amount of notifications a user has, with a
48
+
// given limit.
49
+
pub fn (app &DatabaseAccess) get_notification_count(user_id int, limit int) int {
50
+
notifications := app.db.exec_param2('SELECT id FROM "Notification" WHERE user_id = $1 LIMIT $2', user_id.str(), limit.str()) or { [] }
51
+
return notifications.len
52
+
}
53
+
54
+
// send_notification_to sends a notification to the given user.
55
+
pub fn (app &DatabaseAccess) send_notification_to(user_id int, summary string, body string) {
56
+
notification := Notification{
57
+
user_id: user_id
58
+
summary: summary
59
+
body: body
60
+
}
61
+
sql app.db {
62
+
insert notification into Notification
63
+
} or {
64
+
eprintln('failed to send notification ${notification}')
65
+
}
66
+
}
+184
src/database/post.v
+184
src/database/post.v
···
1
+
module database
2
+
3
+
import time
4
+
import db.pg
5
+
import entity { Post, User, Like, LikeCache }
6
+
import util
7
+
8
+
// add_post adds a new post to the database, returns true if this succeeded and
9
+
// false otherwise.
10
+
pub fn (app &DatabaseAccess) add_post(post &Post) bool {
11
+
sql app.db {
12
+
insert post into Post
13
+
} or {
14
+
return false
15
+
}
16
+
return true
17
+
}
18
+
19
+
// get_post_by_id gets a post by its id, returns none if it does not exist.
20
+
pub fn (app &DatabaseAccess) get_post_by_id(id int) ?Post {
21
+
posts := sql app.db {
22
+
select from Post where id == id limit 1
23
+
} or { [] }
24
+
if posts.len != 1 {
25
+
return none
26
+
}
27
+
return posts[0]
28
+
}
29
+
30
+
// get_post_by_author_and_timestamp gets a post by its author and timestamp,
31
+
// returns none if it does not exist
32
+
pub fn (app &DatabaseAccess) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post {
33
+
posts := sql app.db {
34
+
select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1
35
+
} or { [] }
36
+
if posts.len == 0 {
37
+
return none
38
+
}
39
+
return posts[0]
40
+
}
41
+
42
+
// get_posts_with_tag gets a list of the 10 most recent posts with the given tag.
43
+
// this performs sql string operations and probably is not very efficient, use
44
+
// sparingly.
45
+
pub fn (app &DatabaseAccess) get_posts_with_tag(tag string, offset int) []Post {
46
+
posts := sql app.db {
47
+
select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset
48
+
} or { [] }
49
+
return posts
50
+
}
51
+
52
+
// get_pinned_posts returns a list of all pinned posts.
53
+
pub fn (app &DatabaseAccess) get_pinned_posts() []Post {
54
+
posts := sql app.db {
55
+
select from Post where pinned == true
56
+
} or { [] }
57
+
return posts
58
+
}
59
+
60
+
// get_recent_posts returns a list of the ten most recent posts.
61
+
pub fn (app &DatabaseAccess) get_recent_posts() []Post {
62
+
posts := sql app.db {
63
+
select from Post order by posted_at desc limit 10
64
+
} or { [] }
65
+
return posts
66
+
}
67
+
68
+
// get_popular_posts returns a list of the ten most liked posts.
69
+
// TODO: make this time-gated (i.e, top ten liked posts of the day)
70
+
pub fn (app &DatabaseAccess) get_popular_posts() []Post {
71
+
cached_likes := app.db.exec('SELECT post_id FROM "LikeCache" ORDER BY likes DESC LIMIT 10') or { [] }
72
+
posts := cached_likes.map(fn [app] (it pg.Row) Post {
73
+
return app.get_post_by_id(util.or_throw(it.vals[0]).int()) or {
74
+
eprintln('cached like ${it} does not have a post related to it (from get_popular_posts)')
75
+
return Post{}
76
+
}
77
+
}).filter(it.id != 0)
78
+
return posts
79
+
}
80
+
81
+
// get_posts_from_user returns a list of all posts from a user in descending
82
+
// order by posting date.
83
+
pub fn (app &DatabaseAccess) get_posts_from_user(user_id int, limit int) []Post {
84
+
posts := sql app.db {
85
+
select from Post where author_id == user_id order by posted_at desc limit limit
86
+
} or { [] }
87
+
return posts
88
+
}
89
+
90
+
// get_all_posts_from_user returns a list of all posts from a user in descending
91
+
// order by posting date.
92
+
pub fn (app &DatabaseAccess) get_all_posts_from_user(user_id int) []Post {
93
+
posts := sql app.db {
94
+
select from Post where author_id == user_id order by posted_at desc
95
+
} or { [] }
96
+
return posts
97
+
}
98
+
99
+
// pin_post pins the given post, returns true if this succeeds and false
100
+
// otherwise.
101
+
pub fn (app &DatabaseAccess) pin_post(post_id int) bool {
102
+
sql app.db {
103
+
update Post set pinned = true where id == post_id
104
+
} or {
105
+
return false
106
+
}
107
+
return true
108
+
}
109
+
110
+
// update_post updates the given post's title and body with the given title and
111
+
// body, returns true if this succeeds and false otherwise.
112
+
pub fn (app &DatabaseAccess) update_post(post_id int, new_title string, new_body string, new_nsfw bool) bool {
113
+
sql app.db {
114
+
update Post set body = new_body, title = new_title, nsfw = new_nsfw where id == post_id
115
+
} or {
116
+
return false
117
+
}
118
+
return true
119
+
}
120
+
121
+
// delete_post deletes the given post and all likes associated with it, returns
122
+
// true if this succeeds and false otherwise.
123
+
pub fn (app &DatabaseAccess) delete_post(id int) bool {
124
+
sql app.db {
125
+
delete from Post where id == id
126
+
delete from Like where post_id == id
127
+
delete from LikeCache where post_id == id
128
+
} or {
129
+
return false
130
+
}
131
+
return true
132
+
}
133
+
134
+
////// searching //////
135
+
136
+
// PostSearchResult represents a search result for a post.
137
+
pub struct PostSearchResult {
138
+
pub mut:
139
+
post Post
140
+
author User
141
+
}
142
+
143
+
@[inline]
144
+
pub fn PostSearchResult.from_post(app &DatabaseAccess, post &Post) PostSearchResult {
145
+
return PostSearchResult{
146
+
post: post
147
+
author: app.get_user_by_id(post.author_id) or { app.get_unknown_user() }
148
+
}
149
+
}
150
+
151
+
@[inline]
152
+
pub fn PostSearchResult.from_post_list(app &DatabaseAccess, posts []Post) []PostSearchResult {
153
+
mut results := []PostSearchResult{
154
+
cap: posts.len,
155
+
len: posts.len
156
+
}
157
+
for index, post in posts {
158
+
results[index] = PostSearchResult.from_post(app, post)
159
+
}
160
+
return results
161
+
}
162
+
163
+
// search_for_posts searches for posts matching the given query.
164
+
// todo: levenshtein distance, query options/filters (user:beep, !excluded-text,
165
+
// etc)
166
+
pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult {
167
+
queried_posts := app.db.exec_param_many_result('SELECT * FROM search_for_posts($1, $2, $3)', [query, limit.str(), offset.str()]) or {
168
+
eprintln('search_for_posts error in app.db.error: ${err}')
169
+
pg.Result{}
170
+
}
171
+
posts := queried_posts.rows.map(fn [queried_posts] (it pg.Row) Post {
172
+
return Post.from_row(queried_posts, it)
173
+
})
174
+
return PostSearchResult.from_post_list(app, posts)
175
+
}
176
+
177
+
// get_post_count gets the number of posts in the database.
178
+
pub fn (app &DatabaseAccess) get_post_count() int {
179
+
n := app.db.exec('SELECT COUNT(id) FROM "Post"') or {
180
+
eprintln('get_post_count error in app.db.error: ${err}')
181
+
[]
182
+
}
183
+
return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() }
184
+
}
+158
src/database/saved_post.v
+158
src/database/saved_post.v
···
1
+
module database
2
+
3
+
import db.pg
4
+
import entity { SavedPost, Post }
5
+
import util
6
+
7
+
// get_saved_posts_for gets all SavedPost objects for a given user.
8
+
pub fn (app &DatabaseAccess) get_saved_posts_for(user_id int) []SavedPost {
9
+
saved_posts := sql app.db {
10
+
select from SavedPost where user_id == user_id && saved == true
11
+
} or { [] }
12
+
return saved_posts
13
+
}
14
+
15
+
// get_saved_posts_as_post_for gets all saved posts for a given user converted
16
+
// to Post objects.
17
+
pub fn (app &DatabaseAccess) get_saved_posts_as_post_for(user_id int) []Post {
18
+
saved_posts := app.db.exec_param('SELECT id, post_id FROM "SavedPost" WHERE user_id = $1 AND saved = TRUE', user_id.str()) or { [] }
19
+
posts := saved_posts.map(fn [app] (it pg.Row) Post {
20
+
return app.get_post_by_id(util.or_throw(it.vals[1]).int()) or {
21
+
// if the post does not exist, we will remove it now
22
+
id := util.or_throw(it.vals[0]).int()
23
+
sql app.db {
24
+
delete from SavedPost where id == id
25
+
} or {
26
+
eprintln('get_saved_posts_as_post_for: failed to remove non-existent post from saved post: ${it}')
27
+
}
28
+
app.get_unknown_post()
29
+
}
30
+
}).filter(it.id != 0)
31
+
return posts
32
+
}
33
+
34
+
// get_saved_posts_as_post_for gets all posts saved for later for a given user
35
+
// converted to Post objects.
36
+
pub fn (app &DatabaseAccess) get_saved_for_later_posts_as_post_for(user_id int) []Post {
37
+
saved_posts := sql app.db {
38
+
select from SavedPost where user_id == user_id && later == true
39
+
} or { [] }
40
+
posts := saved_posts.map(fn [app] (it SavedPost) Post {
41
+
return app.get_post_by_id(it.post_id) or {
42
+
// if the post does not exist, we will remove it now
43
+
sql app.db {
44
+
delete from SavedPost where id == it.id
45
+
} or {
46
+
eprintln('get_saved_for_later_posts_as_post_for: failed to remove non-existent post from saved post: ${it}')
47
+
}
48
+
app.get_unknown_post()
49
+
}
50
+
}).filter(it.id != 0)
51
+
return posts
52
+
}
53
+
54
+
// get_user_post_save_status returns the SavedPost object representing the user
55
+
// and post id. returns none if the post is not saved anywhere.
56
+
pub fn (app &DatabaseAccess) get_user_post_save_status(user_id int, post_id int) ?SavedPost {
57
+
saved_posts := sql app.db {
58
+
select from SavedPost where user_id == user_id && post_id == post_id
59
+
} or { [] }
60
+
if saved_posts.len == 1 {
61
+
return saved_posts[0]
62
+
} else if saved_posts.len == 0 {
63
+
return none
64
+
} else {
65
+
eprintln('get_user_post_save_status: user `${user_id}` had multiple SavedPost entries for post `${post_id}')
66
+
return none
67
+
}
68
+
}
69
+
70
+
pub fn (app &DatabaseAccess) is_post_saved_by(user_id int, post_id int) bool {
71
+
saved_post := app.get_user_post_save_status(user_id, post_id) or {
72
+
return false
73
+
}
74
+
return saved_post.saved
75
+
}
76
+
77
+
pub fn (app &DatabaseAccess) is_post_saved_for_later_by(user_id int, post_id int) bool {
78
+
saved_post := app.get_user_post_save_status(user_id, post_id) or {
79
+
return false
80
+
}
81
+
return saved_post.later
82
+
}
83
+
84
+
// toggle_save_post (un)saves the given post for the user. returns true if this
85
+
// succeeds and false otherwise.
86
+
pub fn (app &DatabaseAccess) toggle_save_post(user_id int, post_id int) bool {
87
+
if s := app.get_user_post_save_status(user_id, post_id) {
88
+
if s.saved {
89
+
sql app.db {
90
+
update SavedPost set saved = false where id == s.id
91
+
} or {
92
+
eprintln('toggle_save_post: failed to unsave post (user_id: ${user_id}, post_id: ${post_id})')
93
+
return false
94
+
}
95
+
return true
96
+
} else {
97
+
sql app.db {
98
+
update SavedPost set saved = true where id == s.id
99
+
} or {
100
+
eprintln('toggle_save_post: failed to save post (user_id: ${user_id}, post_id: ${post_id})')
101
+
return false
102
+
}
103
+
return true
104
+
}
105
+
} else {
106
+
post := SavedPost{
107
+
user_id: user_id
108
+
post_id: post_id
109
+
saved: true
110
+
later: false
111
+
}
112
+
sql app.db {
113
+
insert post into SavedPost
114
+
} or {
115
+
eprintln('toggle_save_post: failed to create saved post: ${post}')
116
+
return false
117
+
}
118
+
return true
119
+
}
120
+
}
121
+
122
+
// toggle_save_for_later_post (un)saves the given post for later for the user.
123
+
// returns true if this succeeds and false otherwise.
124
+
pub fn (app &DatabaseAccess) toggle_save_for_later_post(user_id int, post_id int) bool {
125
+
if s := app.get_user_post_save_status(user_id, post_id) {
126
+
if s.later {
127
+
sql app.db {
128
+
update SavedPost set later = false where id == s.id
129
+
} or {
130
+
eprintln('toggle_save_post: failed to unsave post for later (user_id: ${user_id}, post_id: ${post_id})')
131
+
return false
132
+
}
133
+
return true
134
+
} else {
135
+
sql app.db {
136
+
update SavedPost set later = true where id == s.id
137
+
} or {
138
+
eprintln('toggle_save_post: failed to save post for later (user_id: ${user_id}, post_id: ${post_id})')
139
+
return false
140
+
}
141
+
return true
142
+
}
143
+
} else {
144
+
post := SavedPost{
145
+
user_id: user_id
146
+
post_id: post_id
147
+
saved: false
148
+
later: true
149
+
}
150
+
sql app.db {
151
+
insert post into SavedPost
152
+
} or {
153
+
eprintln('toggle_save_post: failed to create saved post for later: ${post}')
154
+
return false
155
+
}
156
+
return true
157
+
}
158
+
}
+34
src/database/site.v
+34
src/database/site.v
···
1
+
module database
2
+
3
+
import entity { Site }
4
+
5
+
pub fn (app &DatabaseAccess) get_or_create_site_config() Site {
6
+
mut configs := sql app.db {
7
+
select from Site
8
+
} or { [] }
9
+
if configs.len == 0 {
10
+
// make the site config
11
+
site_config := Site{}
12
+
sql app.db {
13
+
insert site_config into Site
14
+
} or { panic('failed to create site config (${err})') }
15
+
configs = sql app.db {
16
+
select from Site
17
+
} or { [] }
18
+
} else if configs.len > 1 {
19
+
// this should never happen
20
+
panic('there are multiple site configs')
21
+
}
22
+
return configs[0]
23
+
}
24
+
25
+
// set_motd sets the site's current message of the day, returns true if this
26
+
// succeeds and false otherwise.
27
+
pub fn (app &DatabaseAccess) set_motd(motd string) bool {
28
+
sql app.db {
29
+
update Site set motd = motd where id == 1
30
+
} or {
31
+
return false
32
+
}
33
+
return true
34
+
}
+249
src/database/user.v
+249
src/database/user.v
···
1
+
module database
2
+
3
+
import entity { User, Notification, Like, LikeCache, Post }
4
+
import util
5
+
import db.pg
6
+
7
+
// new_user creates a new user and returns their struct after creation.
8
+
pub fn (app &DatabaseAccess) new_user(user User) ?User {
9
+
sql app.db {
10
+
insert user into User
11
+
} or {
12
+
eprintln('failed to insert user ${user}')
13
+
return none
14
+
}
15
+
16
+
println('reg: ${user.username}')
17
+
18
+
return app.get_user_by_name(user.username)
19
+
}
20
+
21
+
// set_username sets the given user's username, returns true if this succeeded
22
+
// and false otherwise.
23
+
pub fn (app &DatabaseAccess) set_username(user_id int, new_username string) bool {
24
+
sql app.db {
25
+
update User set username = new_username where id == user_id
26
+
} or {
27
+
eprintln('failed to update username for ${user_id}')
28
+
return false
29
+
}
30
+
return true
31
+
}
32
+
33
+
// set_password sets the given user's password, returns true if this succeeded
34
+
// and false otherwise.
35
+
pub fn (app &DatabaseAccess) set_password(user_id int, hashed_new_password string) bool {
36
+
sql app.db {
37
+
update User set password = hashed_new_password where id == user_id
38
+
} or {
39
+
eprintln('failed to update password for ${user_id}')
40
+
return false
41
+
}
42
+
return true
43
+
}
44
+
45
+
// set_nickname sets the given user's nickname, returns true if this succeeded
46
+
// and false otherwise.
47
+
pub fn (app &DatabaseAccess) set_nickname(user_id int, new_nickname ?string) bool {
48
+
sql app.db {
49
+
update User set nickname = new_nickname where id == user_id
50
+
} or {
51
+
eprintln('failed to update nickname for ${user_id}')
52
+
return false
53
+
}
54
+
return true
55
+
}
56
+
57
+
// set_muted sets the given user's muted status, returns true if this succeeded
58
+
// and false otherwise.
59
+
pub fn (app &DatabaseAccess) set_muted(user_id int, muted bool) bool {
60
+
sql app.db {
61
+
update User set muted = muted where id == user_id
62
+
} or {
63
+
eprintln('failed to update muted status for ${user_id}')
64
+
return false
65
+
}
66
+
return true
67
+
}
68
+
69
+
// set_automated sets the given user's automated status, returns true if this
70
+
// succeeded and false otherwise.
71
+
pub fn (app &DatabaseAccess) set_automated(user_id int, automated bool) bool {
72
+
sql app.db {
73
+
update User set automated = automated where id == user_id
74
+
} or {
75
+
eprintln('failed to update automated status for ${user_id}')
76
+
return false
77
+
}
78
+
return true
79
+
}
80
+
81
+
// set_theme sets the given user's theme url, returns true if this succeeded and
82
+
// false otherwise.
83
+
pub fn (app &DatabaseAccess) set_theme(user_id int, theme ?string) bool {
84
+
sql app.db {
85
+
update User set theme = theme where id == user_id
86
+
} or {
87
+
eprintln('failed to update theme url for ${user_id}')
88
+
return false
89
+
}
90
+
return true
91
+
}
92
+
93
+
// set_css sets the given user's custom CSS, returns true if this succeeded and
94
+
// false otherwise.
95
+
pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool {
96
+
sql app.db {
97
+
update User set css = css where id == user_id
98
+
} or {
99
+
eprintln('failed to update css for ${user_id}')
100
+
return false
101
+
}
102
+
return true
103
+
}
104
+
105
+
// set_pronouns sets the given user's pronouns, returns true if this succeeded
106
+
// and false otherwise.
107
+
pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool {
108
+
sql app.db {
109
+
update User set pronouns = pronouns where id == user_id
110
+
} or {
111
+
eprintln('failed to update pronouns for ${user_id}')
112
+
return false
113
+
}
114
+
return true
115
+
}
116
+
117
+
// set_bio sets the given user's bio, returns true if this succeeded and false
118
+
// otherwise.
119
+
pub fn (app &DatabaseAccess) set_bio(user_id int, bio string) bool {
120
+
sql app.db {
121
+
update User set bio = bio where id == user_id
122
+
} or {
123
+
eprintln('failed to update bio for ${user_id}')
124
+
return false
125
+
}
126
+
return true
127
+
}
128
+
129
+
// get_user_by_name gets a user by their username, returns none if the user was
130
+
// not found.
131
+
pub fn (app &DatabaseAccess) get_user_by_name(username string) ?User {
132
+
users := sql app.db {
133
+
select from User where username == username
134
+
} or { [] }
135
+
if users.len != 1 {
136
+
return none
137
+
}
138
+
return users[0]
139
+
}
140
+
141
+
// get_user_by_id gets a user by their id, returns none if the user was not
142
+
// found.
143
+
pub fn (app &DatabaseAccess) get_user_by_id(id int) ?User {
144
+
users := sql app.db {
145
+
select from User where id == id
146
+
} or { [] }
147
+
if users.len != 1 {
148
+
return none
149
+
}
150
+
return users[0]
151
+
}
152
+
153
+
// get_users returns all users.
154
+
pub fn (app &DatabaseAccess) get_users() []User {
155
+
users := sql app.db {
156
+
select from User
157
+
} or { [] }
158
+
return users
159
+
}
160
+
161
+
// does_user_like_post returns true if a user likes the given post.
162
+
pub fn (app &DatabaseAccess) does_user_like_post(user_id int, post_id int) bool {
163
+
likes := app.db.exec_param2('SELECT id, is_like FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] }
164
+
if likes.len > 1 {
165
+
// something is very wrong lol
166
+
eprintln('does_user_like_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
167
+
} else if likes.len == 0 {
168
+
return false
169
+
}
170
+
return util.or_throw(likes.first().vals[1]).bool()
171
+
}
172
+
173
+
// does_user_dislike_post returns true if a user dislikes the given post.
174
+
pub fn (app &DatabaseAccess) does_user_dislike_post(user_id int, post_id int) bool {
175
+
likes := app.db.exec_param2('SELECT id, is_like FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] }
176
+
if likes.len > 1 {
177
+
// something is very wrong lol
178
+
eprintln('does_user_dislike_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
179
+
} else if likes.len == 0 {
180
+
return false
181
+
}
182
+
return !util.or_throw(likes.first().vals[1]).bool()
183
+
}
184
+
185
+
// does_user_like_or_dislike_post returns true if a user likes *or* dislikes the
186
+
// given post.
187
+
pub fn (app &DatabaseAccess) does_user_like_or_dislike_post(user_id int, post_id int) bool {
188
+
likes := app.db.exec_param2('SELECT id FROM "Like" WHERE user_id = $1 AND post_id = $2', user_id.str(), post_id.str()) or { [] }
189
+
if likes.len > 1 {
190
+
// something is very wrong lol
191
+
eprintln('does_user_like_or_dislike_post: a user somehow got two or more likes on the same post (user: ${user_id}, post: ${post_id})')
192
+
}
193
+
return likes.len == 1
194
+
}
195
+
196
+
// delete_user deletes the given user and their data, returns true if this
197
+
// succeeded and false otherwise.
198
+
pub fn (app &DatabaseAccess) delete_user(user_id int) bool {
199
+
sql app.db {
200
+
delete from User where id == user_id
201
+
delete from Like where user_id == user_id
202
+
delete from Notification where user_id == user_id
203
+
} or {
204
+
return false
205
+
}
206
+
207
+
// delete posts and their likes
208
+
posts_from_this_user := app.db.exec_param('SELECT id FROM "Post" WHERE author_id = $1', user_id.str()) or { [] }
209
+
210
+
for post in posts_from_this_user {
211
+
id := util.or_throw(post.vals[0]).int()
212
+
sql app.db {
213
+
delete from Like where post_id == id
214
+
delete from LikeCache where post_id == id
215
+
} or {
216
+
eprintln('failed to delete like cache for post during user deletion: ${id}')
217
+
}
218
+
}
219
+
220
+
sql app.db {
221
+
delete from Post where author_id == user_id
222
+
} or {
223
+
eprintln('failed to delete posts by deleting user: ${user_id}')
224
+
}
225
+
226
+
return true
227
+
}
228
+
229
+
// search_for_users searches for posts matching the given query.
230
+
// todo: query options/filters, such as created-after:<date>, created-before:<date>, etc
231
+
pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User {
232
+
queried_users := app.db.exec_param_many_result('SELECT * FROM search_for_users($1, $2, $3)', [query, limit.str(), offset.str()]) or {
233
+
eprintln('search_for_users error in app.db.error: ${err}')
234
+
pg.Result{}
235
+
}
236
+
users := queried_users.rows.map(fn [queried_users] (it pg.Row) User {
237
+
return User.from_row(queried_users, it)
238
+
})
239
+
return users
240
+
}
241
+
242
+
// get_user_count gets the number of registered users in the database.
243
+
pub fn (app &DatabaseAccess) get_user_count() int {
244
+
n := app.db.exec('SELECT COUNT(id) FROM "User"') or {
245
+
eprintln('get_user_count error in app.db.error: ${err}')
246
+
[]
247
+
}
248
+
return if n.len == 0 { 0 } else { util.or_throw(n[0].vals[0]).int() }
249
+
}
+3
-3
src/entity/likes.v
+3
-3
src/entity/likes.v
···
1
1
module entity
2
2
3
-
// stores like information for posts
3
+
// Like stores like information for a post.
4
4
pub struct Like {
5
5
pub mut:
6
6
id int @[primary; sql: serial]
···
9
9
is_like bool
10
10
}
11
11
12
-
// Stores total likes per post
12
+
// LikeCache stores the total likes for a post.
13
13
pub struct LikeCache {
14
14
pub mut:
15
15
id int @[primary; sql: serial]
16
-
post_id int
16
+
post_id int @[unique]
17
17
likes int
18
18
}
+34
src/entity/post.v
+34
src/entity/post.v
···
1
1
module entity
2
2
3
+
import db.pg
3
4
import time
5
+
import util
4
6
5
7
pub struct Post {
6
8
pub mut:
···
12
14
body string
13
15
14
16
pinned bool
17
+
nsfw bool
15
18
16
19
posted_at time.Time = time.now()
17
20
}
21
+
22
+
// Post.from_row creates a post object from the given database row.
23
+
// see src/database/post.v#search_for_posts for usage.
24
+
@[inline]
25
+
pub fn Post.from_row(res pg.Result, row pg.Row) Post {
26
+
// curry some arguments for cleanliness
27
+
c := fn [res, row] (key string) ?string {
28
+
return util.get_row_col(res, row, key)
29
+
}
30
+
ct := fn [res, row] (key string) string {
31
+
return util.get_row_col_or_throw(res, row, key)
32
+
}
33
+
34
+
// this throws a cgen error when put in Post{}
35
+
//todo: report this
36
+
posted_at := time.parse(ct('posted_at')) or { panic(err) }
37
+
nsfw := util.map_or_throw[string, bool](ct('nsfw'), |it| it.bool())
38
+
39
+
return Post{
40
+
id: ct('id').int()
41
+
author_id: ct('author_id').int()
42
+
replying_to: if c('replying_to') == none { none } else {
43
+
util.map_or_throw[string, int](ct('replying_to'), |it| it.int())
44
+
}
45
+
title: ct('title')
46
+
body: ct('body')
47
+
pinned: util.map_or_throw[string, bool](ct('pinned'), |it| it.bool())
48
+
nsfw: nsfw
49
+
posted_at: posted_at
50
+
}
51
+
}
+16
src/entity/saved_post.v
+16
src/entity/saved_post.v
···
1
+
module entity
2
+
3
+
// SavedPost represents a saved post for a given user
4
+
pub struct SavedPost {
5
+
pub mut:
6
+
id int @[primary; sql: serial]
7
+
post_id int
8
+
user_id int
9
+
saved bool
10
+
later bool
11
+
}
12
+
13
+
// can_remove returns true if the SavedPost is neither saved or saved for later.
14
+
pub fn (post &SavedPost) can_remove() bool {
15
+
return !post.saved && !post.later
16
+
}
+1
src/entity/site.v
+1
src/entity/site.v
+44
-8
src/entity/user.v
+44
-8
src/entity/user.v
···
1
1
module entity
2
2
3
+
import db.pg
3
4
import time
5
+
import util
4
6
5
7
pub struct User {
6
8
pub mut:
···
11
13
password string
12
14
password_salt string
13
15
14
-
muted bool
15
-
admin bool
16
+
muted bool
17
+
admin bool
18
+
automated bool
16
19
17
-
theme ?string
20
+
theme string
21
+
css string
18
22
19
23
bio string
20
24
pronouns string
···
22
26
created_at time.Time = time.now()
23
27
}
24
28
29
+
// get_name returns the user's nickname if it is not none, if so then their
30
+
// username is returned.
25
31
@[inline]
26
32
pub fn (user User) get_name() string {
27
33
return user.nickname or { user.username }
28
34
}
29
35
30
-
@[inline]
31
-
pub fn (user User) get_theme() string {
32
-
return user.theme or { '' }
33
-
}
34
-
36
+
// to_str_without_sensitive_data returns the stringified data for the user with
37
+
// their password and salt censored.
35
38
@[inline]
36
39
pub fn (user User) to_str_without_sensitive_data() string {
37
40
return user.str()
38
41
.replace(user.password, '*'.repeat(16))
39
42
.replace(user.password_salt, '*'.repeat(16))
40
43
}
44
+
45
+
// User.from_row creates a user object from the given database row.
46
+
// see src/database/user.v#search_for_users for usage.
47
+
@[inline]
48
+
pub fn User.from_row(res pg.Result, row pg.Row) User {
49
+
// curry some arguments for cleanliness
50
+
c := fn [res, row] (key string) ?string {
51
+
return util.get_row_col(res, row, key)
52
+
}
53
+
ct := fn [res, row] (key string) string {
54
+
return util.get_row_col_or_throw(res, row, key)
55
+
}
56
+
57
+
// this throws a cgen error when put in User{}
58
+
//todo: report this
59
+
created_at := time.parse(ct('created_at')) or { panic(err) }
60
+
61
+
return User{
62
+
id: ct('id').int()
63
+
username: ct('username')
64
+
nickname: if c('nickname') == none { none } else {
65
+
ct('nickname')
66
+
}
67
+
password: 'haha lol, nope'
68
+
password_salt: 'haha lol, nope'
69
+
muted: util.map_or_throw[string, bool](util.get_row_col(res, row, 'muted'), |it| it.bool())
70
+
admin: util.map_or_throw[string, bool](util.get_row_col(res, row, 'admin'), |it| it.bool())
71
+
theme: ct('theme')
72
+
bio: ct('bio')
73
+
pronouns: ct('pronouns')
74
+
created_at: created_at
75
+
}
76
+
}
+87
-35
src/main.v
+87
-35
src/main.v
···
5
5
import auth
6
6
import entity
7
7
import os
8
+
import webapp { App, Context, StringValidator }
9
+
import beep_sql
10
+
import util
8
11
9
-
fn init_db(db pg.DB) ! {
10
-
sql db {
12
+
@[inline]
13
+
fn connect(mut app App) {
14
+
println('-> connecting to database...')
15
+
app.db = pg.connect(pg.Config{
16
+
host: app.config.postgres.host
17
+
dbname: app.config.postgres.db
18
+
user: app.config.postgres.user
19
+
password: app.config.postgres.password
20
+
port: app.config.postgres.port
21
+
}) or {
22
+
panic('failed to connect to database: ${err}')
23
+
}
24
+
}
25
+
26
+
@[inline]
27
+
fn init_db(mut app App) {
28
+
println('-> initializing database')
29
+
sql app.db {
11
30
create table entity.Site
12
31
create table entity.User
13
32
create table entity.Post
14
33
create table entity.Like
15
34
create table entity.LikeCache
16
35
create table entity.Notification
17
-
}!
36
+
create table entity.SavedPost
37
+
} or {
38
+
panic('failed to initialize database: ${err}')
39
+
}
40
+
}
41
+
42
+
@[inline]
43
+
fn load_validators(mut app App) {
44
+
// vfmt off
45
+
user := app.config.user
46
+
app.validators.username = StringValidator.new(user.username_min_len, user.username_max_len, user.username_pattern)
47
+
app.validators.password = StringValidator.new(user.password_min_len, user.password_max_len, user.password_pattern)
48
+
app.validators.nickname = StringValidator.new(user.nickname_min_len, user.nickname_max_len, user.nickname_pattern)
49
+
app.validators.user_bio = StringValidator.new(user.bio_min_len, user.bio_max_len, user.bio_pattern)
50
+
app.validators.pronouns = StringValidator.new(user.pronouns_min_len, user.pronouns_max_len, user.pronouns_pattern)
51
+
post := app.config.post
52
+
app.validators.post_title = StringValidator.new(post.title_min_len, post.title_max_len, post.title_pattern)
53
+
app.validators.post_body = StringValidator.new(post.body_min_len, post.body_max_len, post.body_pattern)
54
+
// vfmt on
18
55
}
19
56
20
57
fn main() {
21
-
config := load_config_from(os.args[1])
22
-
23
-
println('-> connecting to db...')
24
-
mut db := pg.connect(pg.Config{
25
-
host: config.postgres.host
26
-
dbname: config.postgres.db
27
-
user: config.postgres.user
28
-
password: config.postgres.password
29
-
port: config.postgres.port
30
-
})!
31
-
println('<- connected')
32
-
33
-
defer {
34
-
db.close()
35
-
}
58
+
mut stopwatch := util.Stopwatch.new()
36
59
37
60
mut app := &App{
38
-
config: config
39
-
db: db
40
-
auth: auth.new(db)
61
+
config: if os.args.len > 1 {
62
+
webapp.load_config_from(os.args[1])
63
+
} else if os.exists('config.real.maple') {
64
+
webapp.load_config_from('config.real.maple')
65
+
} else {
66
+
panic('no config found nor specified!')
67
+
}
68
+
buildinfo: if os.exists('buildinfo.maple') {
69
+
webapp.load_buildinfo_from('buildinfo.maple')
70
+
} else {
71
+
webapp.BuildInfo{}
72
+
}
41
73
}
42
74
43
-
// vfmt off
44
-
app.validators.username = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern)
45
-
app.validators.password = StringValidator.new(config.user.username_min_len, config.user.username_max_len, config.user.username_pattern)
46
-
app.validators.nickname = StringValidator.new(config.user.nickname_min_len, config.user.nickname_max_len, config.user.nickname_pattern)
47
-
app.validators.user_bio = StringValidator.new(config.user.bio_min_len, config.user.bio_max_len, config.user.bio_pattern)
48
-
app.validators.pronouns = StringValidator.new(config.user.pronouns_min_len, config.user.pronouns_max_len, config.user.pronouns_pattern)
49
-
app.validators.post_title = StringValidator.new(config.post.title_min_len, config.post.title_max_len, config.post.title_pattern)
50
-
app.validators.post_body = StringValidator.new(config.post.body_min_len, config.post.body_max_len, config.post.body_pattern)
51
-
// vfmt on
75
+
// connect to database
76
+
util.time_it(
77
+
it: fn [mut app] () {
78
+
connect(mut app)
79
+
}
80
+
name: 'connect to db'
81
+
log: true
82
+
)
83
+
defer { app.db.close() or { panic(err) } }
84
+
85
+
// initialize database
86
+
util.time_it(it: fn [mut app] () {
87
+
init_db(mut app)
88
+
}, name: 'init db', log: true)
89
+
90
+
// load sql files kept in beep_sql/
91
+
util.time_it(
92
+
it: fn [mut app] () {
93
+
beep_sql.load(mut app.db)
94
+
}
95
+
name: 'load beep_sql'
96
+
log: true
97
+
)
52
98
53
-
app.mount_static_folder_at(app.config.static_path, '/static')!
99
+
// add authenticator
100
+
app.auth = auth.new(app.db)
54
101
55
-
println('-> initializing database...')
56
-
init_db(db)!
57
-
println('<- done')
102
+
// load validators
103
+
load_validators(mut app)
104
+
105
+
// mount static things
106
+
app.mount_static_folder_at(app.config.static_path, '/static')!
58
107
59
108
// make the website config, if it does not exist
60
109
app.get_or_create_site_config()
61
110
62
-
if config.dev_mode {
111
+
if app.config.dev_mode {
63
112
println('\033[1;31mNOTE: YOU ARE IN DEV MODE\033[0m')
64
113
}
114
+
115
+
stop := stopwatch.stop()
116
+
println('-> took ${stop} to start app')
65
117
66
118
veb.run[App, Context](mut app, app.config.http.port)
67
119
}
-175
src/pages.v
-175
src/pages.v
···
1
-
module main
2
-
3
-
import veb
4
-
import entity { User }
5
-
6
-
fn (mut app App) index(mut ctx Context) veb.Result {
7
-
ctx.title = app.config.instance.name
8
-
user := app.whoami(mut ctx) or { User{} }
9
-
recent_posts := app.get_recent_posts()
10
-
pinned_posts := app.get_pinned_posts()
11
-
motd := app.get_motd()
12
-
return $veb.html()
13
-
}
14
-
15
-
fn (mut app App) login(mut ctx Context) veb.Result {
16
-
ctx.title = 'login to ${app.config.instance.name}'
17
-
user := app.whoami(mut ctx) or { User{} }
18
-
return $veb.html()
19
-
}
20
-
21
-
fn (mut app App) register(mut ctx Context) veb.Result {
22
-
ctx.title = 'register for ${app.config.instance.name}'
23
-
user := app.whoami(mut ctx) or { User{} }
24
-
return $veb.html()
25
-
}
26
-
27
-
fn (mut app App) me(mut ctx Context) veb.Result {
28
-
user := app.whoami(mut ctx) or {
29
-
ctx.error('not logged in')
30
-
return ctx.redirect('/login')
31
-
}
32
-
ctx.title = '${app.config.instance.name} - ${user.get_name()}'
33
-
return ctx.redirect('/user/${user.username}')
34
-
}
35
-
36
-
fn (mut app App) settings(mut ctx Context) veb.Result {
37
-
user := app.whoami(mut ctx) or {
38
-
ctx.error('not logged in')
39
-
return ctx.redirect('/login')
40
-
}
41
-
ctx.title = '${app.config.instance.name} - settings'
42
-
return $veb.html()
43
-
}
44
-
45
-
fn (mut app App) admin(mut ctx Context) veb.Result {
46
-
ctx.title = '${app.config.instance.name} dashboard'
47
-
user := app.whoami(mut ctx) or { User{} }
48
-
return $veb.html()
49
-
}
50
-
51
-
fn (mut app App) inbox(mut ctx Context) veb.Result {
52
-
user := app.whoami(mut ctx) or {
53
-
ctx.error('not logged in')
54
-
return ctx.redirect('/login')
55
-
}
56
-
ctx.title = '${app.config.instance.name} inbox'
57
-
notifications := app.get_notifications_for(user.id)
58
-
return $veb.html()
59
-
}
60
-
61
-
fn (mut app App) logout(mut ctx Context) veb.Result {
62
-
user := app.whoami(mut ctx) or {
63
-
ctx.error('not logged in')
64
-
return ctx.redirect('/login')
65
-
}
66
-
ctx.title = '${app.config.instance.name} logout'
67
-
return $veb.html()
68
-
}
69
-
70
-
@['/user/:username']
71
-
fn (mut app App) user(mut ctx Context, username string) veb.Result {
72
-
user := app.whoami(mut ctx) or { User{} }
73
-
viewing := app.get_user_by_name(username) or {
74
-
ctx.error('user not found')
75
-
return ctx.redirect('/')
76
-
}
77
-
ctx.title = '${app.config.instance.name} - ${user.get_name()}'
78
-
return $veb.html()
79
-
}
80
-
81
-
@['/post/:post_id']
82
-
fn (mut app App) post(mut ctx Context, post_id int) veb.Result {
83
-
post := app.get_post_by_id(post_id) or {
84
-
ctx.error('no such post')
85
-
return ctx.redirect('/')
86
-
}
87
-
ctx.title = '${app.config.instance.name} - ${post.title}'
88
-
user := app.whoami(mut ctx) or { User{} }
89
-
90
-
mut replying_to_post := app.get_unknown_post()
91
-
mut replying_to_user := app.get_unknown_user()
92
-
93
-
if post.replying_to != none {
94
-
replying_to_post = app.get_post_by_id(post.replying_to) or {
95
-
app.get_unknown_post()
96
-
}
97
-
replying_to_user = app.get_user_by_id(replying_to_post.author_id) or {
98
-
app.get_unknown_user()
99
-
}
100
-
}
101
-
102
-
return $veb.html()
103
-
}
104
-
105
-
@['/post/:post_id/edit']
106
-
fn (mut app App) edit(mut ctx Context, post_id int) veb.Result {
107
-
user := app.whoami(mut ctx) or {
108
-
ctx.error('not logged in')
109
-
return ctx.redirect('/login')
110
-
}
111
-
post := app.get_post_by_id(post_id) or {
112
-
ctx.error('no such post')
113
-
return ctx.redirect('/')
114
-
}
115
-
if post.author_id != user.id {
116
-
ctx.error('insufficient permissions')
117
-
return ctx.redirect('/post/${post_id}')
118
-
}
119
-
ctx.title = '${app.config.instance.name} - editing ${post.title}'
120
-
return $veb.html()
121
-
}
122
-
123
-
@['/post/:post_id/reply']
124
-
fn (mut app App) reply(mut ctx Context, post_id int) veb.Result {
125
-
user := app.whoami(mut ctx) or {
126
-
ctx.error('not logged in')
127
-
return ctx.redirect('/login')
128
-
}
129
-
post := app.get_post_by_id(post_id) or {
130
-
ctx.error('no such post')
131
-
return ctx.redirect('/')
132
-
}
133
-
ctx.title = '${app.config.instance.name} - reply to ${post.title}'
134
-
replying := true
135
-
replying_to := post_id
136
-
replying_to_user := app.get_user_by_id(post.author_id) or {
137
-
ctx.error('no such post')
138
-
return ctx.redirect('/')
139
-
}
140
-
return $veb.html('templates/new_post.html')
141
-
}
142
-
143
-
@['/post/new']
144
-
fn (mut app App) new(mut ctx Context) veb.Result {
145
-
user := app.whoami(mut ctx) or {
146
-
ctx.error('not logged in')
147
-
return ctx.redirect('/login')
148
-
}
149
-
ctx.title = '${app.config.instance.name} - new post'
150
-
replying := false
151
-
replying_to := 0
152
-
replying_to_user := User{}
153
-
return $veb.html('templates/new_post.html')
154
-
}
155
-
156
-
@['/tag/:tag']
157
-
fn (mut app App) tag(mut ctx Context, tag string) veb.Result {
158
-
user := app.whoami(mut ctx) or {
159
-
ctx.error('not logged in')
160
-
return ctx.redirect('/login')
161
-
}
162
-
ctx.title = '${app.config.instance.name} - #${tag}'
163
-
offset := 0
164
-
return $veb.html()
165
-
}
166
-
167
-
@['/tag/:tag/:offset']
168
-
fn (mut app App) tag_with_offset(mut ctx Context, tag string, offset int) veb.Result {
169
-
user := app.whoami(mut ctx) or {
170
-
ctx.error('not logged in')
171
-
return ctx.redirect('/login')
172
-
}
173
-
ctx.title = '${app.config.instance.name} - #${tag}'
174
-
return $veb.html('templates/tag.html')
175
-
}
+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
+
}
+14
src/static/js/post.js
+14
src/static/js/post.js
···
11
11
})
12
12
window.location.reload()
13
13
}
14
+
15
+
const save = async id => {
16
+
await fetch('/api/post/save?id=' + id, {
17
+
method: 'GET'
18
+
})
19
+
window.location.reload()
20
+
}
21
+
22
+
const save_for_later = async id => {
23
+
await fetch('/api/post/save_for_later?id=' + id, {
24
+
method: 'GET'
25
+
})
26
+
window.location.reload()
27
+
}
+111
-26
src/static/js/render_body.js
+111
-26
src/static/js/render_body.js
···
1
-
// TODO: move this to the backend?
1
+
const get_apple_music_iframe = src =>
2
+
`<iframe
3
+
class="post-iframe iframe-music iframe-music-apple"
4
+
style="border-radius:12px"
5
+
width="100%"
6
+
height="152"
7
+
frameBorder="0"
8
+
allowfullscreen=""
9
+
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
10
+
loading="lazy"
11
+
src="${src}"
12
+
></iframe>`
13
+
14
+
const get_spotify_iframe = src =>
15
+
`<iframe
16
+
class="post-iframe iframe-music iframe-music-spotify"
17
+
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
18
+
frameborder="0"
19
+
height="175"
20
+
style="width:100%;overflow:hidden;border-radius:10px;"
21
+
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
22
+
loading="lazy"
23
+
src="${src}"
24
+
></iframe>`
25
+
26
+
const get_youtube_frame = src =>
27
+
`<iframe
28
+
width="560"
29
+
height="315"
30
+
src="${src}"
31
+
title="YouTube video player"
32
+
frameborder="0"
33
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
34
+
referrerpolicy="strict-origin-when-cross-origin"
35
+
allowfullscreen
36
+
></iframe>`
37
+
38
+
const link_handlers = {
39
+
'https://music.apple.com/': link => {
40
+
const embed_url = `https://embed.${link.substring(8)}`
41
+
return get_apple_music_iframe(embed_url)
42
+
},
43
+
'https://open.spotify.com/': link => {
44
+
const type = link.substring(link.indexOf('/', 8) + 1, link.indexOf('/', link.indexOf('/', 8) + 1))
45
+
const id = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?'))
46
+
const embed_url = `https://open.spotify.com/embed/${type}/${id}?utm_source=generator&theme=0`
47
+
return get_spotify_iframe(embed_url)
48
+
},
49
+
'https://youtu.be/': link => {
50
+
const id = link.substring(link.lastIndexOf('/') + 1, link.indexOf('?'))
51
+
const embed_url = `https://www.youtube.com/embed/${id}`
52
+
return get_youtube_frame(embed_url)
53
+
},
54
+
}
55
+
2
56
const render_body = async id => {
3
57
const element = document.getElementById(id)
4
58
var body = element.innerText
59
+
var html = element.innerHTML
5
60
6
-
const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g)
61
+
// give the body a loading """animation""" while we let the fetches cook
62
+
element.innerText = 'loading...'
63
+
64
+
const matches = body.matchAll(/\\?[@#*]\([a-zA-Z0-9_.-]*\)/g)
7
65
const cache = {}
8
66
for (const match of matches) {
67
+
// escaped
68
+
if (match[0][0] == '\\') {
69
+
html = html.replace(match[0], match[0].replace('\\', ''))
70
+
}
9
71
// mention
10
-
if (match[0][0] == '@') {
72
+
else if (match[0][0] == '@') {
11
73
if (cache.hasOwnProperty(match[0])) {
12
-
element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]])
74
+
html = html.replace(match[0], cache[match[0]])
13
75
continue
14
76
}
15
-
(await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text().then(s => {
16
-
if (s == 'no such user') {
17
-
return
18
-
}
19
-
const link = document.createElement('a')
20
-
link.href = `/user/${match[0].substring(2, match[0].length - 1)}`
21
-
link.innerText = '@' + s
22
-
cache[match[0]] = link.outerHTML
23
-
element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML)
24
-
})
77
+
const s = await (await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text()
78
+
const link = document.createElement('a')
79
+
link.href = `/user/${match[0].substring(2, match[0].length - 1)}`
80
+
link.innerText = '@' + s
81
+
cache[match[0]] = link.outerHTML
82
+
html = html.replace(match[0], link.outerHTML)
25
83
}
26
84
// tags
27
85
else if (match[0][0] == '#') {
···
33
91
link.href = `/tag/${tag}`
34
92
link.innerText = '#' + tag
35
93
cache[match[0]] = link.outerHTML
36
-
element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML)
94
+
html = html.replace(match[0], link.outerHTML)
37
95
}
38
96
// post reference
39
97
else if (match[0][0] == '*') {
40
98
if (cache.hasOwnProperty(match[0])) {
41
-
element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]])
99
+
html = html.replace(match[0], cache[match[0]])
42
100
continue
43
101
}
44
-
(await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text().then(s => {
45
-
if (s == 'no such post') {
46
-
return
47
-
}
48
-
const link = document.createElement('a')
49
-
link.href = `/post/${match[0].substring(2, match[0].length - 1)}`
50
-
link.innerText = '*' + s
51
-
cache[match[0]] = link.outerHTML
52
-
element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML)
53
-
})
102
+
const s = await (await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text()
103
+
const link = document.createElement('a')
104
+
link.href = `/post/${match[0].substring(2, match[0].length - 1)}`
105
+
link.innerText = '*' + s
106
+
cache[match[0]] = link.outerHTML
107
+
html = html.replace(match[0], link.outerHTML)
54
108
}
55
109
}
110
+
111
+
var handled_links = []
112
+
// i am not willing to write a url regex myself, so here is where i got
113
+
// this: https://stackoverflow.com/a/3809435
114
+
const links = html.matchAll(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g)
115
+
for (const match of links) {
116
+
const link = match[0]
117
+
for (const entry of Object.entries(link_handlers)) {
118
+
if (link.startsWith(entry[0])) {
119
+
handled_links.push(entry[1](link))
120
+
break
121
+
}
122
+
}
123
+
// sanatize the link before rendering it directly. no link
124
+
// should ever have these three characters in them anyway.
125
+
const sanatized = link
126
+
.replace('<', '>')
127
+
.replace('>', '<')
128
+
.replace('"', '"')
129
+
html = html.replace(link, `<a href="${sanatized}">${sanatized}</a>`)
130
+
}
131
+
132
+
// append handled links
133
+
if (handled_links.length > 0) {
134
+
// element.innerHTML += '\n\nlinks:\n'
135
+
for (const handled of handled_links) {
136
+
html += `\n\n${handled}`
137
+
}
138
+
}
139
+
140
+
element.innerHTML = html
56
141
}
+15
src/static/js/search.js
+15
src/static/js/search.js
···
1
+
const search_posts = async (query, limit, offset) => {
2
+
const data = await fetch(`/api/post/search?query=${query}&limit=${limit}&offset=${offset}`, {
3
+
method: 'GET'
4
+
})
5
+
const json = await data.json()
6
+
return json
7
+
}
8
+
9
+
const search_users = async (query, limit, offset) => {
10
+
const data = await fetch(`/api/user/search?query=${query}&limit=${limit}&offset=${offset}`, {
11
+
method: 'GET'
12
+
})
13
+
const json = await data.json()
14
+
return json
15
+
}
+10
src/static/js/text_area_counter.js
+10
src/static/js/text_area_counter.js
···
1
+
// this script is used to provide character counters to textareas
2
+
3
+
const add_character_counter = (textarea_id, p_id, max_len) => {
4
+
const textarea = document.getElementById(textarea_id)
5
+
const p = document.getElementById(p_id)
6
+
textarea.addEventListener('input', () => {
7
+
p.innerText = textarea.value.length + '/' + max_len
8
+
})
9
+
p.innerText = textarea.value.length + '/' + max_len
10
+
}
+1
src/static/js/user_utils.js
+1
src/static/js/user_utils.js
···
1
+
const get_display_name = user => user.nickname == undefined ? user.username : user.nickname
+26
-2
src/static/style.css
+26
-2
src/static/style.css
···
1
+
:root {
2
+
--c-nsfw-border: red;
3
+
}
4
+
1
5
.post,
2
6
.notification {
3
7
border: 2px solid;
4
8
padding: 8px;
5
9
}
6
10
7
-
.post p,
8
-
.notification p {
11
+
.post > p,
12
+
.notification > p {
9
13
margin: 0;
14
+
}
15
+
16
+
.post > pre,
17
+
.notification > pre {
18
+
margin: 0;
19
+
display: inline;
10
20
}
11
21
12
22
.post + .post,
···
16
26
17
27
pre {
18
28
white-space: pre-wrap;
29
+
word-wrap: break-word;
30
+
}
31
+
32
+
span.nsfw-indicator {
33
+
border: 2px solid var(--c-nsfw-border);
34
+
border-radius: 2px;
35
+
padding-left: 4px;
36
+
padding-right: 4px;
37
+
margin-left: 6px;
38
+
}
39
+
40
+
details>summary:hover {
41
+
cursor: pointer;
19
42
}
20
43
21
44
/*
···
24
47
*/
25
48
input[hidden] {
26
49
display: none !important;
50
+
visibility: none !important;
27
51
}
+239
src/static/themes/default.css
+239
src/static/themes/default.css
···
1
+
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@100..900&family=Oxygen+Mono&display=swap');
2
+
3
+
:root {
4
+
/* palette */
5
+
/* greys */
6
+
--p-black: #333333;
7
+
--p-grey0: #414141;
8
+
--p-grey1: #4a4a4a;
9
+
--p-grey2: #4f4f4f;
10
+
--p-grey3: #5c5c5c;
11
+
--p-grey4: #5f5f5f;
12
+
--p-white: #e7e7e7;
13
+
/* rainbow */
14
+
--p-red: #faa; /* == light red */
15
+
--p-orange: #fa7;
16
+
--p-yellow: #ffa; /* == light-orange */
17
+
--p-teal: #7fa;
18
+
--p-green: #af7;
19
+
--p-blue: #7af;
20
+
--p-purple: #a7f;
21
+
--p-pink: #f7a;
22
+
/* light rainbow */
23
+
--p-light-red: #faa;
24
+
--p-light-blue: #aaf;
25
+
--p-light-green: #afa;
26
+
--p-light-orange: #ffa; /* == yellow */
27
+
--p-light-purple: #faf;
28
+
--p-light-blue: #aff;
29
+
30
+
/* colours */
31
+
--c-bg: var(--p-black);
32
+
--c-panel-bg: var(--p-grey0);
33
+
--c-panel-border: var(--p-grey2);
34
+
--c-panel2-bg: var(--p-grey1);
35
+
--c-panel2-border: var(--p-grey3);
36
+
--c-panel3-bg: var(--p-grey2);
37
+
--c-panel3-border: var(--p-grey4);
38
+
--c-fg: var(--p-white);
39
+
--c-nsfw-border: var(--p-orange);
40
+
--c-link: var(--p-blue);
41
+
--c-link-hover: var(--p-light-blue);
42
+
--c-accent: var(--p-light-green);
43
+
--c-notify-ok: var(--p-light-green);
44
+
--c-notify-error: var(--p-light-red);
45
+
46
+
/* text */
47
+
--t-font: 'Onest', Arial, serif;
48
+
--t-post-font: Garamond, 'Times New Roman', var(--t-font);
49
+
--t-mono-font: 'Oxygen Mono', monospace;
50
+
--t-h-font: 'Oxygen Mono', var(--t-post-font);
51
+
--t-font-weight: 400;
52
+
--t-font-style: normal;
53
+
--t-font-size: 20px;
54
+
55
+
/* layout */
56
+
--l-body-padding: 16px;
57
+
--l-body-gap: 12px;
58
+
--l-body-width: 75vw;
59
+
--l-border-width: 2px;
60
+
--l-border-style: solid;
61
+
--l-border-radius: 0px;
62
+
}
63
+
64
+
html {
65
+
padding: 0;
66
+
offset: 0;
67
+
margin: 0;
68
+
69
+
width: 100vw;
70
+
overflow-x: hidden;
71
+
72
+
display: flex;
73
+
flex-direction: column;
74
+
align-items: center;
75
+
76
+
background-color: var(--c-bg);
77
+
color: var(--c-fg);
78
+
79
+
font-family: var(--t-font);
80
+
font-weight: var(--t-font-weight);
81
+
font-style: var(--t-font-style);
82
+
font-size: var(--t-font-size);
83
+
}
84
+
85
+
body {
86
+
padding: var(--l-body-padding) 0 var(--l-body-padding) 0;
87
+
offset: 0;
88
+
margin: 0;
89
+
width: var(--l-body-width);
90
+
}
91
+
92
+
header {
93
+
padding-bottom: var(--l-body-padding);
94
+
}
95
+
96
+
footer {
97
+
padding-top: var(--l-body-padding);
98
+
}
99
+
100
+
main {
101
+
padding: var(--l-body-padding);
102
+
background-color: var(--c-panel-bg);
103
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel-border);
104
+
border-radius: var(--l-border-radius);
105
+
106
+
display: flex;
107
+
flex-direction: column;
108
+
gap: var(--l-body-gap);
109
+
}
110
+
111
+
form {
112
+
display: flex;
113
+
flex-direction: column;
114
+
gap: var(--l-body-gap);
115
+
}
116
+
117
+
button:hover {
118
+
cursor: pointer;
119
+
}
120
+
121
+
input,
122
+
textarea,
123
+
button {
124
+
background-color: var(--c-panel-bg);
125
+
color: var(--c-fg);
126
+
127
+
border: var(--l-border-width) var(--l-border-style) var(--c-accent);
128
+
border-radius: var(--l-border-radius);
129
+
padding: 6px;
130
+
131
+
font-family: var(--t-font);
132
+
}
133
+
134
+
input:hover,
135
+
textarea:hover,
136
+
button:hover {
137
+
border-color: var(--c-fg);
138
+
}
139
+
140
+
input:focus,
141
+
textarea:focus,
142
+
button:focus {
143
+
background-color: var(--c-accent);
144
+
color: var(--c-bg);
145
+
}
146
+
147
+
h1, h2, h3, h4, h5, h6, p {
148
+
margin: 0;
149
+
}
150
+
151
+
h1, header, footer {
152
+
font-family: var(--t-h-font);
153
+
}
154
+
155
+
a {
156
+
color: var(--c-link);
157
+
transition: 0.15s linear color;
158
+
}
159
+
160
+
a:hover {
161
+
color: var(--c-link-hover);
162
+
}
163
+
164
+
hr {
165
+
width: 100%;
166
+
}
167
+
168
+
pre {
169
+
font-family: var(--t-mono-font);
170
+
}
171
+
172
+
.post {
173
+
border: none;
174
+
border-left: var(--l-border-width) var(--l-border-style) var(--c-fg);
175
+
}
176
+
177
+
.post>pre {
178
+
font-family: var(--t-post-font);
179
+
}
180
+
181
+
.post + .post,
182
+
.notification + .notification {
183
+
margin-top: 18px;
184
+
}
185
+
186
+
form:not(.form-inline),
187
+
#recent-posts,
188
+
#pinned-posts {
189
+
padding: 16px 24px 16px 24px;
190
+
background-color: var(--c-panel2-bg);
191
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel2-border);
192
+
border-radius: var(--l-border-radius);
193
+
}
194
+
195
+
#errors:empty {
196
+
display: none;
197
+
visibility: hidden;
198
+
}
199
+
200
+
#errors {
201
+
display: flex;
202
+
flex-direction: column;
203
+
gap: var(--l-body-gap);
204
+
}
205
+
206
+
#errors>p {
207
+
background-color: var(--c-panel3-bg);
208
+
border: var(--l-border-width) var(--l-border-style) var(--c-panel3-border);
209
+
border-radius: var(--l-border-radius);
210
+
211
+
padding: 8px;
212
+
width: calc(100% - 16px);
213
+
214
+
display: inline-flex;
215
+
align-items: center;
216
+
justify-content: center;
217
+
gap: 12px;
218
+
}
219
+
220
+
#errors>p>button {
221
+
border-color: inherit;
222
+
flex-grow: 0;
223
+
}
224
+
225
+
#errors>p>button:hover {
226
+
border-color: var(--c-fg);
227
+
}
228
+
229
+
#errors>p>span {
230
+
flex-grow: 1;
231
+
}
232
+
233
+
#errors>p.ok {
234
+
border-color: var(--c-notify-ok);
235
+
}
236
+
237
+
#errors>p.error {
238
+
border-color: var(--c-notify-error);
239
+
}
+34
src/templates/about.html
+34
src/templates/about.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<h1>about this instance</h1>
4
+
5
+
<div>
6
+
<p><strong>general:</strong></p>
7
+
<p>name: @{app.config.instance.name}</p>
8
+
<p>version: @{app.config.instance.version}</p>
9
+
<p>public: @{app.config.instance.public_data}</p>
10
+
@if app.config.instance.owner_username != ''
11
+
<p>owner: <a href="/user/@{app.config.instance.owner_username}">@{app.config.instance.owner_username}</a></p>
12
+
@end
13
+
14
+
<br>
15
+
<p><strong>stats:</strong></p>
16
+
<p>users: @{app.get_user_count()}</p>
17
+
<p>posts: @{app.get_post_count()}</p>
18
+
19
+
@if app.config.instance.source != ''
20
+
<br>
21
+
<p><strong>nerd info:</strong></p>
22
+
<p>beep source: <a href="@{app.config.instance.source}">@{app.config.instance.source}</a></p>
23
+
<p>beep commit: <code><a href="@{app.config.instance.source}/commit/@{app.buildinfo.commit}">@{app.buildinfo.commit}</a></code></p>
24
+
<p>V source: <a href="@{app.config.instance.v_source}">@{app.config.instance.v_source}</a></p>
25
+
<p>V commit: <code><a href="@{app.config.instance.v_source}/commit/@{app.v_hash}">@{app.v_hash}</a></code></p>
26
+
<p>built at <span id="built_at">date n/a</span> (unix: <code>@{app.built_at}</code>)</p>
27
+
@end
28
+
</div>
29
+
30
+
<script>
31
+
document.getElementById('built_at').innerText = new Date(@{app.built_at} * 1000).toLocaleString()
32
+
</script>
33
+
34
+
@include 'partial/footer.html'
+82
src/templates/components/new_post.html
+82
src/templates/components/new_post.html
···
1
+
<script src="/static/js/text_area_counter.js"></script>
2
+
<div>
3
+
<form action="/api/post/new_post" method="post"
4
+
beep-redirect-js="(_,t)=>{return'/post/'+t.split('=')[1];}">
5
+
<!--
6
+
the above JS snippet will redirect the user to the new post. It's a liiiitle convoluted but whatever.
7
+
A successful new_post response will always respond with `posted. id=<id>`. I could just return JSON but honestly I don't care lmao.
8
+
TODO: return json because it's definitely better practice. also it would be useful for custom clients :p
9
+
-->
10
+
<h2>new post:</h2>
11
+
12
+
@if replying
13
+
<input
14
+
type="number"
15
+
name="replying_to"
16
+
id="replying_to"
17
+
required aria-required
18
+
readonly aria-readonly
19
+
hidden aria-hidden
20
+
value="@replying_to"
21
+
>
22
+
@end
23
+
24
+
@if replying
25
+
<input
26
+
type="text"
27
+
name="title"
28
+
id="title"
29
+
value="reply to @{replying_to_user.get_name()}"
30
+
required aria-required
31
+
readonly aria-readonly
32
+
hidden aria-hidden
33
+
>
34
+
@else
35
+
<label for="title" id="title_chars">0/@{app.config.post.title_max_len}</label>
36
+
<br>
37
+
<input
38
+
type="text"
39
+
name="title"
40
+
id="title"
41
+
minlength="@app.config.post.title_min_len"
42
+
maxlength="@app.config.post.title_max_len"
43
+
pattern="@app.config.post.title_pattern"
44
+
placeholder="title"
45
+
required aria-required
46
+
>
47
+
@end
48
+
<br>
49
+
50
+
<label for="body" id="body_chars">0/@{app.config.post.body_max_len}</label>
51
+
<br>
52
+
<textarea
53
+
name="body"
54
+
id="body"
55
+
minlength="@app.config.post.body_min_len"
56
+
maxlength="@app.config.post.body_max_len"
57
+
rows="10"
58
+
cols="30"
59
+
placeholder="body"
60
+
required aria-required
61
+
autocomplete="off" aria-autocomplete="off"
62
+
></textarea>
63
+
<br>
64
+
65
+
@if app.config.post.allow_nsfw
66
+
<div>
67
+
<label for="nsfw">is nsfw:</label>
68
+
<input type="checkbox" name="nsfw" id="nsfw" />
69
+
</div>
70
+
<br>
71
+
@else
72
+
<input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden />
73
+
@end
74
+
75
+
<input type="submit" value="post!">
76
+
</form>
77
+
78
+
<script>
79
+
add_character_counter('title', 'title_chars', @{app.config.post.title_max_len})
80
+
add_character_counter('body', 'body_chars', @{app.config.post.body_max_len})
81
+
</script>
82
+
</div>
+3
src/templates/components/post_mini.html
+3
src/templates/components/post_mini.html
···
2
2
<p>
3
3
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
4
4
<a href="/post/@post.id">@post.title</a>
5
+
@if post.nsfw
6
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
7
+
@end
5
8
</p>
6
9
</div>
+16
-4
src/templates/components/post_small.html
+16
-4
src/templates/components/post_small.html
···
1
1
<div class="post post-small">
2
-
<p><a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: <span class="post-title">@post.title</span></p>
3
-
@if post.body.len > 50
4
-
<p>@{post.body[..50]}...</p>
2
+
<p>
3
+
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
4
+
<span class="post-title">@post.title</span>
5
+
@if post.nsfw
6
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
7
+
@end
8
+
</p>
9
+
10
+
@if post.nsfw
11
+
<p>view full to see post body</p>
5
12
@else
6
-
<p>@post.body</p>
13
+
@if post.body.len > 50
14
+
<pre id="post-@{post.id}">@{post.body[..50]}...</pre>
15
+
@else
16
+
<pre id="post-@{post.id}">@post.body</pre>
17
+
@end
7
18
@end
19
+
8
20
<p>likes: @{app.get_net_likes_for_post(post.id)} | posted at: @post.posted_at | <a href="/post/@post.id">view full post</a></p>
9
21
</div>
+5
src/templates/components/user_card_mini.html
+5
src/templates/components/user_card_mini.html
+50
-3
src/templates/edit.html
+50
-3
src/templates/edit.html
···
1
1
@include 'partial/header.html'
2
2
3
-
<script src="/static/js/post.js"></script>
4
-
<script src="/static/js/render_body.js"></script>
3
+
<script src="/static/js/post.js" defer></script>
4
+
<script src="/static/js/render_body.js" defer></script>
5
+
<script src="/static/js/text_area_counter.js"></script>
5
6
6
7
<h1>edit post</h1>
7
8
8
9
<div class="post post-full">
9
-
<form action="/api/post/edit" method="post">
10
+
<form action="/api/post/edit" method="post" beep-redirect="/post/@post.id">
10
11
<input
11
12
type="number"
12
13
name="id"
···
18
19
hidden
19
20
aria-hidden
20
21
>
22
+
23
+
<p id="title_chars">0/@{app.config.post.title_max_len}</p>
21
24
<input
22
25
type="text"
23
26
name="title"
···
30
33
required
31
34
>
32
35
<br>
36
+
37
+
<p id="body_chars">0/@{app.config.post.body_max_len}</p>
33
38
<textarea
34
39
name="body"
35
40
id="body"
···
41
46
required
42
47
>@post.body</textarea>
43
48
<br>
49
+
50
+
@if app.config.post.allow_nsfw
51
+
<div>
52
+
<label for="nsfw">is nsfw:</label>
53
+
<input
54
+
type="checkbox"
55
+
name="nsfw"
56
+
id="nsfw"
57
+
@if post.nsfw
58
+
checked aria-checked
59
+
@end
60
+
/>
61
+
</div>
62
+
<br>
63
+
@else
64
+
<input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden />
65
+
@end
66
+
44
67
<input type="submit" value="save">
45
68
</form>
46
69
</div>
70
+
71
+
<hr>
72
+
73
+
<div>
74
+
<h2>danger zone:</h2>
75
+
<form action="/api/post/delete" method="post" beep-redirect="/">
76
+
<input
77
+
type="number"
78
+
name="id"
79
+
id="id"
80
+
placeholder="post id"
81
+
value="@post.id"
82
+
required aria-required
83
+
readonly aria-readonly
84
+
hidden aria-hidden
85
+
>
86
+
<input type="submit" value="delete">
87
+
</form>
88
+
</div>
89
+
90
+
<script>
91
+
add_character_counter('title', 'title_chars', @{app.config.post.title_max_len})
92
+
add_character_counter('body', 'body_chars', @{app.config.post.body_max_len})
93
+
</script>
47
94
48
95
@include 'partial/footer.html'
+10
-3
src/templates/inbox.html
+10
-3
src/templates/inbox.html
···
9
9
@if notifications.len == 0
10
10
<p>your inbox is empty!</p>
11
11
@else
12
-
<a href="/api/user/notification/clear_all">clear all</a>
12
+
<form action="/api/user/notification/clear_all" method="post" beep-redirect="/inbox">
13
+
<button>clear all</button>
14
+
</form>
13
15
<hr>
14
16
@for notification in notifications.reverse()
15
17
<div class="notification">
16
-
<p><strong>@notification.summary</strong></p>
18
+
<div style="display: flex; flex-direction: row; align-items: center; gap: 12px;">
19
+
<p><strong>@notification.summary</strong></p>
20
+
<form action="/api/user/notification/clear" method="post" beep-redirect="/inbox" class="form-inline" style="display: inline;">
21
+
<input type="number" value="@{notification.id}" name="id" required aria-required hidden aria-hidden readonly aria-readonly />
22
+
<button style="display: inline;">clear</button>
23
+
</form>
24
+
</div>
17
25
<pre id="notif-@{notification.id}">@notification.body</pre>
18
-
<a href="/api/user/notification/clear?id=@{notification.id}">clear</a>
19
26
<script>
20
27
render_body('notif-@{notification.id}')
21
28
</script>
+5
-4
src/templates/index.html
+5
-4
src/templates/index.html
···
8
8
9
9
<div>
10
10
@if pinned_posts.len > 0
11
-
<h2>pinned posts:</h2>
12
-
<div>
11
+
<div id="pinned-posts">
12
+
<h2>pinned posts:</h2>
13
13
@for post in pinned_posts
14
14
@include 'components/post_small.html'
15
15
@end
16
16
</div>
17
+
<br>
17
18
@end
18
19
19
-
<h2>recent posts:</h2>
20
-
<div>
20
+
<div id="recent-posts">
21
+
<h2>recent posts:</h2>
21
22
@if recent_posts.len > 0
22
23
@for post in recent_posts
23
24
@include 'components/post_small.html'
+2
-2
src/templates/login.html
+2
-2
src/templates/login.html
···
11
11
<p>you are already logged in as @{user.get_name()}!</p>
12
12
<a href="/api/user/logout">log out</a>
13
13
@else
14
-
<form action="/api/user/login" method="post">
14
+
<form action="/api/user/login" method="post" beep-redirect="/me">
15
15
<label for="username">username:</label>
16
16
<input
17
17
type="text"
···
39
39
@end
40
40
</div>
41
41
42
-
@include 'partial/footer.html'
42
+
@include 'partial/footer.html'
+1
-52
src/templates/new_post.html
+1
-52
src/templates/new_post.html
···
8
8
@else
9
9
<h2>make a post...</h2>
10
10
@end
11
-
12
-
<div>
13
-
<form action="/api/post/new_post" method="post">
14
-
@if replying
15
-
<input
16
-
type="number"
17
-
name="replying_to"
18
-
id="replying_to"
19
-
required aria-required
20
-
readonly aria-readonly
21
-
hidden aria-hidden
22
-
value="@replying_to"
23
-
>
24
-
<input
25
-
type="text"
26
-
name="title"
27
-
id="title"
28
-
value="reply to @{replying_to_user.get_name()}"
29
-
required aria-required
30
-
readonly aria-readonly
31
-
hidden aria-hidden
32
-
>
33
-
@else
34
-
<input
35
-
type="text"
36
-
name="title"
37
-
id="title"
38
-
minlength="@app.config.post.title_min_len"
39
-
maxlength="@app.config.post.title_max_len"
40
-
pattern="@app.config.post.title_pattern"
41
-
placeholder="title"
42
-
required aria-required
43
-
>
44
-
@end
45
-
46
-
<br>
47
-
<textarea
48
-
name="body"
49
-
id="body"
50
-
minlength="@app.config.post.body_min_len"
51
-
maxlength="@app.config.post.body_max_len"
52
-
rows="10"
53
-
cols="30"
54
-
placeholder="in reply to @{replying_to_user.get_name()}..."
55
-
required
56
-
></textarea>
57
-
58
-
<br>
59
-
60
-
<input type="submit" value="post!">
61
-
</form>
62
-
</div>
11
+
@include 'components/new_post.html'
63
12
@else
64
13
<p>uh oh, you need to be logged in to see this page</p>
65
14
@end
+22
-13
src/templates/partial/header.html
+22
-13
src/templates/partial/header.html
···
1
1
<!DOCTYPE html>
2
-
<html>
2
+
<html lang="en">
3
3
4
4
<head>
5
5
<meta charset="utf-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6
7
<meta name="description" content="" />
7
-
<link rel="icon" href="/favicon.png" />
8
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
8
+
9
9
<title>@ctx.title</title>
10
10
11
11
@include 'assets/style.html'
12
12
13
-
@if ctx.is_logged_in() && user.theme != none
14
-
<link rel="stylesheet" href="@user.get_theme()">
13
+
@if ctx.is_logged_in() && user.theme != ''
14
+
<link rel="stylesheet" href="@user.theme">
15
15
@else if app.config.instance.default_theme != ''
16
16
<link rel="stylesheet" href="@app.config.instance.default_theme">
17
17
@endif
18
18
19
19
<link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32">
20
+
21
+
@if ctx.is_logged_in() && user.css != ''
22
+
<style>@{user.css}</style>
23
+
@else
24
+
<style>@{app.config.instance.default_css}</style>
25
+
@end
26
+
27
+
<script src="/static/js/notify.js" defer></script>
28
+
<script src="/static/js/form.js" defer></script>
20
29
</head>
21
30
22
31
<body>
23
32
24
33
<header>
34
+
@if ctx.is_logged_in()
35
+
<a href="/me">@@@user.get_name()</a>
36
+
-
37
+
@end
38
+
25
39
@if app.config.dev_mode
26
40
<span><strong>dev mode</strong></span>
27
41
-
···
31
45
-
32
46
33
47
@if ctx.is_logged_in()
34
-
<a href="/me">profile</a>
48
+
<a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a>
35
49
-
36
-
<a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a>
50
+
<a href="/search">search</a>
37
51
@else
38
52
<a href="/login">log in</a>
39
53
<span>or</span>
···
42
56
</header>
43
57
44
58
<main>
45
-
<!-- TODO: fix this lol -->
46
-
@if ctx.form_error != ''
47
-
<div>
48
-
<p><strong>error:</strong> @ctx.form_error</p>
49
-
</div>
50
-
@end
59
+
<div id="errors"></div>
+66
-38
src/templates/post.html
+66
-38
src/templates/post.html
···
12
12
@else
13
13
replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a>
14
14
@end
15
+
@if post.nsfw
16
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
17
+
@end
15
18
</h2>
19
+
20
+
<hr>
21
+
22
+
@if post.nsfw
23
+
<details>
24
+
<summary>click to show post (nsfw)</summary>
25
+
<pre id="post-@{post.id}">@post.body</pre>
26
+
</details>
27
+
@else
16
28
<pre id="post-@{post.id}">@post.body</pre>
29
+
@end
30
+
31
+
<hr>
32
+
17
33
<p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p>
18
34
<p><em>posted at: @post.posted_at</em></p>
19
35
20
-
@if ctx.is_logged_in()
36
+
@if ctx.is_logged_in() && !user.automated
37
+
<br>
21
38
<p><a href="/post/@{post.id}/reply">reply</a></p>
22
-
@end
23
-
24
-
@if ctx.is_logged_in() && post.author_id == user.id
25
-
<p><a href="/post/@{post.id}/edit">edit post</a></p>
26
-
@end
27
-
28
-
@if ctx.is_logged_in()
29
39
<br>
30
40
<div>
31
41
<button onclick="like(@post.id)">
···
42
52
dislike
43
53
@end
44
54
</button>
55
+
<button onclick="save(@post.id)">
56
+
@if app.is_post_saved_by(user.id, post.id)
57
+
saved!
58
+
@else
59
+
save
60
+
@end
61
+
</button>
62
+
<button onclick="save_for_later(@post.id)">
63
+
@if app.is_post_saved_for_later_by(user.id, post.id)
64
+
saved for later!
65
+
@else
66
+
save for later
67
+
@end
68
+
</button>
45
69
</div>
46
70
@end
47
71
···
51
75
52
76
@if post.author_id == user.id
53
77
<h4>manage post:</h4>
54
-
@else if user.admin
55
-
<h4>admin powers:</h4>
78
+
79
+
<p><a href="/post/@{post.id}/edit">edit</a></p>
56
80
@end
57
81
58
-
<form action="/api/post/delete" method="post">
59
-
<input
60
-
type="number"
61
-
name="id"
62
-
id="id"
63
-
placeholder="post id"
64
-
value="@post.id"
65
-
required
66
-
readonly
67
-
hidden
68
-
aria-hidden
69
-
>
70
-
<input type="submit" value="delete">
71
-
</form>
82
+
@if user.admin
83
+
<details>
84
+
<summary>admin powers</summary>
72
85
73
-
<form action="/api/post/pin" method="post">
74
-
<input
75
-
type="number"
76
-
name="id"
77
-
id="id"
78
-
placeholder="post id"
79
-
value="@post.id"
80
-
required
81
-
readonly
82
-
hidden
83
-
aria-hidden
84
-
>
85
-
<input type="submit" value="pin">
86
-
</form>
86
+
<form action="/api/post/pin" method="post">
87
+
<input
88
+
type="number"
89
+
name="id"
90
+
id="id"
91
+
placeholder="post id"
92
+
value="@post.id"
93
+
required aria-required
94
+
readonly aria-readonly
95
+
hidden aria-hidden
96
+
>
97
+
<input type="submit" value="pin">
98
+
</form>
99
+
100
+
<form action="/api/post/delete" method="post" beep-redirect="/">
101
+
<input
102
+
type="number"
103
+
name="id"
104
+
id="id"
105
+
placeholder="post id"
106
+
value="@post.id"
107
+
required aria-required
108
+
readonly aria-readonly
109
+
hidden aria-hidden
110
+
>
111
+
<input type="submit" value="delete">
112
+
</form>
113
+
</details>
114
+
@end
87
115
88
116
</div>
89
117
@end
+32
-3
src/templates/register.html
+32
-3
src/templates/register.html
···
1
1
@include 'partial/header.html'
2
2
3
+
<script src="/static/js/password.js"></script>
4
+
3
5
<h1>register</h1>
4
6
5
7
<div>
···
11
13
<p>you are already logged in as @{user.get_name()}!</p>
12
14
<a href="/api/user/logout">log out</a>
13
15
@else
14
-
<form action="/api/user/register" method="post">
16
+
<form action="/api/user/register" method="post" beep-redirect="/me">
15
17
<label for="username">username:</label>
16
18
<input
17
19
type="text"
···
23
25
required
24
26
>
25
27
<br>
26
-
<label for="password">password:</label>
28
+
<label for="password">password: <a href="#" id="view-password" style="display: inline;">view</a></label>
27
29
<input
28
30
type="password"
29
31
name="password"
···
34
36
required
35
37
>
36
38
<br>
39
+
<label for="confirm-password">confirm password: <a href="#" id="view-confirm-password" style="display: inline;">view</a></label>
40
+
<input
41
+
type="password"
42
+
name="confirm-password"
43
+
id="confirm-password"
44
+
pattern="@app.config.user.password_pattern"
45
+
minlength="@app.config.user.password_min_len"
46
+
maxlength="@app.config.user.password_max_len"
47
+
required
48
+
>
49
+
<br>
50
+
<p>passwords match: <span id="passwords-match">yes</span></p>
51
+
<br>
52
+
@if app.config.instance.invite_only
53
+
<label for="invite-code">invite code:</label>
54
+
<input type="text" name="invite-code" id="invite-code" required>
55
+
<br>
56
+
@end
57
+
@if app.config.hcaptcha.enabled
58
+
<div class="h-captcha" data-sitekey="@{app.config.hcaptcha.site_key}"></div>
59
+
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
60
+
<br>
61
+
@end
37
62
<input type="submit" value="register">
38
63
</form>
39
64
@end
40
65
</div>
41
66
42
-
@include 'partial/footer.html'
67
+
<script>
68
+
add_password_checkers('password', 'confirm-password', 'passwords-match');
69
+
</script>
70
+
71
+
@include 'partial/footer.html'
+35
src/templates/saved_posts.html
+35
src/templates/saved_posts.html
···
1
+
@include 'partial/header.html'
2
+
3
+
@if ctx.is_logged_in()
4
+
5
+
<script src="/static/js/post.js"></script>
6
+
7
+
<p><a href="/me">back</a></p>
8
+
9
+
<h1>saved posts:</h1>
10
+
11
+
<div>
12
+
@if posts.len > 0
13
+
@for post in posts
14
+
<!-- components/post_mini.html -->
15
+
<div class="post post-mini">
16
+
<p>
17
+
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
18
+
<a href="/post/@post.id">@post.title</a>
19
+
@if post.nsfw
20
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
21
+
@end
22
+
<button onclick="save(@post.id)" style="display: inline-block;">unsave</button>
23
+
</p>
24
+
</div>
25
+
@end
26
+
@else
27
+
<p>none!</p>
28
+
@end
29
+
</div>
30
+
31
+
@else
32
+
<p>uh oh, you need to be logged in to see this page</p>
33
+
@end
34
+
35
+
@include 'partial/footer.html'
+35
src/templates/saved_posts_for_later.html
+35
src/templates/saved_posts_for_later.html
···
1
+
@include 'partial/header.html'
2
+
3
+
@if ctx.is_logged_in()
4
+
5
+
<script src="/static/js/post.js"></script>
6
+
7
+
<p><a href="/me">back</a></p>
8
+
9
+
<h1>saved posts for later:</h1>
10
+
11
+
<div>
12
+
@if posts.len > 0
13
+
@for post in posts
14
+
<!-- components/post_mini.html -->
15
+
<div class="post post-mini">
16
+
<p>
17
+
<a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>:
18
+
<a href="/post/@post.id">@post.title</a>
19
+
@if post.nsfw
20
+
<span class="nsfw-indicator">(<em>nsfw</em>)</span>
21
+
@end
22
+
<button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button>
23
+
</p>
24
+
</div>
25
+
@end
26
+
@else
27
+
<p>none!</p>
28
+
@end
29
+
</div>
30
+
31
+
@else
32
+
<p>uh oh, you need to be logged in to see this page</p>
33
+
@end
34
+
35
+
@include 'partial/footer.html'
+181
src/templates/search.html
+181
src/templates/search.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<script src="/static/js/user_utils.js"></script>
4
+
<script src="/static/js/search.js"></script>
5
+
6
+
<h1>search</h1>
7
+
8
+
<div>
9
+
<input type="text" name="query" id="query">
10
+
<div>
11
+
<p>search for:</p>
12
+
<input type="radio" name="search-for" id="search-for-posts" value="posts" checked aria-checked>
13
+
<label for="search-for-posts">posts</label>
14
+
<input type="radio" name="search-for" id="search-for-users" value="users">
15
+
<label for="search-for-users">users</label>
16
+
</div>
17
+
<br>
18
+
<button id="search">search</button>
19
+
</div>
20
+
21
+
<br>
22
+
23
+
<div id="pages">
24
+
</div>
25
+
26
+
<div id="results">
27
+
</div>
28
+
29
+
<script>
30
+
const params = new URLSearchParams(window.location.search)
31
+
32
+
const pages = document.getElementById('pages')
33
+
const results = document.getElementById('results')
34
+
35
+
const query = document.getElementById('query')
36
+
if (query.value == '' && params.get('q')) {
37
+
query.value = params.get('q')
38
+
}
39
+
40
+
let limit = params.get('limit')
41
+
if (!limit) {
42
+
limit = 10
43
+
}
44
+
45
+
let offset = params.get('offset')
46
+
if (!limit) {
47
+
offset = 0
48
+
}
49
+
50
+
const add_post_result = result => {
51
+
// same as components/post_mini.html except js
52
+
const element = document.createElement('div')
53
+
element.classList.add('post', 'post-mini')
54
+
const p = document.createElement('p')
55
+
56
+
const user_link = document.createElement('a')
57
+
user_link.href = '/user/' + result.author.username
58
+
const user_text = document.createElement('strong')
59
+
user_text.innerText = get_display_name(result.author)
60
+
user_link.appendChild(user_text)
61
+
p.appendChild(user_link)
62
+
63
+
p.innerHTML += ': '
64
+
65
+
const post_link = document.createElement('a')
66
+
post_link.href = '/post/' + result.post.id
67
+
post_link.innerText = result.post.title
68
+
p.appendChild(post_link)
69
+
70
+
if (result.post.nsfw)
71
+
{
72
+
const nsfw_indicator = document.createElement('span')
73
+
nsfw_indicator.classList.add('nsfw-indicator')
74
+
nsfw_indicator.innerHTML = '(<em>nsfw</em>)';
75
+
p.appendChild(nsfw_indicator)
76
+
}
77
+
78
+
element.appendChild(p)
79
+
results.appendChild(element)
80
+
}
81
+
82
+
const add_user_result = user => {
83
+
const element = document.createElement('div')
84
+
const p = document.createElement('p')
85
+
const user_link = document.createElement('a')
86
+
user_link.href = '/user/' + user.username
87
+
user_link.innerText = get_display_name(user)
88
+
p.appendChild(user_link)
89
+
element.appendChild(p)
90
+
results.appendChild(element)
91
+
}
92
+
93
+
const add_pages = () => {
94
+
// creates a separator
95
+
const sep = () => {
96
+
const span = document.createElement('span')
97
+
span.innerText = ' - '
98
+
pages.appendChild(span)
99
+
}
100
+
101
+
const first_link = document.createElement('a')
102
+
// we escape the $ here because otherwise V will try to perform replacements at compile-time.
103
+
//todo: report this, this behaviour should be changed or at least looked into further.
104
+
first_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=0'
105
+
first_link.innerText = '0'
106
+
pages.appendChild(first_link)
107
+
108
+
sep()
109
+
110
+
const back_link = document.createElement('a')
111
+
back_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + Math.min(0, offset - 10)
112
+
back_link.innerText = '<'
113
+
pages.appendChild(back_link)
114
+
115
+
sep()
116
+
117
+
const next_link = document.createElement('a')
118
+
next_link.href = '/search?q=' + query.value + '&limit=' + limit + '&offset=' + (offset + 10)
119
+
next_link.innerText = '>'
120
+
pages.appendChild(next_link)
121
+
}
122
+
123
+
document.getElementById('search').addEventListener('click', async () => {
124
+
results.innerHTML = '' // yeet the children!
125
+
pages.innerHTML = '' // yeet more children!
126
+
127
+
var search_for
128
+
for (const radio of document.getElementsByName('search-for')) {
129
+
if (radio.checked) {
130
+
search_for = radio.value
131
+
break
132
+
}
133
+
}
134
+
if (search_for == undefined) {
135
+
alert('please select either "users" or "posts" to search for.')
136
+
return
137
+
}
138
+
139
+
console.log('search: ', query.value, limit, offset)
140
+
141
+
var search_results
142
+
if (search_for == 'users') {
143
+
search_results = await search_users(query.value, limit, offset)
144
+
} else if (search_for == 'posts') {
145
+
search_results = await search_posts(query.value, limit, offset)
146
+
} else {
147
+
// this should never happen
148
+
alert('something wrong occured while searching, please report this (01)')
149
+
return
150
+
}
151
+
152
+
console.log(search_results)
153
+
154
+
if (search_results.length >= 0) {
155
+
// i iterate inside the if statements so that i do not have to perform a redundant
156
+
// string comparison for every single result.
157
+
if (search_for == 'users') {
158
+
for (result of search_results) {
159
+
add_user_result(result)
160
+
}
161
+
} else if (search_for == 'posts') {
162
+
for (result of search_results) {
163
+
add_post_result(result)
164
+
}
165
+
} else {
166
+
// this should never happen
167
+
alert('something wrong occured while searching, please report this (02)')
168
+
return
169
+
}
170
+
171
+
// set up pagination, but only if we actually have pages to display
172
+
if (offset > 0) {
173
+
add_pages()
174
+
}
175
+
} else {
176
+
results.innerText = 'no results!'
177
+
}
178
+
})
179
+
</script>
180
+
181
+
@include 'partial/footer.html'
+68
-10
src/templates/settings.html
+68
-10
src/templates/settings.html
···
1
1
@include 'partial/header.html'
2
2
3
3
@if ctx.is_logged_in()
4
+
<script src="/static/js/text_area_counter.js"></script>
5
+
<script src="/static/js/password.js"></script>
6
+
4
7
<h1>user settings:</h1>
5
8
6
9
<form action="/api/user/set_bio" method="post">
7
-
<label for="bio">bio:</label>
10
+
<label for="bio">bio: (<span id="bio_chars">0/@{app.config.user.bio_max_len}</span>)</label>
8
11
<br>
9
12
<textarea
10
13
name="bio"
···
13
16
rows="10"
14
17
minlength="@app.config.user.bio_min_len"
15
18
maxlength="@app.config.user.bio_max_len"
16
-
required aria-required
17
19
>@user.bio</textarea>
18
20
<br>
19
21
<input type="submit" value="save">
···
22
24
<hr>
23
25
24
26
<form action="/api/user/set_pronouns" method="post">
25
-
<label for="pronouns">pronouns:</label>
27
+
<label for="pronouns">pronouns: (<span id="pronouns_chars">0/@{app.config.user.pronouns_max_len}</span>)</label>
26
28
<input
27
29
type="text"
28
30
name="pronouns"
···
31
33
maxlength="@app.config.user.pronouns_max_len"
32
34
pattern="@app.config.user.pronouns_pattern"
33
35
value="@user.pronouns"
34
-
required aria-required
35
36
>
36
37
<input type="submit" value="save">
37
38
</form>
···
39
40
<hr>
40
41
41
42
<form action="/api/user/set_nickname" method="post">
42
-
<label for="nickname">nickname:</label>
43
+
<label for="nickname">nickname: (<span id="nickname_chars">0/@{app.config.user.nickname_max_len}</span>)</label>
43
44
<input
44
45
type="text"
45
46
name="nickname"
···
48
49
minlength="@app.config.user.nickname_min_len"
49
50
maxlength="@app.config.user.nickname_max_len"
50
51
value="@{user.nickname or { '' }}"
51
-
required aria-required
52
52
>
53
53
<input type="submit" value="save">
54
54
</form>
···
57
57
<input type="submit" value="reset nickname">
58
58
</form>
59
59
60
+
<script>
61
+
add_character_counter('bio', 'bio_chars', @{app.config.user.bio_max_len})
62
+
add_character_counter('pronouns', 'pronouns_chars', @{app.config.user.pronouns_max_len})
63
+
add_character_counter('nickname', 'nickname_chars', @{app.config.user.nickname_max_len})
64
+
</script>
65
+
60
66
@if app.config.instance.allow_changing_theme
61
67
<hr>
62
68
63
69
<form action="/api/user/set_theme" method="post">
64
70
<label for="url">theme:</label>
65
-
<input type="url" name="url" id="url" value="@{user.theme or { '' }}">
71
+
<input type="text" name="url" id="url" value="@user.theme">
72
+
<input type="submit" value="save">
73
+
</form>
74
+
75
+
<hr>
76
+
77
+
<form action="/api/user/set_css" method="post">
78
+
<label for="css">custom css:</label>
79
+
<br>
80
+
<textarea type="text" name="css" id="css" style="font: monospace;">@user.css</textarea>
66
81
<input type="submit" value="save">
67
82
</form>
68
83
@end
···
86
101
87
102
<hr>
88
103
104
+
<form action="/api/user/set_automated" method="post">
105
+
<div>
106
+
<label for="is_automated">is automated:</label>
107
+
<input
108
+
type="checkbox"
109
+
name="is_automated"
110
+
id="is_automated"
111
+
value="true"
112
+
@if user.automated
113
+
checked aria-checked
114
+
@end
115
+
>
116
+
</div>
117
+
<input type="submit" value="save">
118
+
<p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p>
119
+
<p>it will also hide most front-end interactions since the user of this account likely will not be using those very often.</p>
120
+
</form>
121
+
122
+
<hr>
123
+
89
124
<details>
90
125
<summary>dangerous settings (click to reveal)</summary>
91
126
127
+
<br>
128
+
92
129
<details>
93
130
<summary>change password (click to reveal)</summary>
94
-
<form action="/api/user/set_password" method="post">
131
+
<form action="/api/user/set_password" method="post" beep-redirect="/login">
95
132
<p>changing your password will log you out of all devices, so you will need to log in again after changing it.</p>
96
133
<label for="current_password">current password:</label>
97
134
<input
···
104
141
required aria-required
105
142
autocomplete="off" aria-autocomplete="off"
106
143
>
107
-
<label for="new_password">new password:</label>
144
+
<br>
145
+
<label for="new_password">new password: <input type="button" id="view-new_password" style="display: inline;" value="view"></input></label>
108
146
<input
109
147
type="password"
110
148
name="new_password"
···
115
153
required aria-required
116
154
autocomplete="off" aria-autocomplete="off"
117
155
>
156
+
<label for="confirm_password">confirm password: <input type="button" id="view-confirm_password" style="display: inline;" value="view"></input></label>
157
+
<input
158
+
type="password"
159
+
name="confirm_password"
160
+
id="confirm_password"
161
+
pattern="@app.config.user.password_pattern"
162
+
minlength="@app.config.user.password_min_len"
163
+
maxlength="@app.config.user.password_max_len"
164
+
required aria-required
165
+
autocomplete="off" aria-autocomplete="off"
166
+
>
167
+
<br>
168
+
<p>passwords match: <span id="passwords-match">yes</span></p>
169
+
<br>
118
170
<input type="submit" value="save">
119
171
</form>
120
172
</details>
121
173
174
+
<br>
175
+
122
176
<details>
123
177
<summary>account deletion (click to reveal)</summary>
124
-
<form action="/api/user/delete" autocomplete="off">
178
+
<form action="/api/user/delete" autocomplete="off" beep-redirect="/">
125
179
<input
126
180
type="number"
127
181
name="id"
···
155
209
</form>
156
210
</details>
157
211
</details>
212
+
213
+
<script>
214
+
add_password_checkers('new_password', 'confirm_password', 'passwords-match');
215
+
</script>
158
216
159
217
@else
160
218
<p>uh oh, you need to be logged in to view this page!</p>
+36
-38
src/templates/user.html
+36
-38
src/templates/user.html
···
8
8
(@viewing.pronouns)
9
9
@end
10
10
11
-
@if viewing.muted && viewing.admin
12
-
[muted admin, somehow]
13
-
@else if viewing.muted
11
+
@if viewing.muted
14
12
[muted]
15
-
@else if viewing.admin
13
+
@end
14
+
15
+
@if viewing.automated
16
+
[automated]
17
+
@end
18
+
19
+
@if viewing.admin
16
20
[admin]
17
21
@end
18
22
</h1>
19
23
20
24
@if app.logged_in_as(mut ctx, viewing.id)
21
25
<p>this is you!</p>
26
+
@if !user.automated
27
+
@include 'components/new_post.html'
28
+
<hr>
29
+
@end
30
+
@end
22
31
32
+
@if viewing.bio != ''
23
33
<div>
24
-
<form action="/api/post/new_post" method="post">
25
-
<h2>new post:</h2>
26
-
27
-
<input
28
-
type="text"
29
-
name="title"
30
-
id="title"
31
-
minlength="@app.config.post.title_min_len"
32
-
maxlength="@app.config.post.title_max_len"
33
-
pattern="@app.config.post.title_pattern"
34
-
placeholder="title"
35
-
required aria-required
36
-
>
37
-
<br>
38
-
39
-
<textarea
40
-
name="body"
41
-
id="body"
42
-
minlength="@app.config.post.body_min_len"
43
-
maxlength="@app.config.post.body_max_len"
44
-
rows="10"
45
-
cols="30"
46
-
placeholder="body"
47
-
required aria-required
48
-
></textarea>
49
-
<br>
50
-
51
-
<input type="submit" value="post!">
52
-
</form>
34
+
<h2>bio:</h2>
35
+
<pre id="bio">@viewing.bio</pre>
53
36
</div>
37
+
<hr>
54
38
@end
55
39
56
-
@if viewing.bio != ''
40
+
@if app.logged_in_as(mut ctx, viewing.id)
57
41
<div>
58
-
<h2>bio:</h2>
59
-
<p>@viewing.bio</p>
42
+
<p><a href="/me/saved">saved posts</a></p>
43
+
<p><a href="/me/saved_for_later">saved for later</a></p>
60
44
</div>
45
+
<hr>
61
46
@end
62
47
63
48
<div>
64
-
<h2>posts:</h2>
65
-
@for post in app.get_posts_from_user(viewing.id)
49
+
<h2>recent posts:</h2>
50
+
@if posts.len > 0
51
+
@for post in posts
66
52
@include 'components/post_small.html'
67
53
@end
54
+
@else
55
+
<p>no posts!</p>
56
+
@end
68
57
</div>
69
58
70
59
@if ctx.is_logged_in() && user.admin
60
+
<hr>
61
+
71
62
<div>
72
63
<h2>admin powers:</h2>
73
64
<form action="/api/user/set_muted" method="post">
···
105
96
@end
106
97
</form>
107
98
</div>
99
+
@end
100
+
101
+
@if viewing.bio != ''
102
+
<script src="/static/js/render_body.js"></script>
103
+
<script>
104
+
render_body('bio')
105
+
</script>
108
106
@end
109
107
110
108
@include 'partial/footer.html'
+21
src/util/none.v
+21
src/util/none.v
···
1
+
module util
2
+
3
+
@[inline]
4
+
pub fn map_or[T, R](val ?T, mapper fn (T) R, or_else R) R {
5
+
return if val == none { or_else } else { mapper(val) }
6
+
}
7
+
8
+
@[inline]
9
+
pub fn map_or_throw[T, R](val ?T, mapper fn (T) R) R {
10
+
return if val == none { panic('value was none: ${val}') } else { mapper(val) }
11
+
}
12
+
13
+
@[inline]
14
+
pub fn map_or_opt[T, R](val ?T, mapper fn (T) ?R, or_else ?R) ?R {
15
+
return if val == none { or_else } else { mapper(val) }
16
+
}
17
+
18
+
@[inline]
19
+
pub fn or_throw[T](val ?T) T {
20
+
return if val == none { panic('value was none: ${val}') } else { val }
21
+
}
+13
src/util/row.v
+13
src/util/row.v
···
1
+
module util
2
+
3
+
import db.pg
4
+
5
+
@[inline]
6
+
pub fn get_row_col(res pg.Result, row pg.Row, key string) ?string {
7
+
return row.vals[res.cols[key]]
8
+
}
9
+
10
+
@[inline]
11
+
pub fn get_row_col_or_throw(res pg.Result, row pg.Row, key string) string {
12
+
return util.or_throw(row.vals[res.cols[key]])
13
+
}
+44
src/util/stopwatch.v
+44
src/util/stopwatch.v
···
1
+
module util
2
+
3
+
import time
4
+
5
+
@[noinit]
6
+
pub struct Stopwatch {
7
+
pub:
8
+
start time.Time = time.now()
9
+
pub mut:
10
+
stop time.Time
11
+
took ?time.Duration
12
+
}
13
+
14
+
@[inline]
15
+
pub fn Stopwatch.new() Stopwatch {
16
+
return Stopwatch{}
17
+
}
18
+
19
+
@[inline]
20
+
pub fn (mut stopwatch Stopwatch) stop() time.Duration {
21
+
stopwatch.stop = time.now()
22
+
duration := stopwatch.stop - stopwatch.start
23
+
stopwatch.took = duration
24
+
return duration
25
+
}
26
+
27
+
@[params]
28
+
pub struct TimeItParams {
29
+
pub:
30
+
it fn () @[required]
31
+
name string
32
+
log bool
33
+
}
34
+
35
+
@[inline]
36
+
pub fn time_it(params TimeItParams) Stopwatch {
37
+
mut stopwatch := Stopwatch.new()
38
+
params.it()
39
+
took := stopwatch.stop()
40
+
if params.log {
41
+
println('-> (time_it) ${params.name} took ${took}')
42
+
}
43
+
return stopwatch
44
+
}
-23
src/validation.v
-23
src/validation.v
···
1
-
module main
2
-
3
-
import regex
4
-
5
-
// handles validation of user-input fields
6
-
pub struct StringValidator {
7
-
pub:
8
-
min_len int
9
-
max_len int = max_int
10
-
pattern regex.RE
11
-
}
12
-
13
-
@[inline]
14
-
pub fn (validator StringValidator) validate(str string) bool {
15
-
return str.len > validator.min_len && str.len < validator.max_len
16
-
&& validator.pattern.matches_string(str)
17
-
}
18
-
19
-
pub fn StringValidator.new(min int, max int, pattern string) StringValidator {
20
-
mut re := regex.new()
21
-
re.compile_opt(pattern) or { panic(err) }
22
-
return StringValidator{min, max, re}
23
-
}
+757
src/webapp/api.v
+757
src/webapp/api.v
···
1
+
module webapp
2
+
3
+
import veb
4
+
import auth
5
+
import entity { Like, Post, User }
6
+
import database { PostSearchResult }
7
+
import net.http
8
+
import json
9
+
10
+
// search_hard_limit is the maximum limit for a search query, used to prevent
11
+
// people from requesting searches with huge limits and straining the SQL server
12
+
pub const search_hard_limit = 50
13
+
pub const not_logged_in_msg = 'you are not logged in!'
14
+
15
+
////// user //////
16
+
17
+
struct HcaptchaResponse {
18
+
pub:
19
+
success bool
20
+
error_codes []string @[json: 'error-codes']
21
+
}
22
+
23
+
@['/api/user/register'; post]
24
+
fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result {
25
+
// before doing *anything*, check the captchas
26
+
if app.config.hcaptcha.enabled {
27
+
token := ctx.form['h-captcha-response']
28
+
response := http.post_form('https://api.hcaptcha.com/siteverify', {
29
+
'secret': app.config.hcaptcha.secret
30
+
'remoteip': ctx.ip()
31
+
'response': token
32
+
}) or {
33
+
return ctx.server_error('failed to post hcaptcha response: ${err}')
34
+
}
35
+
data := json.decode(HcaptchaResponse, response.body) or {
36
+
return ctx.server_error('failed to decode hcaptcha response: ${err}')
37
+
}
38
+
if !data.success {
39
+
return ctx.server_error('failed to verify hcaptcha: ${data}')
40
+
}
41
+
}
42
+
43
+
if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code {
44
+
return ctx.server_error('invalid invite code')
45
+
}
46
+
47
+
if app.get_user_by_name(username) != none {
48
+
return ctx.server_error('username taken')
49
+
}
50
+
51
+
// validate username
52
+
if !app.validators.username.validate(username) {
53
+
return ctx.server_error('invalid username')
54
+
}
55
+
56
+
// validate password
57
+
if !app.validators.password.validate(password) {
58
+
return ctx.server_error('invalid password')
59
+
}
60
+
61
+
if password != ctx.form['confirm-password'] {
62
+
return ctx.server_error('passwords do not match')
63
+
}
64
+
65
+
salt := auth.generate_salt()
66
+
mut user := User{
67
+
username: username
68
+
password: auth.hash_password_with_salt(password, salt)
69
+
password_salt: salt
70
+
}
71
+
72
+
if app.config.instance.default_theme != '' {
73
+
user.theme = app.config.instance.default_theme
74
+
}
75
+
76
+
if x := app.new_user(user) {
77
+
app.send_notification_to(x.id, app.config.welcome.summary.replace('%s', x.get_name()),
78
+
app.config.welcome.body.replace('%s', x.get_name()))
79
+
token := app.auth.add_token(x.id) or {
80
+
eprintln('api_user_register: could not create token for user with id ${x.id}: ${err}')
81
+
return ctx.server_error('could not create token for user')
82
+
}
83
+
ctx.set_cookie(
84
+
name: 'token'
85
+
value: token
86
+
same_site: .same_site_none_mode
87
+
secure: true
88
+
path: '/'
89
+
)
90
+
} else {
91
+
eprintln('api_user_register: could not log into newly-created user: ${user}')
92
+
return ctx.server_error('could not log into newly-created user.')
93
+
}
94
+
95
+
return ctx.ok('user registered')
96
+
}
97
+
98
+
@['/api/user/set_username'; post]
99
+
fn (mut app App) api_user_set_username(mut ctx Context, new_username string) veb.Result {
100
+
user := app.whoami(mut ctx) or {
101
+
return ctx.unauthorized(not_logged_in_msg)
102
+
}
103
+
104
+
if app.get_user_by_name(new_username) != none {
105
+
return ctx.server_error('username taken')
106
+
}
107
+
108
+
// validate username
109
+
if !app.validators.username.validate(new_username) {
110
+
return ctx.server_error('invalid username')
111
+
}
112
+
113
+
if !app.set_username(user.id, new_username) {
114
+
return ctx.server_error('failed to update username')
115
+
}
116
+
117
+
return ctx.ok('username updated')
118
+
}
119
+
120
+
@['/api/user/set_password'; post]
121
+
fn (mut app App) api_user_set_password(mut ctx Context, current_password string, new_password string) veb.Result {
122
+
user := app.whoami(mut ctx) or {
123
+
return ctx.unauthorized(not_logged_in_msg)
124
+
}
125
+
126
+
if !auth.compare_password_with_hash(current_password, user.password_salt, user.password) {
127
+
return ctx.server_error('current_password is incorrect')
128
+
}
129
+
130
+
// validate password
131
+
if !app.validators.password.validate(new_password) {
132
+
return ctx.server_error('invalid password')
133
+
}
134
+
135
+
if new_password != ctx.form['confirm_password'] {
136
+
return ctx.server_error('passwords do not match')
137
+
}
138
+
139
+
hashed_new_password := auth.hash_password_with_salt(new_password, user.password_salt)
140
+
if !app.set_password(user.id, hashed_new_password) {
141
+
return ctx.server_error('failed to update password')
142
+
}
143
+
144
+
// invalidate tokens and log out
145
+
app.auth.delete_tokens_for_user(user.id) or {
146
+
eprintln('failed to yeet tokens during password deletion for ${user.id} (${err})')
147
+
return ctx.server_error('failed to delete tokens during password deletion')
148
+
}
149
+
ctx.set_cookie(
150
+
name: 'token'
151
+
value: ''
152
+
same_site: .same_site_none_mode
153
+
secure: true
154
+
path: '/'
155
+
)
156
+
157
+
return ctx.ok('password updated')
158
+
}
159
+
160
+
@['/api/user/login'; post]
161
+
fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result {
162
+
user := app.get_user_by_name(username) or {
163
+
return ctx.server_error('invalid credentials')
164
+
}
165
+
166
+
if !auth.compare_password_with_hash(password, user.password_salt, user.password) {
167
+
return ctx.server_error('invalid credentials')
168
+
}
169
+
170
+
token := app.auth.add_token(user.id) or {
171
+
eprintln('failed to add token on log in: ${err}')
172
+
return ctx.server_error('could not create token for user with id ${user.id}')
173
+
}
174
+
175
+
ctx.set_cookie(
176
+
name: 'token'
177
+
value: token
178
+
same_site: .same_site_none_mode
179
+
secure: true
180
+
path: '/'
181
+
)
182
+
183
+
return ctx.ok('logged in')
184
+
}
185
+
186
+
@['/api/user/logout'; post]
187
+
fn (mut app App) api_user_logout(mut ctx Context) veb.Result {
188
+
if token := ctx.get_cookie('token') {
189
+
if user := app.get_user_by_token(token) {
190
+
// app.auth.delete_tokens_for_ip(ctx.ip()) or {
191
+
// eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})')
192
+
// return ctx.redirect('/login')
193
+
// }
194
+
app.auth.delete_tokens_for_value(token) or {
195
+
eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()} (${err})')
196
+
}
197
+
} else {
198
+
eprintln('failed to get user for token for logout')
199
+
}
200
+
} else {
201
+
eprintln('failed to get token cookie for logout')
202
+
}
203
+
204
+
ctx.set_cookie(
205
+
name: 'token'
206
+
value: ''
207
+
same_site: .same_site_none_mode
208
+
secure: true
209
+
path: '/'
210
+
)
211
+
212
+
return ctx.ok('logged out')
213
+
}
214
+
215
+
@['/api/user/full_logout'; post]
216
+
fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result {
217
+
if token := ctx.get_cookie('token') {
218
+
if user := app.get_user_by_token(token) {
219
+
app.auth.delete_tokens_for_user(user.id) or {
220
+
eprintln('failed to yeet tokens for ${user.id}')
221
+
}
222
+
} else {
223
+
eprintln('failed to get user for token for full_logout')
224
+
}
225
+
} else {
226
+
eprintln('failed to get token cookie for full_logout')
227
+
}
228
+
229
+
ctx.set_cookie(
230
+
name: 'token'
231
+
value: ''
232
+
same_site: .same_site_none_mode
233
+
secure: true
234
+
path: '/'
235
+
)
236
+
237
+
return ctx.ok('logged out')
238
+
}
239
+
240
+
@['/api/user/set_nickname'; post]
241
+
fn (mut app App) api_user_set_nickname(mut ctx Context, nickname string) veb.Result {
242
+
user := app.whoami(mut ctx) or {
243
+
return ctx.unauthorized(not_logged_in_msg)
244
+
}
245
+
246
+
mut clean_nickname := ?string(nickname.trim_space())
247
+
if clean_nickname or { '' } == '' {
248
+
clean_nickname = none
249
+
}
250
+
251
+
// validate
252
+
if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
253
+
return ctx.server_error('invalid nickname')
254
+
}
255
+
256
+
if !app.set_nickname(user.id, clean_nickname) {
257
+
eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
258
+
return ctx.server_error('failed to update nickname')
259
+
}
260
+
261
+
return ctx.ok('updated nickname')
262
+
}
263
+
264
+
@['/api/user/set_muted'; post]
265
+
fn (mut app App) api_user_set_muted(mut ctx Context, id int, muted bool) veb.Result {
266
+
user := app.whoami(mut ctx) or {
267
+
return ctx.unauthorized(not_logged_in_msg)
268
+
}
269
+
270
+
to_mute := app.get_user_by_id(id) or {
271
+
return ctx.server_error('no such user')
272
+
}
273
+
274
+
if user.admin {
275
+
if !app.set_muted(to_mute.id, muted) {
276
+
return ctx.server_error('failed to change mute status')
277
+
}
278
+
return ctx.ok('muted user')
279
+
} else {
280
+
eprintln('insufficient perms to update mute status for ${to_mute} (${to_mute.muted} -> ${muted})')
281
+
return ctx.unauthorized('insufficient permissions')
282
+
}
283
+
}
284
+
285
+
@['/api/user/set_automated'; post]
286
+
fn (mut app App) api_user_set_automated(mut ctx Context, is_automated bool) veb.Result {
287
+
user := app.whoami(mut ctx) or {
288
+
return ctx.unauthorized(not_logged_in_msg)
289
+
}
290
+
291
+
if !app.set_automated(user.id, is_automated) {
292
+
return ctx.server_error('failed to set automated status.')
293
+
}
294
+
295
+
if is_automated {
296
+
return ctx.ok('you\'re now a bot! :D')
297
+
} else {
298
+
return ctx.ok('you\'re no longer a bot :(')
299
+
}
300
+
}
301
+
302
+
@['/api/user/set_theme'; post]
303
+
fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
304
+
if !app.config.instance.allow_changing_theme {
305
+
return ctx.server_error('this instance disallows changing themes :(')
306
+
}
307
+
308
+
user := app.whoami(mut ctx) or {
309
+
return ctx.unauthorized(not_logged_in_msg)
310
+
}
311
+
312
+
mut theme := ?string(none)
313
+
if url.trim_space() == '' {
314
+
theme = app.config.instance.default_theme
315
+
} else {
316
+
theme = url.trim_space()
317
+
}
318
+
319
+
if !app.set_theme(user.id, theme) {
320
+
return ctx.server_error('failed to change theme')
321
+
}
322
+
323
+
return ctx.ok('theme updated')
324
+
}
325
+
326
+
@['/api/user/set_css'; post]
327
+
fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result {
328
+
if !app.config.instance.allow_changing_theme {
329
+
return ctx.server_error('this instance disallows changing themes :(')
330
+
}
331
+
332
+
user := app.whoami(mut ctx) or {
333
+
return ctx.unauthorized(not_logged_in_msg)
334
+
}
335
+
336
+
c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() }
337
+
338
+
if !app.set_css(user.id, c) {
339
+
return ctx.server_error('failed to change css')
340
+
}
341
+
342
+
return ctx.ok('css updated')
343
+
}
344
+
345
+
@['/api/user/set_pronouns'; post]
346
+
fn (mut app App) api_user_set_pronouns(mut ctx Context, pronouns string) veb.Result {
347
+
user := app.whoami(mut ctx) or {
348
+
return ctx.unauthorized(not_logged_in_msg)
349
+
}
350
+
351
+
clean_pronouns := pronouns.trim_space()
352
+
if !app.validators.pronouns.validate(clean_pronouns) {
353
+
return ctx.server_error('invalid pronouns')
354
+
}
355
+
356
+
if !app.set_pronouns(user.id, clean_pronouns) {
357
+
return ctx.server_error('failed to change pronouns')
358
+
}
359
+
360
+
return ctx.ok('pronouns updated')
361
+
}
362
+
363
+
@['/api/user/set_bio'; post]
364
+
fn (mut app App) api_user_set_bio(mut ctx Context, bio string) veb.Result {
365
+
user := app.whoami(mut ctx) or {
366
+
return ctx.unauthorized(not_logged_in_msg)
367
+
}
368
+
369
+
clean_bio := bio.trim_space()
370
+
if !app.validators.user_bio.validate(clean_bio) {
371
+
return ctx.server_error('invalid bio')
372
+
}
373
+
374
+
if !app.set_bio(user.id, clean_bio) {
375
+
eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
376
+
return ctx.server_error('failed to update bio')
377
+
}
378
+
379
+
return ctx.ok('bio updated')
380
+
}
381
+
382
+
@['/api/user/get_name'; get]
383
+
fn (mut app App) api_user_get_name(mut ctx Context, username string) veb.Result {
384
+
if !app.config.instance.public_data {
385
+
_ := app.whoami(mut ctx) or {
386
+
return ctx.unauthorized('no such user')
387
+
}
388
+
}
389
+
user := app.get_user_by_name(username) or { return ctx.server_error('no such user') }
390
+
return ctx.text(user.get_name())
391
+
}
392
+
393
+
@['/api/user/delete'; post]
394
+
fn (mut app App) api_user_delete(mut ctx Context, id int) veb.Result {
395
+
user := app.whoami(mut ctx) or {
396
+
return ctx.unauthorized(not_logged_in_msg)
397
+
}
398
+
399
+
if user.admin || user.id == id {
400
+
println('attempting to delete ${id} as ${user.id}')
401
+
402
+
// yeet
403
+
if !app.delete_user(user.id) {
404
+
return ctx.server_error('failed to delete user: ${id}')
405
+
}
406
+
407
+
app.auth.delete_tokens_for_user(id) or {
408
+
eprintln('failed to delete tokens for user during deletion: ${id}')
409
+
}
410
+
// log out
411
+
if user.id == id {
412
+
ctx.set_cookie(
413
+
name: 'token'
414
+
value: ''
415
+
same_site: .same_site_none_mode
416
+
secure: true
417
+
path: '/'
418
+
)
419
+
}
420
+
println('deleted user ${id}')
421
+
return ctx.ok('user deleted')
422
+
} else {
423
+
return ctx.unauthorized('be nice. deleting other users is off-limits.')
424
+
}
425
+
}
426
+
427
+
@['/api/user/search'; get]
428
+
fn (mut app App) api_user_search(mut ctx Context, query string, limit int, offset int) veb.Result {
429
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) }
430
+
if limit >= search_hard_limit {
431
+
return ctx.server_error('limit exceeds hard limit (${search_hard_limit})')
432
+
}
433
+
users := app.search_for_users(query, limit, offset)
434
+
return ctx.json[[]User](users)
435
+
}
436
+
437
+
@['/api/user/whoami'; get]
438
+
fn (mut app App) api_user_whoami(mut ctx Context) veb.Result {
439
+
user := app.whoami(mut ctx) or {
440
+
return ctx.unauthorized(not_logged_in_msg)
441
+
}
442
+
return ctx.text(user.username)
443
+
}
444
+
445
+
/// user/notification ///
446
+
447
+
@['/api/user/notification/clear'; post]
448
+
fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result {
449
+
user := app.whoami(mut ctx) or {
450
+
return ctx.unauthorized(not_logged_in_msg)
451
+
}
452
+
453
+
if notification := app.get_notification_by_id(id) {
454
+
if notification.user_id != user.id {
455
+
return ctx.server_error('no such notification for user')
456
+
} else if !app.delete_notification(id) {
457
+
return ctx.server_error('failed to delete notification')
458
+
}
459
+
} else {
460
+
return ctx.server_error('no such notification for user')
461
+
}
462
+
463
+
return ctx.ok('cleared notification')
464
+
}
465
+
466
+
@['/api/user/notification/clear_all'; post]
467
+
fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result {
468
+
user := app.whoami(mut ctx) or {
469
+
return ctx.unauthorized(not_logged_in_msg)
470
+
}
471
+
if !app.delete_notifications_for_user(user.id) {
472
+
return ctx.server_error('failed to delete notifications')
473
+
}
474
+
return ctx.ok('cleared notifications')
475
+
}
476
+
477
+
////// post //////
478
+
479
+
@['/api/post/new_post'; post]
480
+
fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result {
481
+
user := app.whoami(mut ctx) or {
482
+
return ctx.unauthorized(not_logged_in_msg)
483
+
}
484
+
485
+
if user.muted {
486
+
return ctx.server_error('you are muted!')
487
+
}
488
+
489
+
// validate title
490
+
if !app.validators.post_title.validate(title) {
491
+
return ctx.server_error('invalid title')
492
+
}
493
+
494
+
// validate body
495
+
if !app.validators.post_body.validate(body) {
496
+
return ctx.server_error('invalid body')
497
+
}
498
+
499
+
nsfw := 'nsfw' in ctx.form
500
+
if nsfw && !app.config.post.allow_nsfw {
501
+
return ctx.server_error('nsfw posts are not allowed on this instance')
502
+
}
503
+
504
+
mut post := Post{
505
+
author_id: user.id
506
+
title: title
507
+
body: body
508
+
nsfw: nsfw
509
+
}
510
+
511
+
if replying_to != 0 {
512
+
// check if replying post exists
513
+
app.get_post_by_id(replying_to) or {
514
+
return ctx.server_error('the post you are trying to reply to does not exist')
515
+
}
516
+
post.replying_to = replying_to
517
+
}
518
+
519
+
if !app.add_post(post) {
520
+
println('failed to post: ${post} from user ${user.id}')
521
+
return ctx.server_error('failed to post')
522
+
}
523
+
524
+
//TODO: Can I not just get the ID directly?? This method feels dicey at best.
525
+
// find the post's id to process mentions with
526
+
if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) {
527
+
app.process_post_mentions(x)
528
+
return ctx.ok('posted. id=${x.id}')
529
+
} else {
530
+
eprintln('api_post_new_post: get_post_by_timestamp_and_author failed for ${post}')
531
+
return ctx.server_error('failed to get post ID, this error should never happen')
532
+
}
533
+
}
534
+
535
+
@['/api/post/delete'; post]
536
+
fn (mut app App) api_post_delete(mut ctx Context, id int) veb.Result {
537
+
user := app.whoami(mut ctx) or {
538
+
return ctx.unauthorized(not_logged_in_msg)
539
+
}
540
+
541
+
post := app.get_post_by_id(id) or {
542
+
return ctx.server_error('post does not exist')
543
+
}
544
+
545
+
if user.admin || user.id == post.author_id {
546
+
if !app.delete_post(post.id) {
547
+
eprintln('api_post_delete: failed to delete post: ${id}')
548
+
return ctx.server_error('failed to delete post')
549
+
}
550
+
println('deleted post: ${id}')
551
+
return ctx.ok('post deleted')
552
+
} else {
553
+
eprintln('insufficient perms to delete post: ${id} (${user.id})')
554
+
return ctx.unauthorized('insufficient permissions')
555
+
}
556
+
}
557
+
558
+
@['/api/post/like'; post]
559
+
fn (mut app App) api_post_like(mut ctx Context, id int) veb.Result {
560
+
user := app.whoami(mut ctx) or {
561
+
return ctx.unauthorized(not_logged_in_msg)
562
+
}
563
+
564
+
post := app.get_post_by_id(id) or {
565
+
return ctx.server_error('post does not exist')
566
+
}
567
+
568
+
if app.does_user_like_post(user.id, post.id) {
569
+
if !app.unlike_post(post.id, user.id) {
570
+
eprintln('user ${user.id} failed to unlike post ${id}')
571
+
return ctx.server_error('failed to unlike post')
572
+
}
573
+
return ctx.ok('unliked post')
574
+
} else {
575
+
// remove the old dislike, if it exists
576
+
if app.does_user_dislike_post(user.id, post.id) {
577
+
if !app.unlike_post(post.id, user.id) {
578
+
eprintln('user ${user.id} failed to remove dislike on post ${id} when liking it')
579
+
}
580
+
}
581
+
582
+
like := Like{
583
+
user_id: user.id
584
+
post_id: post.id
585
+
is_like: true
586
+
}
587
+
if !app.add_like(like) {
588
+
eprintln('user ${user.id} failed to like post ${id}')
589
+
return ctx.server_error('failed to like post')
590
+
}
591
+
return ctx.ok('liked post')
592
+
}
593
+
}
594
+
595
+
@['/api/post/dislike'; post]
596
+
fn (mut app App) api_post_dislike(mut ctx Context, id int) veb.Result {
597
+
user := app.whoami(mut ctx) or {
598
+
return ctx.unauthorized(not_logged_in_msg)
599
+
}
600
+
601
+
post := app.get_post_by_id(id) or {
602
+
return ctx.server_error('post does not exist')
603
+
}
604
+
605
+
if app.does_user_dislike_post(user.id, post.id) {
606
+
if !app.unlike_post(post.id, user.id) {
607
+
eprintln('user ${user.id} failed to undislike post ${id}')
608
+
return ctx.server_error('failed to undislike post')
609
+
}
610
+
return ctx.ok('undisliked post')
611
+
} else {
612
+
// remove the old like, if it exists
613
+
if app.does_user_like_post(user.id, post.id) {
614
+
if !app.unlike_post(post.id, user.id) {
615
+
eprintln('user ${user.id} failed to remove like on post ${id} when disliking it')
616
+
}
617
+
}
618
+
619
+
like := Like{
620
+
user_id: user.id
621
+
post_id: post.id
622
+
is_like: false
623
+
}
624
+
if !app.add_like(like) {
625
+
eprintln('user ${user.id} failed to dislike post ${id}')
626
+
return ctx.server_error('failed to dislike post')
627
+
}
628
+
return ctx.ok('disliked post')
629
+
}
630
+
}
631
+
632
+
@['/api/post/save'; post]
633
+
fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result {
634
+
user := app.whoami(mut ctx) or {
635
+
return ctx.unauthorized(not_logged_in_msg)
636
+
}
637
+
638
+
if app.get_post_by_id(id) != none {
639
+
if app.toggle_save_post(user.id, id) {
640
+
return ctx.text('toggled save')
641
+
} else {
642
+
return ctx.server_error('failed to save post')
643
+
}
644
+
} else {
645
+
return ctx.server_error('post does not exist')
646
+
}
647
+
}
648
+
649
+
@['/api/post/save_for_later'; post]
650
+
fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result {
651
+
user := app.whoami(mut ctx) or {
652
+
return ctx.unauthorized(not_logged_in_msg)
653
+
}
654
+
655
+
if app.get_post_by_id(id) != none {
656
+
if app.toggle_save_for_later_post(user.id, id) {
657
+
return ctx.text('toggled save')
658
+
} else {
659
+
return ctx.server_error('failed to save post')
660
+
}
661
+
} else {
662
+
return ctx.server_error('post does not exist')
663
+
}
664
+
}
665
+
666
+
@['/api/post/get_title'; get]
667
+
fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
668
+
if !app.config.instance.public_data {
669
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) }
670
+
}
671
+
post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
672
+
return ctx.text(post.title)
673
+
}
674
+
675
+
@['/api/post/edit'; post]
676
+
fn (mut app App) api_post_edit(mut ctx Context, id int, title string, body string) veb.Result {
677
+
user := app.whoami(mut ctx) or {
678
+
return ctx.unauthorized(not_logged_in_msg)
679
+
}
680
+
post := app.get_post_by_id(id) or {
681
+
return ctx.server_error('no such post')
682
+
}
683
+
if post.author_id != user.id {
684
+
return ctx.unauthorized('insufficient permissions')
685
+
}
686
+
687
+
nsfw := if 'nsfw' in ctx.form {
688
+
app.config.post.allow_nsfw
689
+
} else {
690
+
post.nsfw
691
+
}
692
+
693
+
if !app.update_post(id, title, body, nsfw) {
694
+
eprintln('failed to update post')
695
+
return ctx.server_error('failed to update post')
696
+
}
697
+
698
+
return ctx.ok('posted edited')
699
+
}
700
+
701
+
@['/api/post/pin'; post]
702
+
fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result {
703
+
user := app.whoami(mut ctx) or {
704
+
return ctx.unauthorized(not_logged_in_msg)
705
+
}
706
+
707
+
if user.admin {
708
+
if !app.pin_post(id) {
709
+
eprintln('failed to pin post: ${id}')
710
+
return ctx.server_error('failed to pin post')
711
+
}
712
+
return ctx.ok('post pinned')
713
+
} else {
714
+
eprintln('insufficient perms to pin post: ${id} (${user.id})')
715
+
return ctx.unauthorized('insufficient permissions')
716
+
}
717
+
}
718
+
719
+
@['/api/post/get/<id>'; get]
720
+
fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
721
+
if !app.config.instance.public_data {
722
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) }
723
+
}
724
+
post := app.get_post_by_id(id) or { return ctx.text('no such post') }
725
+
return ctx.json[Post](post)
726
+
}
727
+
728
+
@['/api/post/search'; get]
729
+
fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result {
730
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized(not_logged_in_msg) }
731
+
if limit >= search_hard_limit {
732
+
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
733
+
}
734
+
posts := app.search_for_posts(query, limit, offset)
735
+
return ctx.json[[]PostSearchResult](posts)
736
+
}
737
+
738
+
////// site //////
739
+
740
+
@['/api/site/set_motd'; post]
741
+
fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
742
+
user := app.whoami(mut ctx) or {
743
+
return ctx.unauthorized(not_logged_in_msg)
744
+
}
745
+
746
+
if user.admin {
747
+
if !app.set_motd(motd) {
748
+
eprintln('failed to set motd: ${motd}')
749
+
return ctx.server_error('failed to set motd')
750
+
}
751
+
println('set motd to: ${motd}')
752
+
return ctx.ok('motd updated')
753
+
} else {
754
+
eprintln('insufficient perms to set motd to: ${motd} (${user.id})')
755
+
return ctx.unauthorized('insufficient permissions')
756
+
}
757
+
}
+154
src/webapp/app.v
+154
src/webapp/app.v
···
1
+
module webapp
2
+
3
+
import veb
4
+
import db.pg
5
+
import regex
6
+
import auth
7
+
import entity { LikeCache, Like, Post, Site, User, Notification }
8
+
import database { DatabaseAccess }
9
+
10
+
pub struct App {
11
+
veb.StaticHandler
12
+
DatabaseAccess
13
+
pub:
14
+
config Config
15
+
buildinfo BuildInfo
16
+
built_at string = @BUILD_TIMESTAMP
17
+
v_hash string = @VHASH
18
+
pub mut:
19
+
auth auth.Auth[pg.DB]
20
+
validators struct {
21
+
pub mut:
22
+
username StringValidator
23
+
password StringValidator
24
+
nickname StringValidator
25
+
pronouns StringValidator
26
+
user_bio StringValidator
27
+
post_title StringValidator
28
+
post_body StringValidator
29
+
}
30
+
}
31
+
32
+
// get_user_by_token returns a user by their token, returns none if the user was
33
+
// not found.
34
+
pub fn (app &App) get_user_by_token(token string) ?User {
35
+
user_token := app.auth.find_token(token) or {
36
+
eprintln('no such user corresponding to token')
37
+
return none
38
+
}
39
+
return app.get_user_by_id(user_token.user_id)
40
+
}
41
+
42
+
// whoami returns the current logged in user, or none if the user is not logged
43
+
// in.
44
+
pub fn (app &App) whoami(mut ctx Context) ?User {
45
+
token := ctx.get_cookie('token') or { return none }.trim_space()
46
+
if token == '' {
47
+
return none
48
+
}
49
+
if user := app.get_user_by_token(token) {
50
+
if user.username == '' || user.id == 0 {
51
+
eprintln('a user had a token for the blank user')
52
+
// Clear token
53
+
ctx.set_cookie(
54
+
name: 'token'
55
+
value: ''
56
+
same_site: .same_site_none_mode
57
+
secure: true
58
+
path: '/'
59
+
)
60
+
return none
61
+
}
62
+
return user
63
+
} else {
64
+
eprintln('a user had a token for a non-existent user (this token may have been expired and left in cookies)')
65
+
// Clear token
66
+
ctx.set_cookie(
67
+
name: 'token'
68
+
value: ''
69
+
same_site: .same_site_none_mode
70
+
secure: true
71
+
path: '/'
72
+
)
73
+
return none
74
+
}
75
+
}
76
+
77
+
// logged_in_as returns true if the user is logged in as the provided user id.
78
+
pub fn (app &App) logged_in_as(mut ctx Context, id int) bool {
79
+
if !ctx.is_logged_in() {
80
+
return false
81
+
}
82
+
return app.whoami(mut ctx) or { return false }.id == id
83
+
}
84
+
85
+
// get_motd returns the site's message of the day.
86
+
@[inline]
87
+
pub fn (app &App) get_motd() string {
88
+
site := app.get_or_create_site_config()
89
+
return site.motd
90
+
}
91
+
92
+
// get_notification_count_for_frontend returns the notification count for a
93
+
// given user, formatted for usage on the frontend.
94
+
pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string {
95
+
count := app.get_notification_count(user_id, limit)
96
+
if count == 0 {
97
+
return ''
98
+
} else if count > limit {
99
+
return ' (${count}+)'
100
+
} else {
101
+
return ' (${count})'
102
+
}
103
+
}
104
+
105
+
// process_post_mentions parses a post's body to send notifications for mentions
106
+
// or replies.
107
+
pub fn (app &App) process_post_mentions(post &Post) {
108
+
author := app.get_user_by_id(post.author_id) or {
109
+
eprintln('process_post_mentioned called on a post with a non-existent author: ${post}')
110
+
return
111
+
}
112
+
author_name := author.get_name()
113
+
114
+
// used so we do not send more than one notification per post
115
+
mut notified_users := []int{}
116
+
117
+
// notify who we replied to, if applicable
118
+
if post.replying_to != none {
119
+
if x := app.get_post_by_id(post.replying_to) {
120
+
app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})')
121
+
}
122
+
}
123
+
124
+
// find mentions
125
+
mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or {
126
+
eprintln('failed to compile regex for process_post_mentions (err: ${err})')
127
+
return
128
+
}
129
+
matches := re.find_all(post.body)
130
+
for i := 0 ; i < matches.len ; i += 2 {
131
+
mat := post.body[matches[i]..matches[i+1]]
132
+
// skip escaped mentions
133
+
if matches[i] != 0 && post.body[matches[i] - 1] == `\\` {
134
+
continue
135
+
}
136
+
137
+
println('found mentioned user: ${mat}')
138
+
username := mat#[2..-1]
139
+
user := app.get_user_by_name(username) or {
140
+
continue
141
+
}
142
+
143
+
if user.id in notified_users || user.id == author.id {
144
+
continue
145
+
}
146
+
notified_users << user.id
147
+
148
+
app.send_notification_to(
149
+
user.id,
150
+
'${author_name} mentioned you!',
151
+
'you have been mentioned in this post: *(${post.id})'
152
+
)
153
+
}
154
+
}
+159
src/webapp/config.v
+159
src/webapp/config.v
···
1
+
module webapp
2
+
3
+
import emmathemartian.maple
4
+
5
+
// Config stores constant site-wide configuration data.
6
+
pub struct Config {
7
+
pub mut:
8
+
dev_mode bool
9
+
static_path string
10
+
instance struct {
11
+
pub mut:
12
+
name string
13
+
welcome string
14
+
default_theme string
15
+
default_css string
16
+
allow_changing_theme bool
17
+
version string
18
+
source string
19
+
v_source string
20
+
invite_only bool
21
+
invite_code string
22
+
public_data bool
23
+
owner_username string
24
+
}
25
+
http struct {
26
+
pub mut:
27
+
port int
28
+
}
29
+
postgres struct {
30
+
pub mut:
31
+
host string
32
+
port int
33
+
user string
34
+
password string
35
+
db string
36
+
}
37
+
hcaptcha struct {
38
+
pub mut:
39
+
enabled bool
40
+
secret string
41
+
site_key string
42
+
}
43
+
post struct {
44
+
pub mut:
45
+
title_min_len int
46
+
title_max_len int
47
+
title_pattern string
48
+
body_min_len int
49
+
body_max_len int
50
+
body_pattern string
51
+
allow_nsfw bool
52
+
}
53
+
user struct {
54
+
pub mut:
55
+
username_min_len int
56
+
username_max_len int
57
+
username_pattern string
58
+
nickname_min_len int
59
+
nickname_max_len int
60
+
nickname_pattern string
61
+
password_min_len int
62
+
password_max_len int
63
+
password_pattern string
64
+
pronouns_min_len int
65
+
pronouns_max_len int
66
+
pronouns_pattern string
67
+
bio_min_len int
68
+
bio_max_len int
69
+
bio_pattern string
70
+
}
71
+
welcome struct {
72
+
pub mut:
73
+
summary string
74
+
body string
75
+
}
76
+
}
77
+
78
+
pub fn load_config_from(file_path string) Config {
79
+
loaded := maple.load_file(file_path) or { panic(err) }
80
+
mut config := Config{}
81
+
82
+
config.dev_mode = loaded.get('dev_mode').to_bool()
83
+
config.static_path = loaded.get('static_path').to_str()
84
+
85
+
loaded_instance := loaded.get('instance')
86
+
config.instance.name = loaded_instance.get('name').to_str()
87
+
config.instance.welcome = loaded_instance.get('welcome').to_str()
88
+
config.instance.default_theme = loaded_instance.get('default_theme').to_str()
89
+
config.instance.default_css = loaded_instance.get('default_css').to_str()
90
+
config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool()
91
+
config.instance.version = loaded_instance.get('version').to_str()
92
+
config.instance.source = loaded_instance.get('source').to_str()
93
+
config.instance.v_source = loaded_instance.get('v_source').to_str()
94
+
config.instance.invite_only = loaded_instance.get('invite_only').to_bool()
95
+
config.instance.invite_code = loaded_instance.get('invite_code').to_str()
96
+
config.instance.public_data = loaded_instance.get('public_data').to_bool()
97
+
config.instance.owner_username = loaded_instance.get('owner_username').to_str()
98
+
99
+
loaded_http := loaded.get('http')
100
+
config.http.port = loaded_http.get('port').to_int()
101
+
102
+
loaded_postgres := loaded.get('postgres')
103
+
config.postgres.host = loaded_postgres.get('host').to_str()
104
+
config.postgres.port = loaded_postgres.get('port').to_int()
105
+
config.postgres.user = loaded_postgres.get('user').to_str()
106
+
config.postgres.password = loaded_postgres.get('password').to_str()
107
+
config.postgres.db = loaded_postgres.get('db').to_str()
108
+
109
+
loaded_hcaptcha := loaded.get('hcaptcha')
110
+
config.hcaptcha.enabled = loaded_hcaptcha.get('enabled').to_bool()
111
+
config.hcaptcha.secret = loaded_hcaptcha.get('secret').to_str()
112
+
config.hcaptcha.site_key = loaded_hcaptcha.get('site_key').to_str()
113
+
114
+
loaded_post := loaded.get('post')
115
+
config.post.title_min_len = loaded_post.get('title_min_len').to_int()
116
+
config.post.title_max_len = loaded_post.get('title_max_len').to_int()
117
+
config.post.title_pattern = loaded_post.get('title_pattern').to_str()
118
+
config.post.body_min_len = loaded_post.get('body_min_len').to_int()
119
+
config.post.body_max_len = loaded_post.get('body_max_len').to_int()
120
+
config.post.body_pattern = loaded_post.get('body_pattern').to_str()
121
+
config.post.allow_nsfw = loaded_post.get('allow_nsfw').to_bool()
122
+
123
+
loaded_user := loaded.get('user')
124
+
config.user.username_min_len = loaded_user.get('username_min_len').to_int()
125
+
config.user.username_max_len = loaded_user.get('username_max_len').to_int()
126
+
config.user.username_pattern = loaded_user.get('username_pattern').to_str()
127
+
config.user.nickname_min_len = loaded_user.get('nickname_min_len').to_int()
128
+
config.user.nickname_max_len = loaded_user.get('nickname_max_len').to_int()
129
+
config.user.nickname_pattern = loaded_user.get('nickname_pattern').to_str()
130
+
config.user.password_min_len = loaded_user.get('password_min_len').to_int()
131
+
config.user.password_max_len = loaded_user.get('password_max_len').to_int()
132
+
config.user.password_pattern = loaded_user.get('password_pattern').to_str()
133
+
config.user.pronouns_min_len = loaded_user.get('pronouns_min_len').to_int()
134
+
config.user.pronouns_max_len = loaded_user.get('pronouns_max_len').to_int()
135
+
config.user.pronouns_pattern = loaded_user.get('pronouns_pattern').to_str()
136
+
config.user.bio_min_len = loaded_user.get('bio_min_len').to_int()
137
+
config.user.bio_max_len = loaded_user.get('bio_max_len').to_int()
138
+
config.user.bio_pattern = loaded_user.get('bio_pattern').to_str()
139
+
140
+
loaded_welcome := loaded.get('welcome')
141
+
config.welcome.summary = loaded_welcome.get('summary').to_str()
142
+
config.welcome.body = loaded_welcome.get('body').to_str()
143
+
144
+
return config
145
+
}
146
+
147
+
pub struct BuildInfo {
148
+
pub mut:
149
+
commit string
150
+
}
151
+
152
+
pub fn load_buildinfo_from(file_path string) BuildInfo {
153
+
loaded := maple.load_file(file_path) or { panic(err) }
154
+
mut buildinfo := BuildInfo{}
155
+
156
+
buildinfo.commit = loaded.get('commit').to_str()
157
+
158
+
return buildinfo
159
+
}
+18
src/webapp/context.v
+18
src/webapp/context.v
···
1
+
module webapp
2
+
3
+
import veb
4
+
5
+
pub struct Context {
6
+
veb.Context
7
+
pub mut:
8
+
title string
9
+
}
10
+
11
+
pub fn (ctx &Context) is_logged_in() bool {
12
+
return ctx.get_cookie('token') or { '' } != ''
13
+
}
14
+
15
+
pub fn (mut ctx Context) unauthorized(msg string) veb.Result {
16
+
ctx.res.set_status(.unauthorized)
17
+
return ctx.send_response_to_client('text/plain', msg)
18
+
}
+246
src/webapp/pages.v
+246
src/webapp/pages.v
···
1
+
module webapp
2
+
3
+
import veb
4
+
import entity { User }
5
+
6
+
fn (mut app App) index(mut ctx Context) veb.Result {
7
+
if !app.config.instance.public_data {
8
+
_ := app.whoami(mut ctx) or {
9
+
ctx.error('not logged in')
10
+
return ctx.redirect('/login')
11
+
}
12
+
}
13
+
14
+
ctx.title = app.config.instance.name
15
+
user := app.whoami(mut ctx) or { User{} }
16
+
recent_posts := app.get_recent_posts()
17
+
pinned_posts := app.get_pinned_posts()
18
+
motd := app.get_motd()
19
+
return $veb.html('../templates/index.html')
20
+
}
21
+
22
+
fn (mut app App) login(mut ctx Context) veb.Result {
23
+
ctx.title = 'login to ${app.config.instance.name}'
24
+
user := app.whoami(mut ctx) or { User{} }
25
+
return $veb.html('../templates/login.html')
26
+
}
27
+
28
+
fn (mut app App) register(mut ctx Context) veb.Result {
29
+
ctx.title = 'register for ${app.config.instance.name}'
30
+
user := app.whoami(mut ctx) or { User{} }
31
+
return $veb.html('../templates/register.html')
32
+
}
33
+
34
+
fn (mut app App) me(mut ctx Context) veb.Result {
35
+
user := app.whoami(mut ctx) or {
36
+
ctx.error('not logged in')
37
+
return ctx.redirect('/login')
38
+
}
39
+
ctx.title = '${app.config.instance.name} - ${user.get_name()}'
40
+
return ctx.redirect('/user/${user.username}')
41
+
}
42
+
43
+
@['/me/saved']
44
+
fn (mut app App) me_saved(mut ctx Context) veb.Result {
45
+
user := app.whoami(mut ctx) or {
46
+
ctx.error('not logged in')
47
+
return ctx.redirect('/login')
48
+
}
49
+
ctx.title = '${app.config.instance.name} - saved posts'
50
+
posts := app.get_saved_posts_as_post_for(user.id)
51
+
return $veb.html('../templates/saved_posts.html')
52
+
}
53
+
54
+
@['/me/saved_for_later']
55
+
fn (mut app App) me_saved_for_later(mut ctx Context) veb.Result {
56
+
user := app.whoami(mut ctx) or {
57
+
ctx.error('not logged in')
58
+
return ctx.redirect('/login')
59
+
}
60
+
ctx.title = '${app.config.instance.name} - posts saved for later'
61
+
posts := app.get_saved_for_later_posts_as_post_for(user.id)
62
+
return $veb.html('../templates/saved_posts_for_later.html')
63
+
}
64
+
65
+
fn (mut app App) settings(mut ctx Context) veb.Result {
66
+
user := app.whoami(mut ctx) or {
67
+
ctx.error('not logged in')
68
+
return ctx.redirect('/login')
69
+
}
70
+
ctx.title = '${app.config.instance.name} - settings'
71
+
return $veb.html('../templates/settings.html')
72
+
}
73
+
74
+
fn (mut app App) admin(mut ctx Context) veb.Result {
75
+
ctx.title = '${app.config.instance.name} dashboard'
76
+
user := app.whoami(mut ctx) or { User{} }
77
+
return $veb.html('../templates/admin.html')
78
+
}
79
+
80
+
fn (mut app App) inbox(mut ctx Context) veb.Result {
81
+
user := app.whoami(mut ctx) or {
82
+
ctx.error('not logged in')
83
+
return ctx.redirect('/login')
84
+
}
85
+
ctx.title = '${app.config.instance.name} inbox'
86
+
notifications := app.get_notifications_for(user.id)
87
+
return $veb.html('../templates/inbox.html')
88
+
}
89
+
90
+
fn (mut app App) logout(mut ctx Context) veb.Result {
91
+
user := app.whoami(mut ctx) or {
92
+
ctx.error('not logged in')
93
+
return ctx.redirect('/login')
94
+
}
95
+
ctx.title = '${app.config.instance.name} logout'
96
+
return $veb.html('../templates/logout.html')
97
+
}
98
+
99
+
@['/user/:username']
100
+
fn (mut app App) user(mut ctx Context, username string) veb.Result {
101
+
if !app.config.instance.public_data {
102
+
_ := app.whoami(mut ctx) or {
103
+
ctx.error('not logged in')
104
+
return ctx.redirect('/login')
105
+
}
106
+
}
107
+
108
+
user := app.whoami(mut ctx) or { User{} }
109
+
viewing := app.get_user_by_name(username) or {
110
+
ctx.error('user not found')
111
+
return ctx.redirect('/')
112
+
}
113
+
ctx.title = '${app.config.instance.name} - ${user.get_name()}'
114
+
posts := app.get_posts_from_user(viewing.id, 10)
115
+
116
+
// needed for new_post component
117
+
replying := false
118
+
replying_to := 0
119
+
replying_to_user := User{}
120
+
121
+
return $veb.html('../templates/user.html')
122
+
}
123
+
124
+
@['/post/:post_id']
125
+
fn (mut app App) post(mut ctx Context, post_id int) veb.Result {
126
+
if !app.config.instance.public_data {
127
+
_ := app.whoami(mut ctx) or {
128
+
ctx.error('not logged in')
129
+
return ctx.redirect('/login')
130
+
}
131
+
}
132
+
133
+
post := app.get_post_by_id(post_id) or {
134
+
ctx.error('no such post')
135
+
return ctx.redirect('/')
136
+
}
137
+
ctx.title = '${app.config.instance.name} - ${post.title}'
138
+
user := app.whoami(mut ctx) or { User{} }
139
+
140
+
mut replying_to_post := app.get_unknown_post()
141
+
mut replying_to_user := app.get_unknown_user()
142
+
143
+
if post.replying_to != none {
144
+
replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() }
145
+
replying_to_user = app.get_user_by_id(replying_to_post.author_id) or {
146
+
app.get_unknown_user()
147
+
}
148
+
}
149
+
150
+
return $veb.html('../templates/post.html')
151
+
}
152
+
153
+
@['/post/:post_id/edit']
154
+
fn (mut app App) edit(mut ctx Context, post_id int) veb.Result {
155
+
user := app.whoami(mut ctx) or {
156
+
ctx.error('not logged in')
157
+
return ctx.redirect('/login')
158
+
}
159
+
post := app.get_post_by_id(post_id) or {
160
+
ctx.error('no such post')
161
+
return ctx.redirect('/')
162
+
}
163
+
if post.author_id != user.id {
164
+
ctx.error('insufficient permissions')
165
+
return ctx.redirect('/post/${post_id}')
166
+
}
167
+
ctx.title = '${app.config.instance.name} - editing ${post.title}'
168
+
return $veb.html('../templates/edit.html')
169
+
}
170
+
171
+
@['/post/:post_id/reply']
172
+
fn (mut app App) reply(mut ctx Context, post_id int) veb.Result {
173
+
user := app.whoami(mut ctx) or {
174
+
ctx.error('not logged in')
175
+
return ctx.redirect('/login')
176
+
}
177
+
post := app.get_post_by_id(post_id) or {
178
+
ctx.error('no such post')
179
+
return ctx.redirect('/')
180
+
}
181
+
ctx.title = '${app.config.instance.name} - reply to ${post.title}'
182
+
replying := true
183
+
replying_to := post_id
184
+
replying_to_user := app.get_user_by_id(post.author_id) or {
185
+
ctx.error('no such post')
186
+
return ctx.redirect('/')
187
+
}
188
+
return $veb.html('../templates/new_post.html')
189
+
}
190
+
191
+
@['/post/new']
192
+
fn (mut app App) new(mut ctx Context) veb.Result {
193
+
user := app.whoami(mut ctx) or {
194
+
ctx.error('not logged in')
195
+
return ctx.redirect('/login')
196
+
}
197
+
ctx.title = '${app.config.instance.name} - new post'
198
+
replying := false
199
+
replying_to := 0
200
+
replying_to_user := User{}
201
+
return $veb.html('../templates/new_post.html')
202
+
}
203
+
204
+
@['/tag/:tag']
205
+
fn (mut app App) tag(mut ctx Context, tag string) veb.Result {
206
+
user := app.whoami(mut ctx) or {
207
+
ctx.error('not logged in')
208
+
return ctx.redirect('/login')
209
+
}
210
+
ctx.title = '${app.config.instance.name} - #${tag}'
211
+
offset := 0
212
+
return $veb.html('../templates/tag.html')
213
+
}
214
+
215
+
@['/tag/:tag/:offset']
216
+
fn (mut app App) tag_with_offset(mut ctx Context, tag string, offset int) veb.Result {
217
+
user := app.whoami(mut ctx) or {
218
+
ctx.error('not logged in')
219
+
return ctx.redirect('/login')
220
+
}
221
+
ctx.title = '${app.config.instance.name} - #${tag}'
222
+
return $veb.html('../templates/tag.html')
223
+
}
224
+
225
+
@['/search']
226
+
fn (mut app App) search(mut ctx Context, q string, offset int) veb.Result {
227
+
user := app.whoami(mut ctx) or {
228
+
ctx.error('not logged in')
229
+
return ctx.redirect('/login')
230
+
}
231
+
ctx.title = '${app.config.instance.name} - search'
232
+
return $veb.html('../templates/search.html')
233
+
}
234
+
235
+
@['/about']
236
+
fn (mut app App) about(mut ctx Context) veb.Result {
237
+
user := app.whoami(mut ctx) or {
238
+
if !app.config.instance.public_data {
239
+
ctx.error('not logged in')
240
+
return ctx.redirect('/login')
241
+
}
242
+
User{}
243
+
}
244
+
ctx.title = '${app.config.instance.name} - about'
245
+
return $veb.html('../templates/about.html')
246
+
}
+39
src/webapp/validation.v
+39
src/webapp/validation.v
···
1
+
module webapp
2
+
3
+
import regex
4
+
5
+
// StringValidator handles validation of user-input fields.
6
+
pub struct StringValidator {
7
+
pub:
8
+
min_len int
9
+
max_len int = max_int
10
+
pattern regex.RE
11
+
}
12
+
13
+
// validate validates a given string and returns true if it succeeded and false
14
+
// otherwise.
15
+
@[inline]
16
+
pub fn (validator StringValidator) validate(str_ string) bool {
17
+
// for whatever reason form inputs can end up with \r\n. i have
18
+
// absolutely no clue why this is a thing. anyway, this is here as a fix
19
+
str := str_.replace('\r\n', '\n')
20
+
21
+
// used for debugging validators. don't uncomment this in prod, please.
22
+
// a) it will log a crap ton of unneeded info, and b) basically all user
23
+
// inputs are validated. including passwords.
24
+
// println('validator on: ${str}')
25
+
// println(' >= min_len: ${str.len >= validator.min_len} (${str.len} >= ${validator.min_len})')
26
+
// println(' <= max_len: ${str.len <= validator.max_len} (${str.len} <= ${validator.max_len})')
27
+
// println(' regex: ${validator.pattern.matches_string(str)}')
28
+
29
+
return str.len >= validator.min_len && str.len <= validator.max_len
30
+
&& validator.pattern.matches_string(str)
31
+
}
32
+
33
+
// StringValidator.new creates a new StringValidator with the given min, max,
34
+
// and pattern.
35
+
pub fn StringValidator.new(min int, max int, pattern string) StringValidator {
36
+
mut re := regex.new()
37
+
re.compile_opt(pattern) or { panic(err) }
38
+
return StringValidator{min, max, re}
39
+
}