1
0
mirror of https://github.com/vlang/v.git synced 2023-08-10 21:13:21 +03:00

examples: fullstack vweb example (#16761)

This commit is contained in:
Hitalo Souza 2023-01-05 22:36:42 -03:00 committed by GitHub
parent 43d8bc30f9
commit 0146509516
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 759 additions and 0 deletions

View File

@ -9,6 +9,7 @@ const vroot = @VMODROOT
const efolders = [
'examples/viewer',
'examples/vweb_orm_jwt',
'examples/vweb_fullstack',
]
fn main() {

View File

@ -0,0 +1,9 @@
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.v]
indent_style = tab
indent_size = 4

View File

@ -0,0 +1,7 @@
* text=auto eol=lf
*.bat eol=crlf
**/*.v linguist-language=V
**/*.vv linguist-language=V
**/*.vsh linguist-language=V
**/v.mod linguist-language=V

18
examples/vweb_fullstack/.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Binaries for programs and plugins
main
batata
*.exe
*.exe~
*.so
*.dylib
*.dll
*.sql
# Ignore binary output folders
bin/
# Ignore common editor/system specific metadata
.DS_Store
.idea/
.vscode/
*.iml

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Hitalo Souza
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!-- Generator: Gravit.io --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 500 500" width="500px" height="500px"><defs><clipPath id="_clipPath_8TWIgR1z3pxinjWBiigzcEIrVJKv9Gq4"><rect width="500" height="500"/></clipPath></defs><g clip-path="url(#_clipPath_8TWIgR1z3pxinjWBiigzcEIrVJKv9Gq4)"><path d=" M 318.422 453.543 L 463.705 49.541 C 466.168 42.689 462.285 37.693 455.037 38.392 L 340.786 49.398 C 333.539 50.097 325.71 56.246 323.316 63.121 L 188.843 449.216 C 186.447 456.091 190.414 461.673 197.695 461.673 L 308.901 461.673 C 312.541 461.673 316.497 458.893 317.729 455.466 L 318.422 453.543 Z " fill="rgb(83,107,138)"/><defs><filter id="Hmac7mZraFWHw0G84Yxj4QuzeTFp0E7Y" x="-200%" y="-200%" width="400%" height="400%" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB"><feGaussianBlur xmlns="http://www.w3.org/2000/svg" in="SourceGraphic" stdDeviation="6.440413594258542"/><feOffset xmlns="http://www.w3.org/2000/svg" dx="0" dy="0" result="pf_100_offsetBlur"/><feFlood xmlns="http://www.w3.org/2000/svg" flood-color="#000000" flood-opacity="0.65"/><feComposite xmlns="http://www.w3.org/2000/svg" in2="pf_100_offsetBlur" operator="in" result="pf_100_dropShadow"/><feBlend xmlns="http://www.w3.org/2000/svg" in="SourceGraphic" in2="pf_100_dropShadow" mode="normal"/></filter></defs><g filter="url(#Hmac7mZraFWHw0G84Yxj4QuzeTFp0E7Y)"><path d=" M 301.848 455.466 L 241.359 280.725 L 250 275.324 L 311.57 453.543 L 301.848 455.466 Z " fill="rgb(235,235,235)"/></g><path d=" M 44.963 38.392 L 159.214 49.398 C 166.461 50.097 174.298 56.243 176.704 63.115 L 314.022 455.448 C 315.224 458.885 313.245 461.673 309.604 461.673 L 197.695 461.673 C 190.414 461.673 182.502 456.111 180.038 449.259 L 36.295 49.541 C 33.832 42.689 37.715 37.693 44.963 38.392 Z " fill="rgb(93,135,191)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View File

@ -0,0 +1,13 @@
module main
import vweb
['/controller/auth'; post]
pub fn (mut app App) controller_auth(username string, password string) vweb.Result {
response := app.service_auth(username, password) or {
app.set_status(400, '')
return app.text('error: ${err}')
}
return app.json(response)
}

View File

@ -0,0 +1,6 @@
module main
struct AuthRequestDto {
username string [required]
password string [required]
}

View File

@ -0,0 +1,90 @@
module main
import crypto.hmac
import crypto.sha256
import crypto.bcrypt
import encoding.base64
import json
import databases
import time
struct JwtHeader {
alg string
typ string
}
struct JwtPayload {
sub string // (subject) = Entity to whom the token belongs, usually the user ID;
iss string // (issuer) = Token issuer;
exp string // (expiration) = Timestamp of when the token will expire;
iat time.Time // (issued at) = Timestamp of when the token was created;
aud string // (audience) = Token recipient, represents the application that will use it.
name string
roles string
permissions string
}
fn (mut app App) service_auth(username string, password string) !string {
mut db := databases.create_db_connection() or {
eprintln(err)
panic(err)
}
defer {
db.close() or { panic('fail to close database') }
}
user := sql db {
select from User where username == username limit 1
}
if user.username != username {
return error('user not found')
}
if !user.active {
return error('user is not active')
}
bcrypt.compare_hash_and_password(password.bytes(), user.password.bytes()) or {
return error('Failed to auth user, ${err}')
}
token := make_token(user)
return token
}
fn make_token(user User) string {
secret := 'SECRET_KEY' // os.getenv('SECRET_KEY')
jwt_header := JwtHeader{'HS256', 'JWT'}
jwt_payload := JwtPayload{
sub: '${user.id}'
name: '${user.username}'
iat: time.now()
}
header := base64.url_encode(json.encode(jwt_header).bytes())
payload := base64.url_encode(json.encode(jwt_payload).bytes())
signature := base64.url_encode(hmac.new(secret.bytes(), '${header}.${payload}'.bytes(),
sha256.sum, sha256.block_size).bytestr().bytes())
jwt := '${header}.${payload}.${signature}'
return jwt
}
fn auth_verify(token string) bool {
if token == '' {
return false
}
secret := 'SECRET_KEY' // os.getenv('SECRET_KEY')
token_split := token.split('.')
signature_mirror := hmac.new(secret.bytes(), '${token_split[0]}.${token_split[1]}'.bytes(),
sha256.sum, sha256.block_size).bytestr().bytes()
signature_from_token := base64.url_decode(token_split[2])
return hmac.equal(signature_from_token, signature_mirror)
// return true
}

View File

@ -0,0 +1,8 @@
module databases
import sqlite // can change to 'mysql', 'pg'
pub fn create_db_connection() !sqlite.DB {
mut db := sqlite.connect('vweb.sql')!
return db
}

View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<head>
<!--Let browser know website is optimized for mobile-->
<meta charset="UTF-8" name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Compiled and minified CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<!-- Compiled and minified JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<!-- Material UI icons -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<title>${title}</title>
</head>
<body>
<div>@include 'templates/header_component.html'</div>
<div class="card-panel center-align" style="max-width: 240px; padding: 10px; margin: 10px; border-radius: 5px;">
<form id="index_form" method='post' action=''>
<div style="display:flex; flex-direction: column;">
<input type='text' name='username' placeholder='Username' required autofocus>
<input type='password' name='password' placeholder='Password' required>
</div>
<div style="margin-top: 10px;">
<input class="waves-effect waves-light btn-small" type='submit' onclick="login()" formaction="javascript:void(0);" value='Login'>
<input class="waves-effect waves-light btn-small" type='submit' onclick="addUser()" formaction="javascript:void(0);" value='Register'>
</div>
</form>
<script type="text/javascript">
// function eraseCookie(name) {
// document.cookie = name + '=; Max-Age=0'
// }
async function addUser() {
const form = document.querySelector('#index_form');
const formData = new FormData(form);
await fetch('/controller/user/create', {
method: 'POST',
body: formData
})
.then( async (response) => {
if (response.status != 201) {
throw await response.text()
}
return await response.text()
})
.then((data) => {
alert("User created successfully")
})
.catch((error) => {
alert(error);
});
}
async function login() {
const form = document.querySelector('#index_form');
const formData = new FormData(form);
await fetch('/controller/auth', {
method: 'POST',
body: formData
})
.then( async (response) => {
if (response.status != 200) {
throw await response.text()
}
return response.json()
})
.then((data) => {
document.cookie = 'token='+data+';';
window.location.href = '/products'
})
.catch((error) => {
alert(error);
});
}
</script>
</div>
</body>
</html>

View File

@ -0,0 +1,40 @@
module main
import vweb
import databases
import os
const (
port = 8082
)
struct App {
vweb.Context
}
pub fn (app App) before_request() {
println('[web] before_request: ${app.req.method} ${app.req.url}')
}
fn main() {
mut db := databases.create_db_connection() or { panic(err) }
sql db {
create table User
} or { panic('error on create table: ${err}') }
db.close() or { panic(err) }
mut app := &App{}
app.serve_static('/favicon.ico', 'src/assets/favicon.ico')
// makes all static files available.
app.mount_static_folder_at(os.resource_abs_path('.'), '/')
vweb.run(app, port)
}
pub fn (mut app App) index() vweb.Result {
title := 'vweb app'
return $vweb.html()
}

View File

@ -0,0 +1,62 @@
module main
import vweb
import encoding.base64
import json
['/controller/products'; get]
pub fn (mut app App) controller_get_all_products() vweb.Result {
token := app.req.header.get_custom('token') or { '' }
if !auth_verify(token) {
app.set_status(401, '')
return app.text('Not valid token')
}
jwt_payload_stringify := base64.url_decode_str(token.split('.')[1])
jwt_payload := json.decode(JwtPayload, jwt_payload_stringify) or {
app.set_status(501, '')
return app.text('jwt decode error')
}
user_id := jwt_payload.sub
response := app.service_get_all_products_from(user_id.int()) or {
app.set_status(400, '')
return app.text('${err}')
}
return app.json(response)
// return app.text('response')
}
['/controller/product/create'; post]
pub fn (mut app App) controller_create_product(product_name string) vweb.Result {
if product_name == '' {
app.set_status(400, '')
return app.text('product name cannot be empty')
}
token := app.req.header.get_custom('token') or { '' }
if !auth_verify(token) {
app.set_status(401, '')
return app.text('Not valid token')
}
jwt_payload_stringify := base64.url_decode_str(token.split('.')[1])
jwt_payload := json.decode(JwtPayload, jwt_payload_stringify) or {
app.set_status(501, '')
return app.text('jwt decode error')
}
user_id := jwt_payload.sub
app.service_add_product(product_name, user_id.int()) or {
app.set_status(400, '')
return app.text('error: ${err}')
}
app.set_status(201, '')
return app.text('product created successfully')
}

View File

@ -0,0 +1,9 @@
module main
[table: 'products']
struct Product {
id int [primary; sql: serial]
user_id int
name string [required; sql_type: 'TEXT']
created_at string [default: 'CURRENT_TIMESTAMP']
}

View File

@ -0,0 +1,43 @@
module main
import databases
fn (mut app App) service_add_product(product_name string, user_id int) ! {
mut db := databases.create_db_connection()!
defer {
db.close() or { panic(err) }
}
product_model := Product{
name: product_name
user_id: user_id
}
mut insert_error := ''
sql db {
insert product_model into Product
} or { insert_error = err.msg() }
if insert_error != '' {
return error(insert_error)
}
}
fn (mut app App) service_get_all_products_from(user_id int) ?[]Product {
mut db := databases.create_db_connection() or {
println(err)
return err
}
defer {
db.close() or { panic(err) }
}
results := sql db {
select from Product where user_id == user_id
}
return results
}

View File

@ -0,0 +1,18 @@
module main
import vweb
['/products'; get]
pub fn (mut app App) products() !vweb.Result {
token := app.get_cookie('token') or {
app.set_status(400, '')
return app.text('${err}')
}
user := get_user(token) or {
app.set_status(400, '')
return app.text('Failed to fetch data from the server. Error: ${err}')
}
return $vweb.html()
}

View File

@ -0,0 +1,35 @@
module main
import json
import net.http
pub fn get_products(token string) ![]Product {
mut header := http.new_header()
header.add_custom('token', token)!
url := 'http://localhost:8082/controller/products'
mut config := http.FetchConfig{
header: header
}
resp := http.fetch(http.FetchConfig{ ...config, url: url })!
products := json.decode([]Product, resp.body)!
return products
}
pub fn get_product(token string) ![]User {
mut header := http.new_header()
header.add_custom('token', token)!
url := 'http://localhost:8082/controller/product'
mut config := http.FetchConfig{
header: header
}
resp := http.fetch(http.FetchConfig{ ...config, url: url })!
products := json.decode([]User, resp.body)!
return products
}

View File

@ -0,0 +1,15 @@
<nav>
<div class="nav-wrapper">
<a href="javascript:window.history.back();" class="left">
<i class="material-icons">arrow_back_ios_new</i>
</a>
<a href="/">
<img src="src/assets/veasel.png" alt="logo" style="max-height: 100%" />
</a>
<ul id="nav-mobile" class="right">
<li><a href="https://github.com/vlang/v">github</a></li>
<li><a href="https://vlang.io/">website</a></li>
<li><a href="https://github.com/sponsors/medvednikov">support</a></li>
</ul>
</div>
</nav>

View File

@ -0,0 +1,11 @@
h1.title {
font-family: Arial, Helvetica, sans-serif;
color: #3b7bbf;
}
div.products-table {
border: 1px solid;
max-width: 720px;
padding: 10px;
margin: 10px;
}

View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<head>
<!--Let browser know website is optimized for mobile-->
<meta charset="UTF-8" name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Compiled and minified CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<!-- Compiled and minified JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<!-- Material UI icons -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<title>Login</title>
@css 'src/templates/products.css'
</head>
<body>
<div>@include 'header_component.html'</div>
<h1 class="title">Hi, ${user.username}! you are online</h1>
<!-- <button onclick="document.location.reload(true)">Lala</button> -->
<form id="product_form" method='post' action=''>
<div class="row">
<div class="input-field col s2">
<input id="product_name" name='product_name' type="text" class="validate">
<label class="active" for="product_name">product name</label>
</div>
<div style="margin-top: 10px;">
<input class="waves-effect waves-light btn-small" type='submit' onclick="addProduct()" formaction="javascript:void(0);" value='Register' required autofocus>
</div>
</div>
<!-- <div style="width: 20; height: 300;">
<input type='text' name='product_name' placeholder='product name' required autofocus>
</div> -->
</form>
<script type="text/javascript">
function getCookie(cookieName) {
let cookie = {};
document.cookie.split(';').forEach(function(el) {
let [key,value] = el.split('=');
cookie[key.trim()] = value;
})
return cookie[cookieName];
}
async function addProduct() {
const form = document.querySelector('#product_form');
const formData = new FormData(form);
console.log(getCookie("token"));
await fetch('/controller/product/create', {
method: 'POST',
body: formData,
headers :{
token: getCookie("token")
}
})
.then( async (response) => {
if (response.status != 201) {
throw await response.text()
}
return await response.text()
})
.then((data) => {
// alert("User created successfully")
document.location.reload(true)
})
.catch((error) => {
alert(error);
});
}
</script>
<div class="products-table card-panel">
<table class="highlight striped responsive-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Created date</th>
</tr>
</thead>
<tbody>
@for product in user.products
<tr>
<td>${product.id}</td>
<td>${product.name}</td>
<td>${product.created_at}</td>
</tr>
@end
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,66 @@
module main
import vweb
import encoding.base64
import json
['/controller/users'; get]
pub fn (mut app App) controller_get_all_user() vweb.Result {
// token := app.get_cookie('token') or { '' }
token := app.req.header.get_custom('token') or { '' }
if !auth_verify(token) {
app.set_status(401, '')
return app.text('Not valid token')
}
response := app.service_get_all_user() or {
app.set_status(400, '')
return app.text('${err}')
}
return app.json(response)
}
['/controller/user'; get]
pub fn (mut app App) controller_get_user() vweb.Result {
// token := app.get_cookie('token') or { '' }
token := app.req.header.get_custom('token') or { '' }
if !auth_verify(token) {
app.set_status(401, '')
return app.text('Not valid token')
}
jwt_payload_stringify := base64.url_decode_str(token.split('.')[1])
jwt_payload := json.decode(JwtPayload, jwt_payload_stringify) or {
app.set_status(501, '')
return app.text('jwt decode error')
}
user_id := jwt_payload.sub
response := app.service_get_user(user_id.int()) or {
app.set_status(400, '')
return app.text('${err}')
}
return app.json(response)
}
['/controller/user/create'; post]
pub fn (mut app App) controller_create_user(username string, password string) vweb.Result {
if username == '' {
app.set_status(400, '')
return app.text('username cannot be empty')
}
if password == '' {
app.set_status(400, '')
return app.text('password cannot be empty')
}
app.service_add_user(username, password) or {
app.set_status(400, '')
return app.text('error: ${err}')
}
app.set_status(201, '')
return app.text('User created successfully')
}

View File

@ -0,0 +1,11 @@
module main
[table: 'users']
pub struct User {
mut:
id int [primary; sql: serial]
username string [required; sql_type: 'TEXT'; unique]
password string [required; sql_type: 'TEXT']
active bool
products []Product [fkey: 'user_id']
}

View File

@ -0,0 +1,65 @@
module main
import crypto.bcrypt
import databases
fn (mut app App) service_add_user(username string, password string) ! {
mut db := databases.create_db_connection()!
defer {
db.close() or { panic(err) }
}
hashed_password := bcrypt.generate_from_password(password.bytes(), bcrypt.min_cost) or {
eprintln(err)
return err
}
user_model := User{
username: username
password: hashed_password
active: true
}
mut insert_error := ''
sql db {
insert user_model into User
} or { insert_error = err.msg() }
if insert_error != '' {
return error(insert_error)
}
}
fn (mut app App) service_get_all_user() ?[]User {
mut db := databases.create_db_connection() or {
println(err)
return err
}
defer {
db.close() or { panic(err) }
}
results := sql db {
select from User
}
return results
}
fn (mut app App) service_get_user(id int) ?User {
mut db := databases.create_db_connection() or {
println(err)
return err
}
defer {
db.close() or { panic(err) }
}
results := sql db {
select from User where id == id
}
return results
}

View File

@ -0,0 +1,36 @@
module main
import json
import net.http
pub fn get_users(token string) ![]User {
mut header := http.new_header()
header.add_custom('token', token)!
url := 'http://localhost:8082/controller/users'
mut config := http.FetchConfig{
header: header
}
resp := http.fetch(http.FetchConfig{ ...config, url: url })!
users := json.decode([]User, resp.body)!
return users
}
pub fn get_user(token string) !User {
mut header := http.new_header()
header.add_custom('token', token)!
url := 'http://localhost:8082/controller/user'
mut config := http.FetchConfig{
header: header
}
resp := http.fetch(http.FetchConfig{ ...config, url: url })!
users := json.decode(User, resp.body)!
return users
}

View File

@ -0,0 +1,7 @@
Module {
name: 'vweb_fullstack'
description: ''
version: ''
license: ''
dependencies: []
}