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