a mini social media app for small communities

Initial commit

Emmeline 1378a2df

+8
.editorconfig
··· 1 + [*] 2 + charset = utf-8 3 + end_of_line = lf 4 + insert_final_newline = true 5 + trim_trailing_whitespace = true 6 + 7 + [*.v] 8 + indent_style = tab
+8
.gitattributes
··· 1 + * text=auto eol=lf 2 + *.bat eol=crlf 3 + 4 + *.v linguist-language=V 5 + *.vv linguist-language=V 6 + *.vsh linguist-language=V 7 + v.mod linguist-language=V 8 + .vdocignore linguist-language=ignore
+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/
+3
.gitpod.yml
··· 1 + tasks: 2 + - name: "Init" 3 + command: ./scripts/init.sh
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + module entity 2 + 3 + import time 4 + 5 + @[json: 'post'] 6 + pub struct Post { 7 + pub mut: 8 + id int @[primary; sql: serial] 9 + author_id int 10 + 11 + title string 12 + body string 13 + 14 + posted_at time.Time = time.now() 15 + }
+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
··· 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
··· 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
··· 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

This is a binary file and will not be displayed.

+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
··· 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
··· 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
··· 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'
+15
src/templates/partial/footer.html
··· 1 + </main> 2 + 3 + <footer> 4 + <hr> 5 + <p>powered by beep</p> 6 + <p><a href="https://github.com/emmathemartian/beep">source</a></p> 7 + @if app.config.dev_mode 8 + <p>token: @{ctx.get_cookie('token')}</p> 9 + <p>user: @{user}</p> 10 + @end 11 + </footer> 12 + 13 + </body> 14 + 15 + </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
··· 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
··· 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'
+11
v.mod
··· 1 + Module { 2 + name: 'beep' 3 + description: 'A self-hosted mini-blogger' 4 + version: '1.0.0' 5 + license: 'MIT' 6 + author: 'EmmaTheMartian' 7 + repo_url: 'https://github.com/emmathemartian/beep' 8 + dependencies: [ 9 + 'EmmaTheMartian.Maple' 10 + ] 11 + }