+8
-16
.gitignore
+8
-16
.gitignore
···
1
-
# binaries
2
-
main
3
-
clockwork
4
-
beep
5
-
*.exe
6
-
*.exe~
7
-
*.so
8
-
*.dylib
9
-
*.dll
10
-
bin/
11
12
-
# editor/system specific metadata
13
.DS_Store
14
-
.idea/
15
.vscode/
16
-
*.iml
17
18
-
# secrets
19
/config.real.maple
20
.env
21
22
-
# local v and clockwork install (from gitpod stuffs)
23
/v/
24
/clockwork/
25
26
-
# quick notes i keep while developing
27
/stickynote.md
···
1
+
# Binaries
2
+
/beep
3
+
/build/
4
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
/clockwork/
17
18
+
# Quick notes I keep while developing
19
/stickynote.md
+7
config.maple
+7
config.maple
···
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
license
+26
license
···
···
1
+
Copyright 2025 Emmeline Coats
2
+
3
+
Redistribution and use in source and binary forms, with or without
4
+
modification, are permitted provided that the following conditions are met:
5
+
6
+
1. Redistributions of source code must retain the above copyright notice, this
7
+
list of conditions and the following disclaimer.
8
+
9
+
2. Redistributions in binary form must reproduce the above copyright notice,
10
+
this list of conditions and the following disclaimer in the documentation
11
+
and/or other materials provided with the distribution.
12
+
13
+
3. Neither the name of the copyright holder nor the names of its contributors
14
+
may be used to endorse or promote products derived from this software
15
+
without specific prior written permission.
16
+
17
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
18
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-7
license.txt
-7
license.txt
···
1
-
Copyright 2024 EmmaTheMartian
2
-
3
-
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
-
5
-
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
-
7
-
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
···
+49
readme
+49
readme
···
···
1
+
2
+
beep
3
+
====
4
+
5
+
> *a legendary land of lowercase lovers.*
6
+
7
+
A self-hosted "social-media-oriented" mini-blogger.
8
+
9
+
Technically made because I wanted to mess around with RSS,
10
+
but I also wanted a teensy little blog/slow-paced-chat-app
11
+
for myself and my friends.
12
+
13
+
hosting
14
+
-------
15
+
16
+
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!
-39
readme.md
-39
readme.md
···
1
-
# beep
2
-
3
-
> *a legendary land of lowercase lovers.*
4
-
5
-
a self-hosted "social-media-oriented" mini-blogger.
6
-
7
-
technically made because i wanted to mess around with rss, but i also wanted a
8
-
teensy little blog/slow-paced-chat-app for myself and my friends.
9
-
10
-
## hosting
11
-
12
-
you will need a postgresql database somewhere, along with v to compile beep:
13
-
14
-
copy the `config.maple` as `config.real.maple`
15
-
16
-
edit `config.real.maple` to set the url, port, username, password, and database
17
-
name.
18
-
19
-
> `config.real.maple` also has settings to configure the feel of your beep
20
-
> instance, post length, username length, welcome messages, etc etc.
21
-
22
-
> **do not put your secrets in `config.maple`**. it is intended to be pushed to
23
-
> git as a "template config." instead, use `config.real.maple` if you plan to
24
-
> push anywhere. it is gitignored already, meaning you do not have to fear about
25
-
> your secrets not being kept a secret.
26
-
27
-
```sh
28
-
git clone https://github.com/emmathemartian/beep
29
-
cd beep
30
-
v -prod .
31
-
./beep config.real.maple
32
-
```
33
-
34
-
then go to the configured url to view (default is `http://localhost:8008`).
35
-
36
-
if you do not have a database, you can either self-host a postgresql database on
37
-
your machine, or you can find a free one online. i use and like
38
-
[neon.tech](https://neon.tech), their free plan is pretty comfortable for a
39
-
small beep instance!
···
+3
-2
src/templates/partial/header.html
+3
-2
src/templates/partial/header.html
···
1
<!DOCTYPE html>
2
+
<html lang="en">
3
4
<head>
5
<meta charset="utf-8" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
<meta name="description" content="" />
8
+
9
<link rel="icon" href="/favicon.png" />
10
<title>@ctx.title</title>
11
12
@include 'assets/style.html'
+5
src/templates/register.html
+5
src/templates/register.html
···
34
required
35
>
36
<br>
37
+
@if app.config.instance.invite_only
38
+
<label for="invite-code">invite code:</label>
39
+
<input type="text" name="invite-code" id="invite-code" required>
40
+
<br>
41
+
@end
42
@if app.config.hcaptcha.enabled
43
<div class="h-captcha" data-sitekey="@{app.config.hcaptcha.site_key}"></div>
44
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
+45
-13
src/webapp/api.v
+45
-13
src/webapp/api.v
···
11
// people from requesting searches with huge limits and straining the SQL server
12
pub const search_hard_limit = 50
13
14
////// user //////
15
16
struct HcaptchaResponse {
···
40
ctx.error('failed to verify hcaptcha: ${data}')
41
return ctx.redirect('/register')
42
}
43
}
44
45
if app.get_user_by_name(username) != none {
···
254
// validate
255
if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
256
ctx.error('invalid nickname')
257
-
return ctx.redirect('/me')
258
}
259
260
if !app.set_nickname(user.id, clean_nickname) {
261
eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
262
-
return ctx.redirect('/me')
263
}
264
265
-
return ctx.redirect('/me')
266
}
267
268
@['/api/user/set_muted'; post]
···
301
ctx.error('failed to set automated status.')
302
}
303
304
-
return ctx.redirect('/me')
305
}
306
307
@['/api/user/set_theme'; post]
308
fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
309
if !app.config.instance.allow_changing_theme {
310
ctx.error('this instance disallows changing themes :(')
311
-
return ctx.redirect('/me')
312
}
313
314
user := app.whoami(mut ctx) or {
···
323
324
if !app.set_theme(user.id, theme) {
325
ctx.error('failed to change theme')
326
-
return ctx.redirect('/me')
327
}
328
329
-
return ctx.redirect('/me')
330
}
331
332
@['/api/user/set_pronouns'; post]
···
339
clean_pronouns := pronouns.trim_space()
340
if !app.validators.pronouns.validate(clean_pronouns) {
341
ctx.error('invalid pronouns')
342
-
return ctx.redirect('/me')
343
}
344
345
if !app.set_pronouns(user.id, clean_pronouns) {
346
ctx.error('failed to change pronouns')
347
-
return ctx.redirect('/me')
348
}
349
350
-
return ctx.redirect('/me')
351
}
352
353
@['/api/user/set_bio'; post]
···
360
clean_bio := bio.trim_space()
361
if !app.validators.user_bio.validate(clean_bio) {
362
ctx.error('invalid bio')
363
-
return ctx.redirect('/me')
364
}
365
366
if !app.set_bio(user.id, clean_bio) {
367
eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
368
-
return ctx.redirect('/me')
369
}
370
371
-
return ctx.redirect('/me')
372
}
373
374
@['/api/user/get_name']
···
650
651
@['/api/post/get_title']
652
fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
653
post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
654
return ctx.text(post.title)
655
}
···
701
702
@['/api/post/get/<id>'; get]
703
fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
704
post := app.get_post_by_id(id) or { return ctx.text('no such post') }
705
return ctx.json[Post](post)
706
}
707
708
@['/api/post/search'; get]
709
fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result {
710
if limit >= search_hard_limit {
711
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
712
}
···
11
// people from requesting searches with huge limits and straining the SQL server
12
pub const search_hard_limit = 50
13
14
+
////// util ///////
15
+
16
+
const always_public_routes = [
17
+
'/api/user/register/',
18
+
]
19
+
20
+
fn (mut app App) public_data_check(mut ctx Context) ?veb.Result {
21
+
if !app.config.instance.public_data {
22
+
println(ctx.req.url)
23
+
if ctx.req.url in always_public_routes {
24
+
return none
25
+
}
26
+
_ := app.whoami(mut ctx) or {
27
+
ctx.error('not logged in')
28
+
return ctx.redirect('/login')
29
+
}
30
+
}
31
+
return none
32
+
}
33
+
34
////// user //////
35
36
struct HcaptchaResponse {
···
60
ctx.error('failed to verify hcaptcha: ${data}')
61
return ctx.redirect('/register')
62
}
63
+
}
64
+
65
+
if app.config.instance.invite_only && ctx.form['invite-code'] != app.config.instance.invite_code {
66
+
ctx.error('invalid invite code')
67
+
return ctx.redirect('/register')
68
}
69
70
if app.get_user_by_name(username) != none {
···
279
// validate
280
if clean_nickname != none && !app.validators.nickname.validate(clean_nickname or { '' }) {
281
ctx.error('invalid nickname')
282
+
return ctx.redirect('/settings')
283
}
284
285
if !app.set_nickname(user.id, clean_nickname) {
286
eprintln('failed to update nickname for ${user} (${user.nickname} -> ${clean_nickname})')
287
+
return ctx.redirect('/settings')
288
}
289
290
+
return ctx.redirect('/settings')
291
}
292
293
@['/api/user/set_muted'; post]
···
326
ctx.error('failed to set automated status.')
327
}
328
329
+
return ctx.redirect('/settings')
330
}
331
332
@['/api/user/set_theme'; post]
333
fn (mut app App) api_user_set_theme(mut ctx Context, url string) veb.Result {
334
if !app.config.instance.allow_changing_theme {
335
ctx.error('this instance disallows changing themes :(')
336
+
return ctx.redirect('/settings')
337
}
338
339
user := app.whoami(mut ctx) or {
···
348
349
if !app.set_theme(user.id, theme) {
350
ctx.error('failed to change theme')
351
+
return ctx.redirect('/settings')
352
}
353
354
+
return ctx.redirect('/settings')
355
}
356
357
@['/api/user/set_pronouns'; post]
···
364
clean_pronouns := pronouns.trim_space()
365
if !app.validators.pronouns.validate(clean_pronouns) {
366
ctx.error('invalid pronouns')
367
+
return ctx.redirect('/settings')
368
}
369
370
if !app.set_pronouns(user.id, clean_pronouns) {
371
ctx.error('failed to change pronouns')
372
+
return ctx.redirect('/settings')
373
}
374
375
+
return ctx.redirect('/settings')
376
}
377
378
@['/api/user/set_bio'; post]
···
385
clean_bio := bio.trim_space()
386
if !app.validators.user_bio.validate(clean_bio) {
387
ctx.error('invalid bio')
388
+
return ctx.redirect('/settings')
389
}
390
391
if !app.set_bio(user.id, clean_bio) {
392
eprintln('failed to update bio for ${user} (${user.bio} -> ${clean_bio})')
393
+
return ctx.redirect('/settings')
394
}
395
396
+
return ctx.redirect('/settings')
397
}
398
399
@['/api/user/get_name']
···
675
676
@['/api/post/get_title']
677
fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result {
678
+
if !app.config.instance.public_data {
679
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
680
+
}
681
post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
682
return ctx.text(post.title)
683
}
···
729
730
@['/api/post/get/<id>'; get]
731
fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result {
732
+
if !app.config.instance.public_data {
733
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
734
+
}
735
post := app.get_post_by_id(id) or { return ctx.text('no such post') }
736
return ctx.json[Post](post)
737
}
738
739
@['/api/post/search'; get]
740
fn (mut app App) api_post_search(mut ctx Context, query string, limit int, offset int) veb.Result {
741
+
_ := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') }
742
if limit >= search_hard_limit {
743
return ctx.text('limit exceeds hard limit (${search_hard_limit})')
744
}
+6
src/webapp/config.v
+6
src/webapp/config.v
···
15
allow_changing_theme bool
16
version string
17
source string
18
}
19
http struct {
20
pub mut:
···
82
config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool()
83
config.instance.version = loaded_instance.get('version').to_str()
84
config.instance.source = loaded_instance.get('source').to_str()
85
86
loaded_http := loaded.get('http')
87
config.http.port = loaded_http.get('port').to_int()
···
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:
···
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()
+22
-3
src/webapp/pages.v
+22
-3
src/webapp/pages.v
···
4
import entity { User }
5
6
fn (mut app App) index(mut ctx Context) veb.Result {
7
ctx.title = app.config.instance.name
8
user := app.whoami(mut ctx) or { User{} }
9
recent_posts := app.get_recent_posts()
···
91
92
@['/user/:username']
93
fn (mut app App) user(mut ctx Context, username string) veb.Result {
94
user := app.whoami(mut ctx) or { User{} }
95
viewing := app.get_user_by_name(username) or {
96
ctx.error('user not found')
···
103
104
@['/post/:post_id']
105
fn (mut app App) post(mut ctx Context, post_id int) veb.Result {
106
post := app.get_post_by_id(post_id) or {
107
ctx.error('no such post')
108
return ctx.redirect('/')
···
114
mut replying_to_user := app.get_unknown_user()
115
116
if post.replying_to != none {
117
-
replying_to_post = app.get_post_by_id(post.replying_to) or {
118
-
app.get_unknown_post()
119
-
}
120
replying_to_user = app.get_user_by_id(replying_to_post.author_id) or {
121
app.get_unknown_user()
122
}
···
4
import entity { User }
5
6
fn (mut app App) index(mut ctx Context) veb.Result {
7
+
if !app.config.instance.public_data {
8
+
_ := app.whoami(mut ctx) or {
9
+
ctx.error('not logged in')
10
+
return ctx.redirect('/login')
11
+
}
12
+
}
13
+
14
ctx.title = app.config.instance.name
15
user := app.whoami(mut ctx) or { User{} }
16
recent_posts := app.get_recent_posts()
···
98
99
@['/user/:username']
100
fn (mut app App) user(mut ctx Context, username string) veb.Result {
101
+
if !app.config.instance.public_data {
102
+
_ := app.whoami(mut ctx) or {
103
+
ctx.error('not logged in')
104
+
return ctx.redirect('/login')
105
+
}
106
+
}
107
+
108
user := app.whoami(mut ctx) or { User{} }
109
viewing := app.get_user_by_name(username) or {
110
ctx.error('user not found')
···
117
118
@['/post/:post_id']
119
fn (mut app App) post(mut ctx Context, post_id int) veb.Result {
120
+
if !app.config.instance.public_data {
121
+
_ := app.whoami(mut ctx) or {
122
+
ctx.error('not logged in')
123
+
return ctx.redirect('/login')
124
+
}
125
+
}
126
+
127
post := app.get_post_by_id(post_id) or {
128
ctx.error('no such post')
129
return ctx.redirect('/')
···
135
mut replying_to_user := app.get_unknown_user()
136
137
if post.replying_to != none {
138
+
replying_to_post = app.get_post_by_id(post.replying_to) or { app.get_unknown_post() }
139
replying_to_user = app.get_user_by_id(replying_to_post.author_id) or {
140
app.get_unknown_user()
141
}