+8
.editorconfig
+8
.editorconfig
+8
.gitattributes
+8
.gitattributes
+31
.gitignore
+31
.gitignore
···
1
+
# Binaries for programs and plugins
2
+
main
3
+
clockwork
4
+
beep
5
+
*.exe
6
+
*.exe~
7
+
*.so
8
+
*.dylib
9
+
*.dll
10
+
11
+
# Ignore binary output folders
12
+
bin/
13
+
14
+
# Ignore common editor/system specific metadata
15
+
.DS_Store
16
+
.idea/
17
+
.vscode/
18
+
*.iml
19
+
20
+
# ENV
21
+
.env
22
+
23
+
# vweb and database
24
+
*.db
25
+
*.js
26
+
27
+
# Local V install
28
+
/v/
29
+
30
+
# Local Clockwork install
31
+
/clockwork/
+50
build.maple
+50
build.maple
···
1
+
plugins = [ 'v' ]
2
+
3
+
task:db.init = {
4
+
description = 'Initialize and start a test postgres database'
5
+
category = 'db'
6
+
run = 'docker run -it \
7
+
--name beep-database \
8
+
-e POSTGRES_DB=beep \
9
+
-e POSTGRES_USER=beep \
10
+
-e POSTGRES_PASSWORD=beep \
11
+
--mount source=beep-data,target=/var/lib/postgresql/data \
12
+
-p 5432:5432 \
13
+
postgres:15'
14
+
}
15
+
16
+
task:db.start = {
17
+
description = 'Start the docker image for the database'
18
+
category = 'db'
19
+
run = 'docker start beep-database'
20
+
}
21
+
22
+
task:db.stop = {
23
+
description = 'Stop the docker image for the database'
24
+
category = 'db'
25
+
run = 'docker stop beep-database'
26
+
}
27
+
28
+
task:db.login = {
29
+
description = 'Log into and modify the database'
30
+
category = 'db'
31
+
run = 'docker exec -it beep-database psql -h localhost -p 5432 -d beep -U beep -W'
32
+
}
33
+
34
+
task:db.shell = {
35
+
description = 'Open a shell in the database'
36
+
category = 'db'
37
+
run = 'docker exec -it beep-database sh'
38
+
}
39
+
40
+
task:db.dangerous.nuke = {
41
+
description = 'Nuke the docker image AND ITS DATA. This will delete **EVERYTHING** in the database.'
42
+
category = 'db'
43
+
run = 'docker rm beep-database && docker volume rm beep-data'
44
+
}
45
+
46
+
task:run = {
47
+
description = 'Run beep'
48
+
category = 'run'
49
+
run = '${v} -d veb_livereload watch run ${v_main}'
50
+
}
+39
config.maple
+39
config.maple
···
1
+
dev_mode = true
2
+
3
+
http = {
4
+
port = 8008
5
+
}
6
+
7
+
postgres = {
8
+
host = 'localhost'
9
+
port = 5432
10
+
user = 'beep'
11
+
password = 'beep'
12
+
db = 'beep'
13
+
}
14
+
15
+
// At least one must be enabled for beep to work.
16
+
oauth = {
17
+
github = {
18
+
enabled = true
19
+
id = ''
20
+
secret = ''
21
+
}
22
+
}
23
+
24
+
post = {
25
+
title_max_len = 50
26
+
body_max_len = 500
27
+
}
28
+
29
+
user = {
30
+
username_min_len = 3
31
+
username_max_len = 20
32
+
username_pattern = '[a-z0-9_.]+'
33
+
nickname_min_len = 1
34
+
nickname_max_len = 20
35
+
nickname_pattern = '.+'
36
+
password_min_len = 12
37
+
password_max_len = 72
38
+
password_pattern = '.+'
39
+
}
+7
license.txt
+7
license.txt
···
1
+
Copyright 2024 EmmaTheMartian
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+16
readme.md
+16
readme.md
···
1
+
# beep
2
+
3
+
a self-hosted mini-blogger
4
+
5
+
technically made because i wanted to mess around with rss, but i also wanted a
6
+
teensy little blog for myself
7
+
8
+
## hosting
9
+
10
+
```sh
11
+
git clone https://github.com/emmathemartian/beep
12
+
v -prod .
13
+
./beep [port]
14
+
```
15
+
16
+
then go to `localhost:[port]` to view
+30
scripts/init.sh
+30
scripts/init.sh
···
1
+
#!/usr/bin/env sh
2
+
3
+
set -e
4
+
5
+
# Download V
6
+
if [ ! -e 'v/' ]
7
+
then
8
+
git clone https://github.com/vlang/v
9
+
fi
10
+
11
+
cd v
12
+
if [ ! -e './v' ]
13
+
then
14
+
make
15
+
fi
16
+
sudo ./v symlink
17
+
cd ..
18
+
19
+
# Download Clockwork
20
+
if [ ! -e 'clockwork/' ]
21
+
then
22
+
git clone https://github.com/EmmaTheMartian/clockwork --recursive
23
+
fi
24
+
# Build and install Clockwork
25
+
cd clockwork
26
+
../v/v run . install
27
+
cd ..
28
+
29
+
# Install dependencies
30
+
# clockwork deps
+152
src/api.v
+152
src/api.v
···
1
+
module main
2
+
3
+
import veb
4
+
import auth
5
+
import entity { User, Post }
6
+
7
+
////// Users //////
8
+
9
+
@['/api/user/register'; post]
10
+
fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result {
11
+
println('reg: ${username}')
12
+
13
+
if app.get_user_by_name(username) != none {
14
+
ctx.error('username taken')
15
+
return ctx.redirect('/register')
16
+
}
17
+
18
+
salt := auth.generate_salt()
19
+
user := User{
20
+
username: username
21
+
password: auth.hash_password_with_salt(password, salt)
22
+
password_salt: salt
23
+
}
24
+
25
+
sql app.db {
26
+
insert user into User
27
+
} or {
28
+
eprintln('failed to insert user ${user}')
29
+
return ctx.redirect('/')
30
+
}
31
+
32
+
if x := app.get_user_by_name(username) {
33
+
token := app.auth.add_token(x.id, ctx.ip()) or {
34
+
eprintln(err)
35
+
ctx.error('could not create token for user with id ${user.id}')
36
+
return ctx.redirect('/')
37
+
}
38
+
ctx.set_cookie(
39
+
name: 'token'
40
+
value: token
41
+
same_site: .same_site_none_mode
42
+
secure: true
43
+
path: '/'
44
+
)
45
+
} else {
46
+
eprintln('could not log into newly-created user: ${user}')
47
+
ctx.error('could not log into newly-created user.')
48
+
}
49
+
50
+
return ctx.redirect('/')
51
+
}
52
+
53
+
@['/api/user/login'; post]
54
+
fn (mut app App) api_user_login(mut ctx Context, username string, password string) veb.Result {
55
+
user := app.get_user_by_name(username) or {
56
+
ctx.error('invalid credentials')
57
+
return ctx.redirect('/login')
58
+
}
59
+
if !auth.compare_password_with_hash(password, user.password_salt, user.password) {
60
+
ctx.error('invalid credentials')
61
+
return ctx.redirect('/login')
62
+
}
63
+
token := app.auth.add_token(user.id, ctx.ip()) or {
64
+
eprintln('failed to add token on log in: ${err}')
65
+
ctx.error('could not create token for user with id ${user.id}')
66
+
return ctx.redirect('/login')
67
+
}
68
+
ctx.set_cookie(
69
+
name: 'token'
70
+
value: token
71
+
same_site: .same_site_none_mode
72
+
secure: true
73
+
path: '/'
74
+
)
75
+
return ctx.redirect('/')
76
+
}
77
+
78
+
@['/api/user/logout']
79
+
fn (mut app App) api_user_logout(mut ctx Context) veb.Result {
80
+
if token := ctx.get_cookie('token') {
81
+
if user := app.get_user_by_token(ctx, token) {
82
+
app.auth.delete_tokens_for_ip(ctx.ip()) or {
83
+
eprintln('failed to yeet tokens for ${user.id} with ip ${ctx.ip()}')
84
+
return ctx.redirect('/login')
85
+
}
86
+
} else {
87
+
eprintln('failed to get user for token: `${token}` for logout')
88
+
}
89
+
} else {
90
+
eprintln('failed to get token cookie for logout')
91
+
}
92
+
ctx.set_cookie(
93
+
name: 'token'
94
+
value: ''
95
+
same_site: .same_site_none_mode
96
+
secure: true
97
+
path: '/'
98
+
)
99
+
return ctx.redirect('/login')
100
+
}
101
+
102
+
@['/api/user/full_logout']
103
+
fn (mut app App) api_user_full_logout(mut ctx Context) veb.Result {
104
+
if token := ctx.get_cookie('token') {
105
+
if user := app.get_user_by_token(ctx, token) {
106
+
app.auth.delete_tokens_for_user(user.id) or {
107
+
eprintln('failed to yeet tokens for ${user.id}')
108
+
return ctx.redirect('/login')
109
+
}
110
+
} else {
111
+
eprintln('failed to get user for token: `${token}` for full_logout')
112
+
}
113
+
} else {
114
+
eprintln('failed to get token cookie for full_logout')
115
+
}
116
+
ctx.set_cookie(
117
+
name: 'token'
118
+
value: ''
119
+
same_site: .same_site_none_mode
120
+
secure: true
121
+
path: '/'
122
+
)
123
+
return ctx.redirect('/login')
124
+
}
125
+
126
+
////// Posts //////
127
+
128
+
@['/api/post/new_post'; post]
129
+
fn (mut app App) api_post_new_post(mut ctx Context, title string, body string) veb.Result {
130
+
mut user := app.whoami(mut ctx) or {
131
+
ctx.error('not logged in!')
132
+
return ctx.redirect('/')
133
+
}
134
+
135
+
post := Post{
136
+
author_id: user.id
137
+
title: title
138
+
body: body
139
+
}
140
+
141
+
sql app.db {
142
+
insert post into Post
143
+
} or {
144
+
ctx.error('failed to post!')
145
+
println('failed to post: ${post} from user ${user.id}')
146
+
return ctx.redirect('/me')
147
+
}
148
+
149
+
user.posts << post
150
+
151
+
return ctx.redirect('/me')
152
+
}
+95
src/app.v
+95
src/app.v
···
1
+
module main
2
+
3
+
import veb
4
+
import auth
5
+
import db.pg
6
+
import entity { User, Post }
7
+
8
+
pub struct Context {
9
+
veb.Context
10
+
pub mut:
11
+
title string
12
+
}
13
+
14
+
pub struct App {
15
+
pub:
16
+
config Config
17
+
pub mut:
18
+
db pg.DB
19
+
auth auth.Auth[pg.DB]
20
+
}
21
+
22
+
pub fn (app &App) get_user_by_name(username string) ?User {
23
+
users := sql app.db {
24
+
select from User where username == username
25
+
} or { [] }
26
+
if users.len != 1 {
27
+
return none
28
+
}
29
+
return users[0]
30
+
}
31
+
32
+
pub fn (app &App) get_user_by_id(id int) ?User {
33
+
users := sql app.db {
34
+
select from User where id == id
35
+
} or { [] }
36
+
if users.len != 1 {
37
+
return none
38
+
}
39
+
return users[0]
40
+
}
41
+
42
+
pub fn (app &App) get_user_by_token(ctx &Context, token string) ?User {
43
+
user_token := app.auth.find_token(token, ctx.ip()) or {
44
+
eprintln('no such user corresponding to token: ${token}')
45
+
return none
46
+
}
47
+
return app.get_user_by_id(user_token.user_id)
48
+
}
49
+
50
+
pub fn (mut app App) get_recent_posts() []Post {
51
+
posts := sql app.db {
52
+
select from Post order by posted_at desc limit 10
53
+
} or { [] }
54
+
return posts
55
+
}
56
+
57
+
pub fn (mut app App) get_users() []User {
58
+
users := sql app.db {
59
+
select from User
60
+
} or { [] }
61
+
return users
62
+
}
63
+
64
+
pub fn (app &App) whoami(mut ctx Context) ?User {
65
+
token := ctx.get_cookie('token') or {
66
+
return none
67
+
}.trim_space()
68
+
if token == '' {
69
+
return none
70
+
}
71
+
if user := app.get_user_by_token(ctx, token) {
72
+
if user.username == '' || user.id == 0 {
73
+
eprintln('a user had a token (${token}) for the blank user')
74
+
// Clear token
75
+
ctx.set_cookie(
76
+
name: 'token'
77
+
value: ''
78
+
same_site: .same_site_none_mode
79
+
secure: true
80
+
path: '/'
81
+
)
82
+
return none
83
+
}
84
+
return user
85
+
}
86
+
return none
87
+
}
88
+
89
+
pub fn (ctx &Context) is_logged_in() bool {
90
+
return ctx.get_cookie('token') or { '' } != ''
91
+
}
92
+
93
+
pub fn (app &App) get_unknown_user() User {
94
+
return User{ username: 'unknown' }
95
+
}
+97
src/auth/auth.v
+97
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
+
6
+
module auth
7
+
8
+
import rand
9
+
import crypto.rand as crypto_rand
10
+
import crypto.hmac
11
+
import crypto.sha256
12
+
13
+
const max_safe_unsigned_integer = u32(4_294_967_295)
14
+
15
+
pub struct Auth[T] {
16
+
db T
17
+
}
18
+
19
+
pub struct Token {
20
+
pub:
21
+
id int @[primary; sql: serial]
22
+
user_id int
23
+
value string
24
+
ip string
25
+
}
26
+
27
+
pub fn new[T](db T) Auth[T] {
28
+
set_rand_crypto_safe_seed()
29
+
sql db {
30
+
create table Token
31
+
} or { eprintln('veb.auth: failed to create table Token') }
32
+
return Auth[T]{
33
+
db: db
34
+
}
35
+
}
36
+
37
+
pub fn (mut app Auth[T]) add_token(user_id int, ip string) !string {
38
+
mut uuid := rand.uuid_v4()
39
+
token := Token{
40
+
user_id: user_id
41
+
value: uuid
42
+
ip: ip
43
+
}
44
+
sql app.db {
45
+
insert token into Token
46
+
}!
47
+
return uuid
48
+
}
49
+
50
+
pub fn (app &Auth[T]) find_token(value string, ip string) ?Token {
51
+
tokens := sql app.db {
52
+
select from Token where value == value && ip == ip limit 1
53
+
} or { []Token{} }
54
+
if tokens.len == 0 {
55
+
return none
56
+
}
57
+
return tokens.first()
58
+
}
59
+
60
+
pub fn (mut app Auth[T]) delete_tokens_for_user(user_id int) ! {
61
+
sql app.db {
62
+
delete from Token where user_id == user_id
63
+
}!
64
+
}
65
+
66
+
pub fn (mut app Auth[T]) delete_tokens_for_ip(ip string) ! {
67
+
sql app.db {
68
+
delete from Token where ip == ip
69
+
}!
70
+
}
71
+
72
+
pub fn set_rand_crypto_safe_seed() {
73
+
first_seed := generate_crypto_safe_int_u32()
74
+
second_seed := generate_crypto_safe_int_u32()
75
+
rand.seed([first_seed, second_seed])
76
+
}
77
+
78
+
fn generate_crypto_safe_int_u32() u32 {
79
+
return u32(crypto_rand.int_u64(max_safe_unsigned_integer) or { 0 })
80
+
}
81
+
82
+
pub fn generate_salt() string {
83
+
return rand.i64().str()
84
+
}
85
+
86
+
pub fn hash_password_with_salt(plain_text_password string, salt string) string {
87
+
salted_password := '${plain_text_password}${salt}'
88
+
return sha256.sum(salted_password.bytes()).hex().str()
89
+
}
90
+
91
+
pub fn compare_password_with_hash(plain_text_password string, salt string, hashed string) bool {
92
+
digest := hash_password_with_salt(plain_text_password, salt)
93
+
// constant time comparison
94
+
// I know this is operating on the hex-encoded strings, but it's still constant time
95
+
// and better than not doing it at all
96
+
return hmac.equal(digest.bytes(), hashed.bytes())
97
+
}
+86
src/config.v
+86
src/config.v
···
1
+
module main
2
+
3
+
import emmathemartian.maple
4
+
5
+
pub struct Config {
6
+
pub mut:
7
+
dev_mode bool
8
+
http struct {
9
+
pub mut:
10
+
port int
11
+
}
12
+
postgres struct {
13
+
pub mut:
14
+
host string
15
+
port int
16
+
user string
17
+
password string
18
+
db string
19
+
}
20
+
oauth struct {
21
+
pub mut:
22
+
github struct {
23
+
pub mut:
24
+
enabled bool
25
+
id string
26
+
secret string
27
+
}
28
+
}
29
+
post struct {
30
+
pub mut:
31
+
title_max_len int
32
+
body_max_len int
33
+
}
34
+
user struct {
35
+
pub mut:
36
+
username_min_len int
37
+
username_max_len int
38
+
username_pattern string
39
+
nickname_min_len int
40
+
nickname_max_len int
41
+
nickname_pattern string
42
+
password_min_len int
43
+
password_max_len int
44
+
password_pattern string
45
+
}
46
+
}
47
+
48
+
pub fn load_config_from(file_path string) Config {
49
+
loaded := maple.load_file(file_path) or { panic(err) }
50
+
mut config := Config{}
51
+
52
+
config.dev_mode = loaded.get('dev_mode').to_bool()
53
+
54
+
loaded_http := loaded.get('http')
55
+
config.http.port = loaded_http.get('port').to_int()
56
+
57
+
loaded_postgres := loaded.get('postgres')
58
+
config.postgres.host = loaded_postgres.get('host').to_str()
59
+
config.postgres.port = loaded_postgres.get('port').to_int()
60
+
config.postgres.user = loaded_postgres.get('user').to_str()
61
+
config.postgres.password = loaded_postgres.get('password').to_str()
62
+
config.postgres.db = loaded_postgres.get('db').to_str()
63
+
64
+
loaded_oauth := loaded.get('oauth')
65
+
loaded_oauth_github := loaded_oauth.get('github')
66
+
config.oauth.github.enabled = loaded_oauth_github.get('enabled').to_bool()
67
+
config.oauth.github.id = loaded_oauth_github.get('id').to_str()
68
+
config.oauth.github.secret = loaded_oauth_github.get('secret').to_str()
69
+
70
+
loaded_post := loaded.get('post')
71
+
config.post.title_max_len = loaded_post.get('title_max_len').to_int()
72
+
config.post.body_max_len = loaded_post.get('body_max_len').to_int()
73
+
74
+
loaded_user := loaded.get('user')
75
+
config.user.username_min_len = loaded_user.get('username_min_len').to_int()
76
+
config.user.username_max_len = loaded_user.get('username_max_len').to_int()
77
+
config.user.username_pattern = loaded_user.get('username_pattern').to_str()
78
+
config.user.nickname_min_len = loaded_user.get('nickname_min_len').to_int()
79
+
config.user.nickname_max_len = loaded_user.get('nickname_max_len').to_int()
80
+
config.user.nickname_pattern = loaded_user.get('nickname_pattern').to_str()
81
+
config.user.password_min_len = loaded_user.get('password_min_len').to_int()
82
+
config.user.password_max_len = loaded_user.get('password_max_len').to_int()
83
+
config.user.password_pattern = loaded_user.get('password_pattern').to_str()
84
+
85
+
return config
86
+
}
+15
src/entity/post.v
+15
src/entity/post.v
+26
src/entity/user.v
+26
src/entity/user.v
···
1
+
module entity
2
+
3
+
import time
4
+
5
+
@[json: 'user']
6
+
pub struct User {
7
+
pub mut:
8
+
id int @[primary; sql: serial]
9
+
username string @[unique]
10
+
nickname ?string
11
+
12
+
password string
13
+
password_salt string
14
+
15
+
muted bool
16
+
admin bool
17
+
18
+
posts []Post @[fkey: 'id']
19
+
20
+
created_at time.Time = time.now()
21
+
}
22
+
23
+
@[inline]
24
+
pub fn (user User) get_name() string {
25
+
return user.nickname or { user.username }
26
+
}
+43
src/main.v
+43
src/main.v
···
1
+
module main
2
+
3
+
import db.pg
4
+
import veb
5
+
import auth
6
+
import entity
7
+
8
+
fn init_db(db pg.DB) ! {
9
+
sql db {
10
+
create table entity.User
11
+
create table entity.Post
12
+
}!
13
+
}
14
+
15
+
fn main() {
16
+
config := load_config_from('config.maple')
17
+
18
+
mut db := pg.connect(pg.Config{
19
+
host: config.postgres.host
20
+
dbname: config.postgres.db
21
+
user: config.postgres.user
22
+
password: config.postgres.password
23
+
port: config.postgres.port
24
+
})!
25
+
26
+
defer {
27
+
db.close()
28
+
}
29
+
30
+
mut app := &App{
31
+
config: config
32
+
db: db
33
+
auth: auth.new(db)
34
+
}
35
+
36
+
init_db(db)!
37
+
38
+
if config.dev_mode {
39
+
println('NOTE: YOU ARE IN DEV MODE')
40
+
}
41
+
42
+
veb.run[App, Context](mut app, app.config.http.port)
43
+
}
+49
src/pages.v
+49
src/pages.v
···
1
+
module main
2
+
3
+
import veb
4
+
import entity { User, Post }
5
+
6
+
fn (mut app App) index(mut ctx Context) veb.Result {
7
+
ctx.title = 'beep'
8
+
recent_posts := app.get_recent_posts()
9
+
user := app.whoami(mut ctx) or { User{} }
10
+
return $veb.html()
11
+
}
12
+
13
+
fn (mut app App) login(mut ctx Context) veb.Result {
14
+
ctx.title = 'login to beep'
15
+
user := app.whoami(mut ctx) or { User{} }
16
+
return $veb.html()
17
+
}
18
+
19
+
fn (mut app App) register(mut ctx Context) veb.Result {
20
+
ctx.title = 'register for beep'
21
+
user := app.whoami(mut ctx) or { User{} }
22
+
return $veb.html()
23
+
}
24
+
25
+
fn (mut app App) me(mut ctx Context) veb.Result {
26
+
user := app.whoami(mut ctx) or {
27
+
ctx.error('not logged in')
28
+
return ctx.redirect('/login')
29
+
}
30
+
ctx.title = 'beep - ${user.get_name()}'
31
+
return $veb.html()
32
+
}
33
+
34
+
fn (mut app App) admin(mut ctx Context) veb.Result {
35
+
ctx.title = 'beep dashboard'
36
+
user := app.whoami(mut ctx) or { User{} }
37
+
return $veb.html()
38
+
}
39
+
40
+
@['/user/:username']
41
+
fn (mut app App) user(mut ctx Context, username string) veb.Result {
42
+
user := app.get_user_by_name(username) or {
43
+
ctx.error('user not found')
44
+
return ctx.redirect('/')
45
+
}
46
+
self := app.whoami(mut ctx) or { User{} }
47
+
ctx.title = 'beep - ${user.get_name()}'
48
+
return $veb.html()
49
+
}
+27
src/templates/admin.html
+27
src/templates/admin.html
···
1
+
@include 'partial/header.html'
2
+
3
+
@if !app.config.dev_mode && !ctx.is_logged_in()
4
+
<p>error: not logged in</p>
5
+
@else if !app.config.dev_mode && !user.admin
6
+
<p>error: you are not an admin!</p>
7
+
@else
8
+
9
+
<h1>admin dashboard</h1>
10
+
<p>logged in as: @user</p>
11
+
<div>
12
+
<h2>user list:</h2>
13
+
<div>
14
+
@for u in app.get_users()
15
+
<div>
16
+
<a href="/user/@u.id">@u.get_name() (@@@u.username) [@u.id]</a>
17
+
<p>muted=@u.muted, admin=@u.admin</p>
18
+
<p>created_at=@u.created_at</p>
19
+
</div>
20
+
<hr>
21
+
@end
22
+
</div>
23
+
</div>
24
+
25
+
@end
26
+
27
+
@include 'partial/footer.html'
src/templates/assets/style.html
src/templates/assets/style.html
This is a binary file and will not be displayed.
+5
src/templates/components/post.html
+5
src/templates/components/post.html
···
1
+
<div>
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> - @post.title</p>
3
+
<p>@post.body</p>
4
+
<p>posted at: @post.posted_at</p>
5
+
</div>
+22
src/templates/index.html
+22
src/templates/index.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<h1>welcome to beep</h1>
4
+
5
+
@if ctx.is_logged_in()
6
+
<p>logged in as: @{user.get_name()}</p>
7
+
@end
8
+
9
+
<div>
10
+
<h2>recent posts:</h2>
11
+
<div>
12
+
@if recent_posts.len > 0
13
+
@for post in app.get_recent_posts()
14
+
@include 'components/post.html'
15
+
@end
16
+
@else
17
+
<p>none, you could be the first!</p>
18
+
@end
19
+
</div>
20
+
</div>
21
+
22
+
@include 'partial/footer.html'
+26
src/templates/login.html
+26
src/templates/login.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<h1>log in</h1>
4
+
5
+
<div>
6
+
@if ctx.form_error != ''
7
+
<p>error: @ctx.form_error</p>
8
+
@end
9
+
10
+
@if ctx.is_logged_in()
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">
15
+
<label for="username">username:</label>
16
+
<input type="text" name="username" id="username">
17
+
<br>
18
+
<label for="password">password:</label>
19
+
<input type="password" name="password" id="password">
20
+
<br>
21
+
<input type="submit" value="log in">
22
+
</form>
23
+
@end
24
+
</div>
25
+
26
+
@include 'partial/footer.html'
+46
src/templates/me.html
+46
src/templates/me.html
···
1
+
@include 'partial/header.html'
2
+
3
+
@if ctx.is_logged_in()
4
+
5
+
<h1>welcome, @{user.get_name()} (@@@user.username)</h1>
6
+
<div>
7
+
<form action="/api/post/new_post" method="post">
8
+
<h2>new post:</h2>
9
+
<input
10
+
type="text"
11
+
name="title"
12
+
id="title"
13
+
minlength="1"
14
+
maxlength="@app.config.post.title_max_len"
15
+
>
16
+
<br>
17
+
<textarea
18
+
name="body"
19
+
id="body"
20
+
minlength="1"
21
+
maxlength="@app.config.post.body_max_len"
22
+
></textarea>
23
+
<br>
24
+
<input type="submit" value="post!">
25
+
</form>
26
+
</div>
27
+
<div>
28
+
<h2>posts:</h2>
29
+
@for post in user.posts
30
+
@include 'components/post.html'
31
+
@end
32
+
</div>
33
+
<div>
34
+
<h2>user info:</h2>
35
+
<p>id: @user.id</p>
36
+
<p>username: @user.username</p>
37
+
<p>display name: @user.get_name()</p>
38
+
<p><a href="/api/user/logout">log out</a></p>
39
+
<p><a href="/api/user/full_logout">log out of all devices</a></p>
40
+
</div>
41
+
42
+
@else
43
+
<p>uh oh, you are not logged in! you can log in <a href="/login">here</a></p>
44
+
@end
45
+
46
+
@include 'partial/footer.html'
+41
src/templates/partial/header.html
+41
src/templates/partial/header.html
···
1
+
<!DOCTYPE html>
2
+
<html>
3
+
4
+
<head>
5
+
<meta charset="utf-8" />
6
+
<meta name="description" content="" />
7
+
<link rel="icon" href="/favicon.png" />
8
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
9
+
<title>@ctx.title</title>
10
+
@include 'assets/style.html'
11
+
</head>
12
+
13
+
<body>
14
+
15
+
<header>
16
+
<a href="/">home</a>
17
+
-
18
+
19
+
@if app.config.dev_mode
20
+
<a href="/admin">admin</a>
21
+
-
22
+
@end
23
+
24
+
@if ctx.is_logged_in()
25
+
<a href="/me">profile</a>
26
+
-
27
+
<a href="/api/full_logout">log out</a>
28
+
@else
29
+
<a href="/login">log in</a>
30
+
<span>or</span>
31
+
<a href="/register">sign up</a>
32
+
@end
33
+
<hr>
34
+
</header>
35
+
36
+
<main>
37
+
@if ctx.form_error != ''
38
+
<div>
39
+
<p><strong>error:</strong> ctx.form_error</p>
40
+
</div>
41
+
@end
+26
src/templates/register.html
+26
src/templates/register.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<h1>register</h1>
4
+
5
+
<div>
6
+
@if ctx.form_error != ''
7
+
<p>error: @ctx.form_error</p>
8
+
@end
9
+
10
+
@if ctx.is_logged_in()
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 type="text" name="username" id="username">
17
+
<br>
18
+
<label for="password">password:</label>
19
+
<input type="password" name="password" id="password">
20
+
<br>
21
+
<input type="submit" value="register">
22
+
</form>
23
+
@end
24
+
</div>
25
+
26
+
@include 'partial/footer.html'
+20
src/templates/user.html
+20
src/templates/user.html
···
1
+
@include 'partial/header.html'
2
+
3
+
<h1>@{user.get_name()} (@@@user.username)</h1>
4
+
@if ctx.is_logged_in() && user.username == self.username
5
+
<p>this is you!</p>
6
+
@end
7
+
<div>
8
+
<h2>posts:</h2>
9
+
@for post in user.posts
10
+
@include 'components/post.html'
11
+
@end
12
+
</div>
13
+
<div>
14
+
<h2>user info:</h2>
15
+
<p>id: @user.id</p>
16
+
<p>username: @user.username</p>
17
+
<p>display name: @user.get_name()</p>
18
+
</div>
19
+
20
+
@include 'partial/footer.html'