a mini social media app for small communities

add invite-only and private data, clean up readme, change from MIT to BSD 3-clause

+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
··· 13 14 // set this to '' if your instance is closed source (twt) 15 source = 'https://github.com/emmathemartian/beep' 16 } 17 18 http = {
··· 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
···
··· 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
··· 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
···
··· 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
··· 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
··· 1 <!DOCTYPE html> 2 - <html> 3 4 <head> 5 <meta charset="utf-8" /> 6 <meta name="description" content="" /> 7 <link rel="icon" href="/favicon.png" /> 8 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 9 <title>@ctx.title</title> 10 11 @include 'assets/style.html'
··· 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
··· 34 required 35 > 36 <br> 37 @if app.config.hcaptcha.enabled 38 <div class="h-captcha" data-sitekey="@{app.config.hcaptcha.site_key}"></div> 39 <script src="https://js.hcaptcha.com/1/api.js" async defer></script>
··· 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
··· 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
··· 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
··· 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 }