Hello,
+@{mail.body}
+@{button}
+diff --git a/.github/workflows/hero_build_s3.yml b/.github/workflows/hero_build_linux.yml similarity index 54% rename from .github/workflows/hero_build_s3.yml rename to .github/workflows/hero_build_linux.yml index e3eaa69e..63187346 100644 --- a/.github/workflows/hero_build_s3.yml +++ b/.github/workflows/hero_build_linux.yml @@ -1,4 +1,4 @@ -name: Build Hero & Run tests +name: Build Hero on Linux & Run tests permissions: contents: write @@ -22,7 +22,7 @@ jobs: # os: macos-latest # short-name: macos-arm64 # - target: x86_64-apple-darwin - # os: macos-latest + # os: macos-13 # short-name: macos-i64 runs-on: ${{ matrix.os }} steps: @@ -47,30 +47,24 @@ jobs: ln -s $GITHUB_WORKSPACE/lib ~/.vmodules/freeflowuniverse/herolib echo "Installing secp256k1..." - if [ "${{ matrix.os }}" = "macos-latest" ]; then - brew install secp256k1 - elif [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - # Install build dependencies - sudo apt-get install -y build-essential wget autoconf libtool + # Install build dependencies + sudo apt-get install -y build-essential wget autoconf libtool - # Download and extract secp256k1 - cd /tmp - wget https://github.com/bitcoin-core/secp256k1/archive/refs/tags/v0.3.2.tar.gz - tar -xvf v0.3.2.tar.gz + # Download and extract secp256k1 + cd /tmp + wget https://github.com/bitcoin-core/secp256k1/archive/refs/tags/v0.3.2.tar.gz + tar -xvf v0.3.2.tar.gz - # Build and install - cd secp256k1-0.3.2/ - ./autogen.sh - ./configure - make -j 5 - sudo make install + # Build and install + cd secp256k1-0.3.2/ + ./autogen.sh + ./configure + make -j 5 + sudo make install + + # Cleanup + rm -rf secp256k1-0.3.2 v0.3.2.tar.gz - # Cleanup - rm -rf secp256k1-0.3.2 v0.3.2.tar.gz - else - echo "secp256k1 installation not implemented for ${OSNAME}" - exit 1 - fi echo "secp256k1 installation complete!" - name: Install and Start Redis @@ -82,32 +76,18 @@ jobs: # Install Redis sudo apt-get update sudo apt-get install -y redis + + # Start Redis + redis-server --daemonize yes + # Print versions redis-cli --version redis-server --version - # Start Redis - sudo systemctl start redis-server - redis-cli ping - name: Build Hero run: | - if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - v -cg -enable-globals -w -n cli/hero.v - # else if [ "${{ matrix.os }}" = "macos-latest" ]; then - # v -w -cg -gc none -no-retry-compilation -cc tcc -d use_openssl -enable-globals cli/hero.v - fi + v -cg -enable-globals -w -n cli/hero.v - name: Do all the basic tests run: | - alias vtest='v -stats -enable-globals -n -w -cg -gc none -no-retry-compilation -cc tcc test' ./test_basic.vsh - - # - name: Upload to S3 - # run: | - # echo 'export S3KEYID=${{ secrets.S3KEYID }}' > ${HOME}/mysecrets.sh - # echo 'export S3APPID=${{ secrets.S3APPID }}' >> ${HOME}/mysecrets.sh - # set -e && cat ${HOME}/mysecrets.sh - # sudo bash +x scripts/githubactions.sh - - - name: Extract tag name - run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV diff --git a/.github/workflows/hero_build_macos.yml b/.github/workflows/hero_build_macos.yml new file mode 100644 index 00000000..22474fed --- /dev/null +++ b/.github/workflows/hero_build_macos.yml @@ -0,0 +1,66 @@ +name: Build Hero on Macos & Run tests + +permissions: + contents: write + +on: + push: + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + short-name: macos-arm64 + - target: x86_64-apple-darwin + os: macos-13 + short-name: macos-i64 + runs-on: ${{ matrix.os }} + steps: + - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." + - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" + - run: echo "🔎 The name of your branch is ${{ github.ref_name }} and your repository is ${{ github.repository }}." + + - name: Check out repository code + uses: actions/checkout@v3 + + - name: Setup Vlang + run: | + git clone --depth=1 https://github.com/vlang/v + cd v + make + sudo ./v symlink + cd .. + + - name: Setup Herolib + run: | + mkdir -p ~/.vmodules/freeflowuniverse + ln -s $GITHUB_WORKSPACE/lib ~/.vmodules/freeflowuniverse/herolib + + echo "Installing secp256k1..." + brew install secp256k1 + + echo "secp256k1 installation complete!" + + - name: Install and Start Redis + run: | + brew update + brew install redis + + # Start Redis + redis-server --daemonize yes + + # Print versions + redis-cli --version + redis-server --version + + - name: Build Hero + run: | + v -w -cg -gc none -no-retry-compilation -d use_openssl -enable-globals cli/hero.v + + - name: Do all the basic tests + run: | + ./test_basic.vsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..210c8574 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + push: + tags: + - v* + +jobs: + upload: + strategy: + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + short-name: macos-arm64 + - target: x86_64-apple-darwin + os: macos-13 + short-name: macos-i64 + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + short-name: linux-i64 + + runs-on: ${{ matrix.os }} + permissions: + contents: write + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Setup Vlang + run: | + git clone --depth=1 https://github.com/vlang/v + cd v + make + sudo ./v symlink + cd .. + + - name: Setup Herolib + run: | + mkdir -p ~/.vmodules/freeflowuniverse + ln -s $GITHUB_WORKSPACE/lib ~/.vmodules/freeflowuniverse/herolib + + echo "Installing secp256k1..." + if [[ ${{ matrix.os }} == 'macos-latest' || ${{ matrix.os }} == 'macos-13' ]]; then + brew install secp256k1 + + elif [[ ${{ matrix.os }} == 'ubuntu-latest' ]]; then + # Install build dependencies + sudo apt-get install -y build-essential wget autoconf libtool + + # Download and extract secp256k1 + cd /tmp + wget https://github.com/bitcoin-core/secp256k1/archive/refs/tags/v0.3.2.tar.gz + tar -xvf v0.3.2.tar.gz + + # Build and install + cd secp256k1-0.3.2/ + ./autogen.sh + ./configure + make -j 5 + sudo make install + + else + echo "Unsupported OS: ${{ matrix.os }}" + exit 1 + fi + + echo "secp256k1 installation complete!" + + - name: Build Hero + run: | + v -w -cg -gc none -no-retry-compilation -d use_openssl -enable-globals cli/hero.v -o cli/hero-${{ matrix.target }} + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: hero-${{ matrix.target }} + path: cli/hero-${{ matrix.target }} + + release_hero: + needs: upload + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + # TODO: this adds commits that don't belong to this branhc, check another action + # - name: Generate changelog + # id: changelog + # uses: heinrichreimer/github-changelog-generator-action@v2.3 + # with: + # token: ${{ secrets.GITHUB_TOKEN }} + # headerLabel: "# 📑 Changelog" + # breakingLabel: "### 💥 Breaking" + # enhancementLabel: "### 🚀 Enhancements" + # bugsLabel: "### 🐛 Bug fixes" + # securityLabel: "### 🛡️ Security" + # issuesLabel: "### 📁 Other issues" + # prLabel: "### 📁 Other pull requests" + # addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]}}' + # onlyLastTag: true + # issues: false + # issuesWoLabels: false + # pullRequests: true + # prWoLabels: true + # author: true + # unreleased: true + # compareLink: true + # stripGeneratorNotice: true + # verbose: true + + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + path: cli/bins + merge-multiple: true + + - name: Release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + name: Release ${{ github.ref_name }} + draft: false + fail_on_unmatched_files: true + # body: ${{ steps.changelog.outputs.changelog }} + files: cli/bins/* diff --git a/examples/threefold/tfgrid3deployer/tfgrid3deployer_example.vsh b/examples/threefold/tfgrid3deployer/tfgrid3deployer_example.vsh index 272eaa3e..9bd099bc 100755 --- a/examples/threefold/tfgrid3deployer/tfgrid3deployer_example.vsh +++ b/examples/threefold/tfgrid3deployer/tfgrid3deployer_example.vsh @@ -6,90 +6,41 @@ import freeflowuniverse.herolib.ui.console import freeflowuniverse.herolib.installers.threefold.griddriver fn main() { - mut installer := griddriver.get()! - installer.install()! + griddriver.install()! - // v := tfgrid3deployer.get()! - // println('cred: ${v}') - // deployment_name := "my_deployment27" + v := tfgrid3deployer.get()! + println('cred: ${v}') + deployment_name := 'my_deployment27' - // // mut deployment := tfgrid3deployer.new_deployment(deployment_name)! - // mut deployment := tfgrid3deployer.get_deployment(deployment_name)! - // // deployment.add_machine(name: "my_vm1" cpu: 1 memory: 2 planetary: true mycelium: tfgrid3deployer.Mycelium{} nodes: [u32(11)]) - // // deployment.add_machine(name: "my_vm3" cpu: 1 memory: 2 planetary: true mycelium: tfgrid3deployer.Mycelium{} nodes: [u32(11)]) - // // deployment.add_machine(name: "my_vm3" cpu: 1 memory: 2 planetary: true mycelium: tfgrid3deployer.Mycelium{} nodes: [u32(28)]) - // // deployment.add_zdb(name: "my_zdb", password: "my_passw&rd", size: 2) - // // deployment.add_webname(name: 'mywebname2', backend: 'http://37.27.132.47:8000') - // // deployment.deploy()! + mut deployment := tfgrid3deployer.new_deployment(deployment_name)! + // mut deployment := tfgrid3deployer.get_deployment(deployment_name)! + deployment.add_machine( + name: 'my_vm1' + cpu: 1 + memory: 2 + planetary: false + public_ip4: true + mycelium: tfgrid3deployer.Mycelium{} + nodes: [u32(167)] + ) + deployment.add_machine( + name: 'my_vm2' + cpu: 1 + memory: 2 + planetary: false + public_ip4: true + mycelium: tfgrid3deployer.Mycelium{} + // nodes: [u32(164)] + ) - // // deployment.add_machine(name: "my_vm2" cpu: 2 memory: 3 planetary: true mycelium: true nodes: [u32(28)]) - // // deployment.deploy()! + deployment.add_zdb(name: 'my_zdb', password: 'my_passw&rd', size: 2) + deployment.add_webname(name: 'mywebname2', backend: 'http://37.27.132.47:8000') + deployment.deploy()! - // deployment.remove_machine("my_vm1")! - // // deployment.remove_webname("mywebname2")! - // // deployment.remove_zdb("my_zdb")! - // // deployment.deploy()! + deployment.remove_machine('my_vm1')! + deployment.remove_webname('mywebname2')! + deployment.remove_zdb('my_zdb')! + deployment.deploy()! - // // deployment.vm_get("my_vm1")! - - // // // deployment.remove_machine(name: "my_vm121") - // // // deployment.update_machine(name: "my_vm121") - // println("deployment: ${deployment}") - - // If not sent: The client will create a network for the deployment. - // deployment.network = NetworkSpecs{ - // name: 'hamadanetcafe' - // ip_range: '10.10.0.0/16' - // } - - // deployment.add_machine(name: "my_vm121" cpu: 1 memory: 2 planetary: true mycelium: true nodes: [u32(11)]) - // deployment.add_zdb(name: "my_zdb", password: "my_passw&rd", size: 2) - // deployment.add_webname(name: 'mywebname2', backend: 'http://37.27.132.47:8000') - // deployment.add_machine(name: "my_vm1" cpu: 1 memory: 2 planetary: true mycelium: true nodes: [u32(28)]) - // deployment.deploy()! - - // vm1 := deployment.vm_get("my_vm1")! - // reachable := vm1.healthcheck()! - // println("vm reachable: ${reachable}") - - // if !reachable { - // deployment.vm_delete()! - // deployment.vm_deploy()! - // } - - // if !rach { - // vm1.delete()! - // vm1.deploy()! - // } - - /* - TODO: Agreed on - - # Deploying a new deployemnt - mut deployment := tfgrid3deployer.new_deployment(deployment_name)! - deployment.add_machine(name: "my_vm121" cpu: 1 memory: 2 planetary: true mycelium: true nodes: [u32(11)]) - deployment.add_zdb(name: "my_zdb", password: "my_passw&rd", size: 2) - deployment.deploy()! - - # if the user wants to load the deployment and do some actions on it: - mut deployment := tfgrid3deployer.get_deployment(deployment_name)! - deployment.add_webname(name: 'mywebname2', backend: 'http://37.27.132.47:8000') - deployment.add_machine(name: "my_vm1" cpu: 1 memory: 2 planetary: true mycelium: true nodes: [u32(28)]) - deployment.deploy()! - - # The user wants to delete the recently deployed machine - mut deployment := tfgrid3deployer.get_deployment(deployment_name)! - deployment.remove_machine(name: "my_vm1") - deployment.deploy()! - - # The user wants to update the first deployed machine - mut deployment := tfgrid3deployer.get_deployment(deployment_name)! - deployment.remove_machine(name: "my_vm1") - deployment.add_machine(name: "my_vm1" cpu: 1 memory: 2 planetary: true mycelium: true nodes: [u32(28)]) - deployment.deploy()! - ## PS: The same goes with ZDBs and Webnames - - # How deploy works: - 1. Let's assume the user wants to add one more workload - */ + tfgrid3deployer.delete_deployment(deployment_name)! } diff --git a/examples/threefold/tfgrid3deployer/vm_gw_caddy/delete.vsh b/examples/threefold/tfgrid3deployer/vm_gw_caddy/delete.vsh index 1ff3ae23..24d6e759 100755 --- a/examples/threefold/tfgrid3deployer/vm_gw_caddy/delete.vsh +++ b/examples/threefold/tfgrid3deployer/vm_gw_caddy/delete.vsh @@ -12,13 +12,4 @@ v := tfgrid3deployer.get()! println('cred: ${v}') deployment_name := 'vm_caddy1' -mut deployment := tfgrid3deployer.get_deployment(deployment_name)! -deployment.remove_machine('vm_caddy1')! -deployment.deploy()! -os.rm('${os.home_dir()}/hero/db/0/session_deployer/${deployment_name}')! - -deployment_name2 := 'vm_caddy_gw' -mut deployment2 := tfgrid3deployer.get_deployment(deployment_name2)! -deployment2.remove_webname('gwnamecaddy')! -deployment2.deploy()! -os.rm('${os.home_dir()}/hero/db/0/session_deployer/${deployment_name2}')! +tfgrid3deployer.delete_deployment(deployment_name)! diff --git a/examples/threefold/tfgrid3deployer/vm_gw_caddy/vm_gw_caddy.vsh b/examples/threefold/tfgrid3deployer/vm_gw_caddy/vm_gw_caddy.vsh index ca32512a..b499d1dc 100755 --- a/examples/threefold/tfgrid3deployer/vm_gw_caddy/vm_gw_caddy.vsh +++ b/examples/threefold/tfgrid3deployer/vm_gw_caddy/vm_gw_caddy.vsh @@ -29,12 +29,9 @@ println('vm1 info: ${vm1}') vm1_public_ip4 := vm1.public_ip4.all_before('/') -deployment_name2 := 'vm_caddy_gw' -mut deployment2 := tfgrid3deployer.new_deployment(deployment_name2)! -deployment2.add_webname(name: 'gwnamecaddy', backend: 'http://${vm1_public_ip4}:80') -deployment2.deploy()! - -gw1 := deployment2.webname_get('gwnamecaddy')! +deployment.add_webname(name: 'gwnamecaddy', backend: 'http://${vm1_public_ip4}:80') +deployment.deploy()! +gw1 := deployment.webname_get('gwnamecaddy')! println('gw info: ${gw1}') // Retry logic to wait for the SSH server to be up diff --git a/lib/clients/livekit/.heroscript b/lib/clients/livekit/.heroscript new file mode 100644 index 00000000..8fb1278c --- /dev/null +++ b/lib/clients/livekit/.heroscript @@ -0,0 +1,7 @@ + +!!hero_code.generate_client + name:'livekit' + classname:'LivekitClient' + singleton:0 + default:1 + reset:0 \ No newline at end of file diff --git a/lib/clients/livekit/client.v b/lib/clients/livekit/client.v new file mode 100644 index 00000000..44e16c98 --- /dev/null +++ b/lib/clients/livekit/client.v @@ -0,0 +1,9 @@ +module livekit + +// App struct with `livekit.Client`, API keys, and other shared data +pub struct Client { +pub: + url string @[required] + api_key string @[required] + api_secret string @[required] +} diff --git a/lib/clients/livekit/factory.v b/lib/clients/livekit/factory.v new file mode 100644 index 00000000..033eb2f9 --- /dev/null +++ b/lib/clients/livekit/factory.v @@ -0,0 +1,6 @@ + +module livekit + +pub fn new(client Client) Client { + return Client{...client} +} \ No newline at end of file diff --git a/lib/clients/livekit/readme.md b/lib/clients/livekit/readme.md new file mode 100644 index 00000000..e8500dcb --- /dev/null +++ b/lib/clients/livekit/readme.md @@ -0,0 +1,25 @@ +# livekit + +To get started + +```vlang + +import freeflowuniverse.herolib.clients.livekit + +mut client:= livekit.get()! + +client... + +``` + +## example heroscript + + +```hero +!!livekit.configure + livekit_url:'' + livekit_api_key:'' + livekit_api_secret:'' +``` + + diff --git a/lib/clients/livekit/room.v b/lib/clients/livekit/room.v new file mode 100644 index 00000000..cce17e32 --- /dev/null +++ b/lib/clients/livekit/room.v @@ -0,0 +1,51 @@ +module livekit + +import net.http +import json + +@[params] +pub struct ListRoomsParams { + names []string +} + +pub struct ListRoomsResponse { +pub: + rooms []Room +} + +pub fn (c Client) list_rooms(params ListRoomsParams) !ListRoomsResponse { + // Prepare request body + request := params + request_json := json.encode(request) + + + // create token and give grant to list rooms + mut token := c.new_access_token()! + token.grants.video.room_list = true + + // make POST request + url := '${c.url}/twirp/livekit.RoomService/ListRooms' + // Configure HTTP request + mut headers := http.new_header_from_map({ + http.CommonHeader.authorization: 'Bearer ${token.to_jwt()!}', + http.CommonHeader.content_type: 'application/json' + }) + + response := http.fetch(http.FetchConfig{ + url: url + method: .post + header: headers + data: request_json + })! + + if response.status_code != 200 { + return error('Failed to list rooms: $response.status_code') + } + + // Parse response + rooms_response := json.decode(ListRoomsResponse, response.body) or { + return error('Failed to parse response: $err') + } + + return rooms_response +} diff --git a/lib/clients/livekit/room_model.v b/lib/clients/livekit/room_model.v new file mode 100644 index 00000000..119c9a67 --- /dev/null +++ b/lib/clients/livekit/room_model.v @@ -0,0 +1,33 @@ +module livekit + +import net.http +import json + +pub struct Codec { +pub: + fmtp_line string + mime string +} + +pub struct Version { +pub: + ticks u64 + unix_micro string +} + +pub struct Room { +pub: + active_recording bool + creation_time string + departure_timeout int + empty_timeout int + enabled_codecs []Codec + max_participants int + metadata string + name string + num_participants int + num_publishers int + sid string + turn_password string + version Version +} \ No newline at end of file diff --git a/lib/clients/livekit/room_test.v b/lib/clients/livekit/room_test.v new file mode 100644 index 00000000..0e19e3e7 --- /dev/null +++ b/lib/clients/livekit/room_test.v @@ -0,0 +1,21 @@ +module livekit + +import os +import freeflowuniverse.herolib.osal + +fn testsuite_begin() ! { + osal.load_env_file('${os.dir(@FILE)}/.env')! +} + +fn new_test_client() Client { + return new( + url: os.getenv('LIVEKIT_URL') + api_key: os.getenv('LIVEKIT_API_KEY') + api_secret: os.getenv('LIVEKIT_API_SECRET') + ) +} + +fn test_client_list_rooms() ! { + client := new_test_client() + rooms := client.list_rooms()! +} diff --git a/lib/clients/livekit/token.v b/lib/clients/livekit/token.v new file mode 100644 index 00000000..a1cdd881 --- /dev/null +++ b/lib/clients/livekit/token.v @@ -0,0 +1,34 @@ +module livekit + +import time +import rand +import crypto.hmac +import crypto.sha256 +import encoding.base64 +import json + +// Define AccessTokenOptions struct +@[params] +pub struct AccessTokenOptions { + pub mut: + ttl int = 21600// TTL in seconds + name string // Display name for the participant + identity string // Identity of the user + metadata string // Custom metadata to be passed to participants +} + +// Constructor for AccessToken +pub fn (client Client) new_access_token(options AccessTokenOptions) !AccessToken { + return AccessToken{ + api_key: client.api_key + api_secret: client.api_secret + identity: options.identity + ttl: options.ttl + grants: ClaimGrants{ + exp: time.now().unix()+ options.ttl + iss: client.api_key + sub: options.name + name: options.name + } + } +} \ No newline at end of file diff --git a/lib/clients/livekit/token_model.v b/lib/clients/livekit/token_model.v new file mode 100644 index 00000000..84a6b352 --- /dev/null +++ b/lib/clients/livekit/token_model.v @@ -0,0 +1,76 @@ +module livekit + +import time +import rand +import crypto.hmac +import crypto.sha256 +import encoding.base64 +import json + +// Struct representing grants +pub struct ClaimGrants { +pub mut: + video VideoGrant + iss string + exp i64 + nbf int + sub string + name string +} + +// VideoGrant struct placeholder +pub struct VideoGrant { +pub mut: + room string + room_join bool @[json: 'roomJoin'] + room_list bool @[json: 'roomList'] + can_publish bool @[json: 'canPublish'] + can_publish_data bool @[json: 'canPublishData'] + can_subscribe bool @[json: 'canSubscribe'] +} + +// SIPGrant struct placeholder +struct SIPGrant {} + +// AccessToken class +pub struct AccessToken { + mut: + api_key string + api_secret string + grants ClaimGrants + identity string + ttl int +} + +// Method to add a video grant to the token +pub fn (mut token AccessToken) add_video_grant(grant VideoGrant) { + token.grants.video = grant +} + +// Method to generate a JWT token +pub fn (token AccessToken) to_jwt() !string { + // Create JWT payload + payload := json.encode(token.grants) + + println('payload: ${payload}') + + // Create JWT header + header := '{"alg":"HS256","typ":"JWT"}' + + // Encode header and payload in base64 + header_encoded := base64.url_encode_str(header) + payload_encoded := base64.url_encode_str(payload) + + // Create the unsigned token + unsigned_token := '${header_encoded}.${payload_encoded}' + + // Create the HMAC-SHA256 signature + signature := hmac.new(token.api_secret.bytes(), unsigned_token.bytes(), sha256.sum, sha256.block_size) + + // Encode the signature in base64 + signature_encoded := base64.url_encode(signature) + + // Create the final JWT + jwt := '${unsigned_token}.${signature_encoded}' + return jwt +} \ No newline at end of file diff --git a/lib/clients/mailclient/mailclient_factory.v b/lib/clients/mailclient/mailclient_factory.v index 61f6b4a2..258b0c58 100644 --- a/lib/clients/mailclient/mailclient_factory.v +++ b/lib/clients/mailclient/mailclient_factory.v @@ -1,6 +1,6 @@ module mailclient -// import freeflowuniverse.herolib.core.base +import freeflowuniverse.herolib.core.base // import freeflowuniverse.herolib.core.playbook // __global ( @@ -45,11 +45,11 @@ module mailclient // mailclient_default = name // } -// fn config_exists(args_ ArgsGet) bool { -// mut args := args_get(args_) -// mut context := base.context() or { panic('bug') } -// return context.hero_config_exists('mailclient', args.name) -// } +fn config_exists(args_ ArgsGet) bool { + mut args := args_get(args_) + mut context := base.context() or { panic('bug') } + return context.hero_config_exists('mailclient', args.name) +} // fn config_load(args_ ArgsGet) ! { // mut args := args_get(args_) diff --git a/lib/clients/mailclient/mailclient_factory_.v b/lib/clients/mailclient/mailclient_factory_.v index b53b5f65..489a45fd 100644 --- a/lib/clients/mailclient/mailclient_factory_.v +++ b/lib/clients/mailclient/mailclient_factory_.v @@ -36,7 +36,7 @@ pub fn get(args_ ArgsGet) !&MailClient { if !config_exists(args) { if default { mut context := base.context() or { panic('bug') } - context.hero_config_set('mailclient', model.name, heroscript_default()!)! + context.hero_config_set('mailclient', args.name, heroscript_default())! } } load(args)! @@ -44,7 +44,7 @@ pub fn get(args_ ArgsGet) !&MailClient { } return mailclient_global[args.name] or { println(mailclient_global) - panic('could not get config for ${args.name} with name:${model.name}') + panic('could not get config for ${args.name} with name:${args.name}') } } @@ -70,12 +70,12 @@ pub fn load(args_ ArgsGet) ! { play(heroscript: heroscript)! } -// save the config to the filesystem in the context -pub fn save(o MailClient) ! { - mut context := base.context()! - heroscript := encoderhero.encode[MailClient](o)! - context.hero_config_set('mailclient', model.name, heroscript)! -} +// // save the config to the filesystem in the context +// pub fn save(o MailClient) ! { +// mut context := base.context()! +// heroscript := encoderhero.encode[MailClient](o)! +// context.hero_config_set('mailclient', model.name, heroscript)! +// } @[params] pub struct PlayArgs { @@ -89,7 +89,7 @@ pub fn play(args_ PlayArgs) ! { mut model := args_ if model.heroscript == '' { - model.heroscript = heroscript_default()! + model.heroscript = heroscript_default() } mut plbook := model.plbook or { playbook.new(text: model.heroscript)! } @@ -97,10 +97,7 @@ pub fn play(args_ PlayArgs) ! { if configure_actions.len > 0 { for config_action in configure_actions { mut p := config_action.params - mycfg := cfg_play(p)! - console.print_debug('install action mailclient.configure\n${mycfg}') - set(mycfg)! - save(mycfg)! + cfg_play(p)! } } } diff --git a/lib/core/generator/generic/readme.md b/lib/core/generator/generic/readme.md index d152a19d..a458cbf3 100644 --- a/lib/core/generator/generic/readme.md +++ b/lib/core/generator/generic/readme.md @@ -73,7 +73,7 @@ to call in code import freeflowuniverse.herolib.core.generator.generic -generic.scan(path:"~/code/github/freeflowuniverse/crystallib/crystallib/installers",force:true)! +generic.scan(path:"~/code/github/freeflowuniverse/herolib/herolib/installers",force:true)! ``` @@ -81,6 +81,6 @@ generic.scan(path:"~/code/github/freeflowuniverse/crystallib/crystallib/installe to run from bash ```bash -~/code/github/freeflowuniverse/crystallib/scripts/fix_installers.vsh +~/code/github/freeflowuniverse/herolib/scripts/fix_installers.vsh ``` diff --git a/lib/core/generator/generic/templates/objname_factory_.vtemplate b/lib/core/generator/generic/templates/objname_factory_.vtemplate index e8ddc0ea..38c1684a 100644 --- a/lib/core/generator/generic/templates/objname_factory_.vtemplate +++ b/lib/core/generator/generic/templates/objname_factory_.vtemplate @@ -116,7 +116,6 @@ pub fn play(args_ PlayArgs) ! { for install_action in install_actions { mut p := install_action.params cfg_play(p)! - console.print_debug("install action ${args.name}.configure\n??{mycfg}") } } @end diff --git a/lib/core/herocmds/bootstrap.v b/lib/core/herocmds/bootstrap.v index 72ad5ff9..e2c720b6 100644 --- a/lib/core/herocmds/bootstrap.v +++ b/lib/core/herocmds/bootstrap.v @@ -103,17 +103,17 @@ fn cmd_bootstrap_execute(cmd Command) ! { } if compileupload { // mycmd:=' - // \${HOME}/code/github/freeflowuniverse/crystallib/scripts/package.vsh + // \${HOME}/code/github/freeflowuniverse/herolib/scripts/package.vsh // ' // osal.exec(cmd: mycmd)! - println('please execute:\n~/code/github/freeflowuniverse/crystallib/scripts/githubactions.sh') + println('please execute:\n~/code/github/freeflowuniverse/herolib/scripts/githubactions.sh') } if update { // mycmd:=' - // \${HOME}/code/github/freeflowuniverse/crystallib/scripts/package.vsh + // \${HOME}/code/github/freeflowuniverse/herolib/scripts/package.vsh // ' // osal.exec(cmd: mycmd)! - println('please execute:\n~/code/github/freeflowuniverse/crystallib/scripts/install_hero.sh') + println('please execute:\n~/code/github/freeflowuniverse/herolib/scripts/install_hero.sh') } } diff --git a/lib/core/herocmds/init.v b/lib/core/herocmds/init.v index cd10c119..8deeadfb 100644 --- a/lib/core/herocmds/init.v +++ b/lib/core/herocmds/init.v @@ -13,8 +13,8 @@ pub fn cmd_init(mut cmdroot Command) { Initialization Helpers for Hero -r will reset everything e.g. done states (when installing something) --d will put the platform in development mode, get V, crystallib, hero... --c will compile hero on local platform (requires local crystallib) +-d will put the platform in development mode, get V, herolib, hero... +-c will compile hero on local platform (requires local herolib) ' description: 'initialize hero environment (reset, development mode, )' @@ -58,7 +58,7 @@ Initialization Helpers for Hero required: false name: 'gitpull' abbrev: 'gp' - description: 'will try to pull git repos for crystallib.' + description: 'will try to pull git repos for herolib.' }) cmd_run.add_flag(Flag{ diff --git a/lib/core/herocmds/installers.v b/lib/core/herocmds/installers.v index 7821ddfa..b7fa417e 100644 --- a/lib/core/herocmds/installers.v +++ b/lib/core/herocmds/installers.v @@ -40,7 +40,7 @@ pub fn cmd_installers(mut cmdroot Command) { required: false name: 'gitpull' abbrev: 'gp' - description: 'e.g. in crystallib or other git repo pull changes.' + description: 'e.g. in herolib or other git repo pull changes.' }) cmd_run.add_flag(Flag{ @@ -48,7 +48,7 @@ pub fn cmd_installers(mut cmdroot Command) { required: false name: 'gitreset' abbrev: 'gr' - description: 'e.g. in crystallib or other git repo pull & reset changes.' + description: 'e.g. in herolib or other git repo pull & reset changes.' }) cmdroot.add_command(cmd_run) } diff --git a/lib/core/log/backend_db.v b/lib/core/log/backend_db.v new file mode 100644 index 00000000..2417ae8d --- /dev/null +++ b/lib/core/log/backend_db.v @@ -0,0 +1,25 @@ +module log + +import db.sqlite + +pub struct DBBackend { +pub: + db sqlite.DB +} + +@[params] +pub struct DBBackendConfig { +pub: + db sqlite.DB +} + +// factory for +pub fn new_backend(config DBBackendConfig) !DBBackend { + sql config.db { + create table Log + } or { panic(err) } + + return DBBackend{ + db: config.db + } +} \ No newline at end of file diff --git a/lib/core/log/events.v b/lib/core/log/events.v new file mode 100644 index 00000000..555f35f0 --- /dev/null +++ b/lib/core/log/events.v @@ -0,0 +1,10 @@ +module log + +import time + +@[params] +pub struct ViewEvent { +pub mut: + page string + duration time.Duration +} \ No newline at end of file diff --git a/lib/core/log/factory.v b/lib/core/log/factory.v new file mode 100644 index 00000000..3547d0f2 --- /dev/null +++ b/lib/core/log/factory.v @@ -0,0 +1,18 @@ +module log + +import db.sqlite + +pub struct Logger { + db_path string + // DBBackend +} + +pub fn new(db_path string) !Logger { + db := sqlite.connect(db_path)! + sql db { + create table Log + } or { panic(err) } + return Logger{ + db_path: db_path + } +} diff --git a/lib/core/log/logger.v b/lib/core/log/logger.v new file mode 100644 index 00000000..a91e82c4 --- /dev/null +++ b/lib/core/log/logger.v @@ -0,0 +1,55 @@ +module log + +import db.sqlite + +pub fn (logger Logger) new_log(log Log) ! { + db := sqlite.connect(logger.db_path)! + + sql db { + insert log into Log + }! +} + +pub struct LogFilter { + Log + matches_all bool + limit int +} + +pub fn (logger Logger) filter_logs(filter LogFilter) ![]Log { + db := sqlite.connect(logger.db_path)! + mut select_stmt := 'select * from Log' + + mut matchers := []string{} + if filter.event != '' { + matchers << "event == '${filter.event}'" + } + + if filter.subject != '' { + matchers << "subject == '${filter.subject}'" + } + + if filter.object != '' { + matchers << "object == '${filter.object}'" + } + + if matchers.len > 0 { + matchers_str := if filter.matches_all { + matchers.join(' AND ') + } else { + matchers.join(' OR ') + } + select_stmt += ' where ${matchers_str}' + } + + responses := db.exec(select_stmt)! + + mut logs := []Log{} + for response in responses { + logs << sql db { + select from Log where id == response.vals[0].int() + }! + } + + return logs +} \ No newline at end of file diff --git a/lib/core/log/model.v b/lib/core/log/model.v new file mode 100644 index 00000000..852b2021 --- /dev/null +++ b/lib/core/log/model.v @@ -0,0 +1,32 @@ +module log + +import time + +pub struct Log { + id int @[primary; sql: serial] +pub: + timestamp time.Time +pub mut: + event string + subject string + object string + message string // a custom message that can be attached to a log +} + +// pub struct Event { +// name string +// description string +// } + +// // log_request logs http requests +// pub fn create_log(log Log) Log { +// return Log{ +// ...log +// timestamp: time.now() +// }) +// } + +// // log_request logs http requests +// pub fn (mut a Analyzer) get_logs(subject string) []Log { +// return []Log{} +// } diff --git a/lib/core/playbook/action.v b/lib/core/playbook/action.v index f6498139..d9fbe347 100644 --- a/lib/core/playbook/action.v +++ b/lib/core/playbook/action.v @@ -43,12 +43,15 @@ pub fn (action Action) heroscript() string { if action.comments.len > 0 { out += texttools.indent(action.comments, '// ') } - if action.actiontype == .sal { + + if action.actiontype == .dal { + out += '!' + } else if action.actiontype == .sal { out += '!!' } else if action.actiontype == .macro { out += '!!!' } else { - panic('only action sal and macro supported for now,\n${action}') + panic('only action sal and macro supported for now') } if action.actor != '' { diff --git a/lib/data/dbfs/db.v b/lib/data/dbfs/db.v index b99df8d0..7237236b 100644 --- a/lib/data/dbfs/db.v +++ b/lib/data/dbfs/db.v @@ -146,7 +146,7 @@ pub fn (mut db DB) set(args_ SetArgs) !u32 { args.id = db.parent.incr()! pathsrc = db.path_get(args.id)! } - console.print_debug('keydb ${pathsrc}') + if db.config.encrypted { args.valueb = aes_symmetric.encrypt(args.valueb, db.secret()!) pathsrc.write(base64.encode(args.valueb))! diff --git a/lib/data/doctree/collection/testdata/export_test/export_expected/src/col1/errors.md b/lib/data/doctree/collection/testdata/export_test/export_expected/src/col1/errors.md index 802abedf..c3ef7877 100644 --- a/lib/data/doctree/collection/testdata/export_test/export_expected/src/col1/errors.md +++ b/lib/data/doctree/collection/testdata/export_test/export_expected/src/col1/errors.md @@ -3,7 +3,7 @@ ## page_not_found -path: /Users/timurgordon/code/github/freeflowuniverse/crystallib/crystallib/data/doctree/collection/testdata/export_test/mytree/dir1/dir2/file1.md +path: /Users/timurgordon/code/github/freeflowuniverse/herolib/herolib/data/doctree/collection/testdata/export_test/mytree/dir1/dir2/file1.md msg: page col3:file5.md not found diff --git a/lib/data/doctree/testdata/export_test/export_expected/col1/.collection b/lib/data/doctree/testdata/export_test/export_expected/col1/.collection index 872927d0..3413597b 100644 --- a/lib/data/doctree/testdata/export_test/export_expected/col1/.collection +++ b/lib/data/doctree/testdata/export_test/export_expected/col1/.collection @@ -1 +1 @@ -name:col1 src:'/Users/timurgordon/code/github/freeflowuniverse/crystallib/crystallib/data/doctree/testdata/export_test/mytree/dir1' \ No newline at end of file +name:col1 src:'/Users/timurgordon/code/github/freeflowuniverse/herolib/herolib/data/doctree/testdata/export_test/mytree/dir1' \ No newline at end of file diff --git a/lib/data/doctree/testdata/export_test/export_expected/col1/errors.md b/lib/data/doctree/testdata/export_test/export_expected/col1/errors.md index 1716bdb6..7f6a8cd6 100644 --- a/lib/data/doctree/testdata/export_test/export_expected/col1/errors.md +++ b/lib/data/doctree/testdata/export_test/export_expected/col1/errors.md @@ -3,7 +3,7 @@ ## page_not_found -path: /Users/timurgordon/code/github/freeflowuniverse/crystallib/crystallib/data/doctree/testdata/export_test/mytree/dir1/dir2/file1.md +path: /Users/timurgordon/code/github/freeflowuniverse/herolib/herolib/data/doctree/testdata/export_test/mytree/dir1/dir2/file1.md msg: page col3:file5.md not found diff --git a/lib/data/doctree/testdata/export_test/export_expected/col2/.collection b/lib/data/doctree/testdata/export_test/export_expected/col2/.collection index 27908d9b..24a5ad9d 100644 --- a/lib/data/doctree/testdata/export_test/export_expected/col2/.collection +++ b/lib/data/doctree/testdata/export_test/export_expected/col2/.collection @@ -1 +1 @@ -name:col2 src:'/Users/timurgordon/code/github/freeflowuniverse/crystallib/crystallib/data/doctree/testdata/export_test/mytree/dir3' \ No newline at end of file +name:col2 src:'/Users/timurgordon/code/github/freeflowuniverse/herolib/herolib/data/doctree/testdata/export_test/mytree/dir3' \ No newline at end of file diff --git a/lib/develop/gittools/gitlocation.v b/lib/develop/gittools/gitlocation.v index 3262c66f..f0be9fc0 100644 --- a/lib/develop/gittools/gitlocation.v +++ b/lib/develop/gittools/gitlocation.v @@ -102,7 +102,7 @@ pub fn (mut gs GitStructure) gitlocation_from_url(url string) !GitLocation { } } -// Return a crystallib path object on the filesystem pointing to the locator +// Return a herolib path object on the filesystem pointing to the locator pub fn (mut l GitLocation) patho() !pathlib.Path { mut addrpath := pathlib.get_dir(path: '${l.provider}/${l.account}/${l.name}', create: false)! if l.path.len > 0 { diff --git a/lib/installers/lang/herolib/templates/hero.sh b/lib/installers/lang/herolib/templates/hero.sh index 887b28b5..74e04e20 100644 --- a/lib/installers/lang/herolib/templates/hero.sh +++ b/lib/installers/lang/herolib/templates/hero.sh @@ -1,7 +1,7 @@ export PATH=${home_dir}/hero/bin:??PATH export TERM=xterm -cd ${home_dir}/code/github/freeflowuniverse/crystallib/cli/hero +cd ${home_dir}/code/github/freeflowuniverse/herolib/cli/hero PRF="${home_dir}/.profile" [ -f "??PRF" ] && source "??PRF" diff --git a/lib/lang/python/readme.md b/lib/lang/python/readme.md index 3a3369a4..444ecb98 100644 --- a/lib/lang/python/readme.md +++ b/lib/lang/python/readme.md @@ -85,7 +85,7 @@ print("==RESULT==") print(json_string) ``` -> see `crystallib/examples/lang/python/pythonexample.vsh` +> see `herolib/examples/lang/python/pythonexample.vsh` ## remark diff --git a/lib/security/authentication/README.md b/lib/security/authentication/README.md new file mode 100644 index 00000000..2baab162 --- /dev/null +++ b/lib/security/authentication/README.md @@ -0,0 +1,9 @@ +# Email authentication module + +Module to verify user email by sending the user a link.The functions in the module can be implemented manually in a web server, but the recommended way is simply to use the API. + +## API + +## Examples + +- see publisher/view/auth_controllers diff --git a/lib/security/authentication/authenticator.v b/lib/security/authentication/authenticator.v new file mode 100644 index 00000000..f6f4335b --- /dev/null +++ b/lib/security/authentication/authenticator.v @@ -0,0 +1,245 @@ +module authentication + +import time +import net.smtp +import crypto.hmac +import crypto.sha256 +import crypto.rand +import encoding.hex +import encoding.base64 +import log + +// Creates and updates, authenticates email authentication sessions +@[noinit] +pub struct Authenticator { + secret string +mut: + config SmtpConfig @[required] + backend IBackend // Backend for authenticator +} + +// Is initialized when an auth link is sent +// Represents the state of the authentication session +pub struct AuthSession { +pub mut: + email string + timeout time.Time + auth_code string // hex representation of 64 bytes + attempts_left int = 3 + authenticated bool +} + +@[params] +pub struct AuthenticatorConfig { + secret string + smtp SmtpConfig + backend IBackend +} + +pub fn new(config AuthenticatorConfig) !Authenticator { + // send email with link in body + // mut client := smtp.new_client( + // server: config.smtp.server + // from: config.smtp.from + // port: config.smtp.port + // username: config.smtp.username + // password: config.smtp.password + // )! + + return Authenticator{ + config: config.smtp + // client: smtp.new_client( + // server: config.smtp.server + // from: config.smtp.from + // port: config.smtp.port + // username: config.smtp.username + // password: config.smtp.password + // )! + backend: config.backend + secret: config.secret + } +} + +@[params] +pub struct SendMailConfig { + email string + mail VerificationMail + link string +} + +pub struct VerificationMail { +pub: + from string = 'email_authenticator@spiderlib.ff' + subject string = 'Verify your email' + body string = 'Please verify your email by clicking the link below' +} + +pub struct SmtpConfig { + server string + from string + port int + username string + password string +} + +pub fn (mut auth Authenticator) email_authentication(config SendMailConfig) ! { + auth.send_verification_mail(config)! + auth.await_authentication(email: config.email)! +} + +// sends mail with verification link +pub fn (mut auth Authenticator) send_verification_mail(config SendMailConfig) ! { + // create auth session + auth_code := rand.bytes(64) or { panic(err) } + auth.backend.create_auth_session( + email: config.email + auth_code: auth_code.hex() + timeout: time.now().add_seconds(180) + )! + + link := 'Click to authenticate' + mail := smtp.Mail{ + to: config.email + from: config.mail.from + subject: config.mail.subject + body_type: .html + body: '${config.mail.body}\n${link}' + } + + mut client := smtp.new_client( + server: auth.config.server + from: auth.config.from + port: auth.config.port + username: auth.config.username + password: auth.config.password + )! + + client.send(mail) or { return error('Error resolving email address') } + client.quit() or { return error('Could not close connection to server') } +} + +// sends mail with login link +pub fn (mut auth Authenticator) send_login_link(config SendMailConfig) ! { + expiration := time.now().add(5 * time.minute) + data := '${config.email}.${expiration}' // data to be signed + signature := hmac.new(hex.decode(auth.secret) or { panic(err) }, data.bytes(), sha256.sum, + sha256.block_size) + + encoded_signature := base64.url_encode(signature.bytestr().bytes()) + link := 'Click to login' + mail := smtp.Mail{ + to: config.email + from: config.mail.from + subject: config.mail.subject + body_type: .html + body: '${config.mail.body}\n${link}' + } + + mut client := smtp.new_client( + server: auth.config.server + from: auth.config.from + port: auth.config.port + username: auth.config.username + password: auth.config.password + )! + + client.send(mail) or { panic('Error resolving email address') } + client.quit() or { panic('Could not close connection to server') } +} + +pub struct LoginAttempt { +pub: + email string + expiration time.Time + signature string +} + +// sends mail with login link +pub fn (mut auth Authenticator) authenticate_login_attempt(attempt LoginAttempt) ! { + if time.now() > attempt.expiration { + return error('link expired') + } + + data := '${attempt.email}.${attempt.expiration}' // data to be signed + signature_mirror := hmac.new(hex.decode(auth.secret) or { panic(err) }, data.bytes(), + sha256.sum, sha256.block_size).bytestr().bytes() + + decoded_signature := base64.url_decode(attempt.signature) + + if !hmac.equal(decoded_signature, signature_mirror) { + return error('signature mismatch') + } +} + +// result of an authentication attempt +// returns time and attempts remaining +pub struct AttemptResult { +pub: + authenticated bool + attempts_left int + time_left time.Time +} + +enum AuthErrorReason { + cypher_mismatch + no_remaining_attempts + session_not_found +} + +struct AuthError { + Error + reason AuthErrorReason +} + +// authenticates if email/cypher combo correct within timeout and remaining attemts +// TODO: address based request limits recognition to prevent brute +// TODO: max allowed request per seccond to prevent dos +pub fn (mut auth Authenticator) authenticate(email string, cypher string) ! { + session := auth.backend.read_auth_session(email) or { + return AuthError{ + reason: .session_not_found + } + } + if session.attempts_left <= 0 { // checks if remaining attempts + return AuthError{ + reason: .no_remaining_attempts + } + } + + // authenticates if cypher in link matches authcode + if cypher == session.auth_code { + auth.backend.set_session_authenticated(email) or { panic(err) } + } else { + updated_session := AuthSession{ + ...session + attempts_left: session.attempts_left - 1 + } + auth.backend.update_auth_session(updated_session)! + return AuthError{ + reason: .cypher_mismatch + } + } +} + +pub struct AwaitAuthParams { + email string @[required] + timeout time.Duration = 3 * time.minute +} + +// function to check if an email is authenticated +pub fn (mut auth Authenticator) await_authentication(params AwaitAuthParams) ! { + stopwatch := time.new_stopwatch() + for stopwatch.elapsed() < params.timeout { + if auth.is_authenticated(params.email)! { + return + } + time.sleep(2 * time.second) + } + return error('Authentication timeout.') +} + +// function to check if an email is authenticated +pub fn (mut auth Authenticator) is_authenticated(email string) !bool { + session := auth.backend.read_auth_session(email) or { return error('Cant find session') } + return session.authenticated +} diff --git a/lib/security/authentication/backend.v b/lib/security/authentication/backend.v new file mode 100644 index 00000000..77209b33 --- /dev/null +++ b/lib/security/authentication/backend.v @@ -0,0 +1,14 @@ +module authentication + +import log + +// Creates and updates, authenticates email authentication sessions +interface IBackend { + read_auth_session(string) ?AuthSession +mut: + logger &log.Logger + create_auth_session(AuthSession) ! + update_auth_session(AuthSession) ! + delete_auth_session(string) ! + set_session_authenticated(string) ! +} diff --git a/lib/security/authentication/backend_database.v b/lib/security/authentication/backend_database.v new file mode 100644 index 00000000..b133b7dc --- /dev/null +++ b/lib/security/authentication/backend_database.v @@ -0,0 +1,93 @@ +module authentication + +import db.sqlite +import log +import time + +// Creates and updates, authenticates email authentication sessions +@[noinit] +struct DatabaseBackend { +mut: + db sqlite.DB +} + +@[params] +pub struct DatabaseBackendConfig { + db_path string = 'email_authenticator.sqlite' +} + +// factory for +pub fn new_database_backend(config DatabaseBackendConfig) !DatabaseBackend { + db := sqlite.connect(config.db_path) or { panic(err) } + + sql db { + create table AuthSession + } or { panic(err) } + + return DatabaseBackend{ + // logger: config.logger + db: db + } +} + +pub fn (auth DatabaseBackend) create_auth_session(session_ AuthSession) ! { + mut session := session_ + if session.timeout.unix() == 0 { + session.timeout = time.now().add_seconds(180) + } + sql auth.db { + insert session into AuthSession + } or { panic('err:${err}') } +} + +pub fn (auth DatabaseBackend) read_auth_session(email string) ?AuthSession { + session := sql auth.db { + select from AuthSession where email == '${email}' + } or { panic('err:${err}') } + return session[0] or { return none } +} + +pub fn (auth DatabaseBackend) update_auth_session(session AuthSession) ! { + sql auth.db { + update AuthSession set attempts_left = session.attempts_left where email == session.email + } or { panic('err:${err}') } +} + +pub fn (auth DatabaseBackend) set_session_authenticated(email string) ! { + sql auth.db { + update AuthSession set authenticated = true where email == email + } or { panic('err:${err}') } +} + +pub fn (auth DatabaseBackend) delete_auth_session(email string) ! { + sql auth.db { + delete from AuthSession where email == '${email}' + } or { panic('err:${err}') } +} + +// if session.attempts_left <= 0 { // checks if remaining attempts +// return AttemptResult{ +// authenticated: false + +// attempts_left: 0 +// time_left: +// } +// } + +// // authenticates if cypher in link matches authcode +// if cypher == auth.sessions[email].auth_code { +// auth.logger.debug(@FN + ':\nUser authenticated email: ${email}') +// auth.sessions[email].authenticated = true +// result := AttemptResult{ +// authenticated: true +// attempts_left: auth.sessions[email].attempts_left +// } +// return result +// } else { +// auth.sessions[email].attempts_left -= 1 +// result := AttemptResult{ +// authenticated: false +// attempts_left: auth.sessions[email].attempts_left +// } +// return result +// } diff --git a/lib/security/authentication/backend_test.v b/lib/security/authentication/backend_test.v new file mode 100644 index 00000000..3047312f --- /dev/null +++ b/lib/security/authentication/backend_test.v @@ -0,0 +1,59 @@ +module authentication + +import db.sqlite +import log +import time + +const test_email = 'test@example.com' + +const test_auth_code = '123ABC' + +const test_db_name = 'email_authenticator.sqlite' + +fn testsuite_begin() { + db := sqlite.connect(email.test_db_name) or { panic(err) } + sql db { + drop table AuthSession + } or { return } +} + +fn testsuite_end() { + db := sqlite.connect(email.test_db_name) or { panic(err) } + sql db { + drop table AuthSession + } or { panic(err) } +} + +fn test_database_backend() { + mut backend := new_database_backend()! + run_backend_tests(mut backend)! + backend.db.close()! +} + +fn test_memory_backend() { + mut backend := new_memory_backend()! + run_backend_tests(mut backend)! +} + +fn run_backend_tests(mut backend IBackend) ! { + session := AuthSession{ + email: email.test_email + } + + backend.create_auth_session(session)! + assert backend.read_auth_session(email.test_email)! == session + + backend.update_auth_session(AuthSession{ + ...session + attempts_left: 1 + })! + assert backend.read_auth_session(email.test_email)!.attempts_left == 1 + + backend.delete_auth_session(email.test_email)! + if _ := backend.read_auth_session(email.test_email) { + // should return none, so fails test + assert false + } else { + assert true + } +} diff --git a/lib/security/authentication/client.v b/lib/security/authentication/client.v new file mode 100644 index 00000000..2e38f2a4 --- /dev/null +++ b/lib/security/authentication/client.v @@ -0,0 +1,68 @@ +module authentication + +import net.http +import time +import json + +// session controller that be be added to vweb projects +pub struct EmailClient { + url string @[required] +} + +struct PostParams { + url string + data string + timeout time.Duration +} + +fn (client EmailClient) post_request(params PostParams) !http.Response { + mut req := http.new_request(http.Method.post, params.url, params.data) + req.read_timeout = params.timeout + resp := req.do() or { + return error('Failed to send request to email authentication server: ${err.code}') + } + if resp.status_code == 404 { + return error('Could not find email verification endpoint, please make sure the auth client url is configured to the url the auth server is running at.') + } + if resp.status_code != 200 { + panic('Email verification request failed, this should never happen: ${resp.status_msg}') + } + return resp +} + +// verify_email posts an email verification req to the email auth controller +pub fn (client EmailClient) email_authentication(params SendMailConfig) ! { + client.post_request( + url: '${client.url}/email_authentication' + data: json.encode(params) + timeout: 180 * time.second + )! +} + +// verify_email posts an email verification req to the email auth controller +pub fn (client EmailClient) is_verified(address string) !bool { + resp := client.post_request( + url: '${client.url}/is_verified' + data: json.encode(address) + timeout: 180 * time.second + )! + return resp.body == 'true' +} + +// send_verification_email posts an email verification req to the email auth controller +pub fn (client EmailClient) send_verification_email(params SendMailConfig) ! { + client.post_request( + url: '${client.url}/send_verification_mail' + data: json.encode(params) + ) or { return error(err.msg()) } +} + +// authenticate posts an authentication attempt req to the email auth controller +pub fn (c EmailClient) authenticate(address string, cypher string) !AttemptResult { + resp := http.post('${c.url}/authenticate', json.encode(AuthAttempt{ + address: address + cypher: cypher + }))! + result := json.decode(AttemptResult, resp.body)! + return result +} diff --git a/lib/security/authentication/controller.v b/lib/security/authentication/controller.v new file mode 100644 index 00000000..9f24d7e5 --- /dev/null +++ b/lib/security/authentication/controller.v @@ -0,0 +1,145 @@ +module authentication + +import vweb +import time +import json +import log +import freeflowuniverse.herolib.ui.console + +const agent = 'Email Authentication Controller' + +// email authentication controller that be be added to vweb projects +@[heap] +pub struct Controller { + vweb.Context + callback string @[vweb_global] +mut: + authenticator Authenticator @[vweb_global] +} + +@[params] +pub struct ControllerParams { + logger &log.Logger + authenticator Authenticator @[required] +} + +pub fn new_controller(params ControllerParams) Controller { + mut app := Controller{ + authenticator: params.authenticator + } + return app +} + +// route responsible for verifying email, email form should be posted here +@[POST] +pub fn (mut app Controller) send_verification_mail() !vweb.Result { + config := json.decode(SendMailConfig, app.req.data)! + app.authenticator.send_verification_mail(config) or { panic(err) } + return app.ok('') +} + +// route responsible for verifying email, email form should be posted here +@[POST] +pub fn (mut app Controller) is_verified() vweb.Result { + address := app.req.data + // checks if email verified every 2 seconds + for { + if app.authenticator.is_authenticated(address) or { panic(err) } { + // returns success message once verified + return app.ok('ok') + } + time.sleep(2 * time.second) + } + return app.html('timeout') +} + +// route responsible for verifying email, email form should be posted here +@[POST] +pub fn (mut app Controller) email_authentication() vweb.Result { + config_ := json.decode(SendMailConfig, app.req.data) or { + app.set_status(422, 'Request payload does not follow anticipated formatting.') + return app.text('Request payload does not follow anticipated formatting.') + } + config := if config_.link == '' { + SendMailConfig{ + ...config_ + link: 'http://localhost:8000/email_authenticator/authentication_link' + } + } else { + config_ + } + + app.authenticator.send_verification_mail(config) or { panic(err) } + + // checks if email verified every 2 seconds + for { + if app.authenticator.is_authenticated(config.email) or { panic(err) } { + // returns success message once verified + return app.ok('ok') + } + time.sleep(2 * time.second) + } + return app.ok('success!') +} + +// route responsible for verifying email, email form should be posted here +@[POST] +pub fn (mut app Controller) verify() vweb.Result { + config_ := json.decode(SendMailConfig, app.req.data) or { + app.set_status(422, 'Request payload does not follow anticipated formatting.') + return app.text('Request payload does not follow anticipated formatting.') + } + + config := if config_.link == '' { + SendMailConfig{ + ...config_ + link: 'http://localhost:8000/email_authenticator/authentication_link' + } + } else { + config_ + } + + app.authenticator.send_verification_mail(config) or { panic(err) } + + // checks if email verified every 2 seconds + stopwatch := time.new_stopwatch() + for stopwatch.elapsed() < 180 * time.second { + authenticated := app.authenticator.is_authenticated(config.email) or { + return app.text(err.msg()) + } + if authenticated { + console.print_debug('heyo yess') + return app.ok('success') + } + time.sleep(2 * time.second) + } + + app.set_status(408, 'Email authentication timeout.') + return app.text('Email authentication timeout.') +} + +pub struct AuthAttempt { +pub: + ip string + address string + cypher string +} + +@[POST] +pub fn (mut app Controller) authenticate() !vweb.Result { + attempt := json.decode(AuthAttempt, app.req.data)! + app.authenticator.authenticate(attempt.address, attempt.cypher) or { + app.set_status(401, err.msg()) + return app.text('Failed to authenticate') + } + return app.ok('Authentication successful') +} + +@['/authentication_link/:address/:cypher'] +pub fn (mut app Controller) authentication_link(address string, cypher string) !vweb.Result { + app.authenticator.authenticate(address, cypher) or { + app.set_status(401, err.msg()) + return app.text('Failed to authenticate') + } + return app.html('Authentication successful') +} diff --git a/lib/security/authentication/controller_test.v b/lib/security/authentication/controller_test.v new file mode 100644 index 00000000..4394274a --- /dev/null +++ b/lib/security/authentication/controller_test.v @@ -0,0 +1,46 @@ +module authentication + +import log +import net.smtp +import os +import toml + +fn test_new_controller() { + mut logger := log.Logger(&log.Log{ + level: .debug + }) + + env := toml.parse_file(os.dir(os.dir(@FILE)) + '/.env') or { + panic('Could not find .env, ${err}') + } + + client := smtp.Client{ + server: 'smtp-relay.brevo.com' + from: 'verify@authenticator.io' + port: 587 + username: env.value('BREVO_SMTP_USERNAME').string() + password: env.value('BREVO_SMTP_PASSWORD').string() + } + + controller := new_controller(logger: &logger) +} + +fn test_send_verification_mail() { + // mut logger := log.Logger(&log.Log{ + // level: .debug + // }) + + // env := toml.parse_file(os.dir(os.dir(@FILE)) + '/.env') or { + // panic('Could not find .env, ${err}') + // } + + // client := smtp.Client{ + // server: 'smtp-relay.brevo.com' + // from: 'verify@authenticator.io' + // port: 587 + // username: env.value('BREVO_SMTP_USERNAME').string() + // password: env.value('BREVO_SMTP_PASSWORD').string() + // } + + // controller := new_controller(logger: &logger) +} diff --git a/lib/security/authentication/email_authentication.v b/lib/security/authentication/email_authentication.v new file mode 100644 index 00000000..0544d996 --- /dev/null +++ b/lib/security/authentication/email_authentication.v @@ -0,0 +1,106 @@ +module authentication + +import time +import crypto.hmac +import crypto.sha256 +import encoding.hex +import encoding.base64 +import freeflowuniverse.herolib.clients.mailclient {MailClient} + +pub struct StatelessAuthenticator { +pub: + secret string +pub mut: + mail_client MailClient +} + + pub fn new_stateless_authenticator(authenticator StatelessAuthenticator) !StatelessAuthenticator { + // TODO: do some checks + return StatelessAuthenticator {...authenticator} +} + +pub struct AuthenticationMail { + RedirectURLs +pub: + to string // email address being authentcated + from string = 'email_authenticator@herolib.tf' + subject string = 'Verify your email' + body string = 'Please verify your email by clicking the link below' + callback string // callback url of authentication link + success_url string // where the user will be redirected upon successful authentication + failure_url string // where the user will be redirected upon failed authentication +} + +pub fn (mut a StatelessAuthenticator) send_authentication_mail(mail AuthenticationMail) ! { + link := a.new_authentication_link(mail.to, mail.callback, mail.RedirectURLs)! + button := 'Verify Email' + + // send email with link in body + a.mail_client.send( + to: mail.to + from: mail.from + subject: mail.subject + body_type: .html + body: $tmpl('./templates/mail.html') + ) or { return error('Error resolving email address $err') } +} + +@[params] +pub struct RedirectURLs { +pub: + success_url string + failure_url string +} + +fn (a StatelessAuthenticator) new_authentication_link(email string, callback string, urls RedirectURLs) !string { + if urls.failure_url != '' { + panic('implement') + } + + // sign email address and expiration of authentication link + expiration := time.now().add(5 * time.minute) + data := '${email}.${expiration}' // data to be signed + + // QUESTION? should success url also be signed for security? + signature := hmac.new( + hex.decode(a.secret)!, + data.bytes(), + sha256.sum, + sha256.block_size + ) + encoded_signature := base64.url_encode(signature.bytestr().bytes()) + mut queries := '' + if urls.success_url != '' { + encoded_url := base64.url_encode(urls.success_url.bytes()) + queries += '?success_url=${encoded_url}' + } + return "${callback}/${email}/${expiration.unix()}/${encoded_signature}${queries}" +} + +pub struct AuthenticationAttempt { +pub: + email string + expiration time.Time + signature string +} + +// sends mail with login link +pub fn (auth StatelessAuthenticator) authenticate(attempt AuthenticationAttempt) ! { + if time.now() > attempt.expiration { + return error('link expired') + } + + data := '${attempt.email}.${attempt.expiration}' // data to be signed + signature_mirror := hmac.new( + hex.decode(auth.secret) or {panic(err)}, + data.bytes(), + sha256.sum, + sha256.block_size + ).bytestr().bytes() + + decoded_signature := base64.url_decode(attempt.signature) + + if !hmac.equal(decoded_signature, signature_mirror) { + return error('signature mismatch') + } +} diff --git a/lib/security/authentication/templates/mail.html b/lib/security/authentication/templates/mail.html new file mode 100644 index 00000000..ff3f5cb1 --- /dev/null +++ b/lib/security/authentication/templates/mail.html @@ -0,0 +1,49 @@ + + +
+ + + + + +Hello,
+@{mail.body}
+@{button}
+