Compare commits
44 Commits
Author | SHA1 | Date | |
---|---|---|---|
b5b4042cc7 | |||
9e3fc427bb | |||
d0b57ab15f | |||
dc93c585eb | |||
de330c0042 | |||
310dac296f | |||
4db7576b12 | |||
f5283b86ed | |||
98ebd7a168 | |||
8ca31d05c0 | |||
34c6a089b5 | |||
da73b842e1 | |||
55d7241d95 | |||
240b07af66 | |||
68e35bf5a6 | |||
4bc2d864ad | |||
dc18381dea | |||
1dde96043a | |||
f758acb4e5 | |||
054e80bbfb | |||
32c6f0a77e | |||
050e529f0a | |||
006f13bc7b | |||
84c8fd51f6 | |||
f08f645432 | |||
fbb27d6ea4 | |||
d8ce2901d2 | |||
1996f4dc56 | |||
e09de0b0a4 | |||
72765de20b | |||
ed535b6f5d | |||
07452ad8ab | |||
0f0fdfb02b | |||
9eefce9b41 | |||
0577762be9 | |||
cf8a3f8ac0 | |||
1f4f795d43 | |||
fd12d2ba42 | |||
6399196fe5 | |||
fef0321475 | |||
a9d2c282f2 | |||
0bb7f2cd85 | |||
b61bf52df9 | |||
f1dd467c95 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,7 +1,7 @@
|
||||
/node_modules
|
||||
/bin
|
||||
/pkg/sdk/client/dist
|
||||
/.env
|
||||
/tools
|
||||
*.sqlite
|
||||
/.gitea-release
|
||||
/.edge
|
49
Jenkinsfile
vendored
Normal file
49
Jenkinsfile
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
@Library('cadoles') _
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
dockerfile {
|
||||
label 'docker'
|
||||
filename 'Dockerfile'
|
||||
dir 'misc/jenkins'
|
||||
}
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Run unit tests') {
|
||||
steps {
|
||||
script {
|
||||
sh 'make GOTEST_ARGS="-timeout 10m -count=1 -v" test'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Release') {
|
||||
when {
|
||||
anyOf {
|
||||
branch 'master'
|
||||
branch 'develop'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
withCredentials([
|
||||
usernamePassword([
|
||||
credentialsId: 'forge-jenkins',
|
||||
usernameVariable: 'GITEA_RELEASE_USERNAME',
|
||||
passwordVariable: 'GITEA_RELEASE_PASSWORD'
|
||||
])
|
||||
]) {
|
||||
sh 'make gitea-release'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
37
Makefile
37
Makefile
@ -2,13 +2,18 @@ LINT_ARGS ?= --timeout 5m
|
||||
GITCHLOG_ARGS ?=
|
||||
SHELL := /bin/bash
|
||||
|
||||
GOTEST_ARGS ?= -short
|
||||
GOTEST_ARGS ?= -short -timeout 60s
|
||||
|
||||
ESBUILD_VERSION ?= v0.17.5
|
||||
|
||||
GIT_VERSION := $(shell git describe --always)
|
||||
DATE_VERSION := $(shell date +%Y.%-m.%-d)
|
||||
FULL_VERSION := v$(DATE_VERSION)-$(GIT_VERSION)$(if $(shell git diff --stat),-dirty,)
|
||||
APP_PATH ?= misc/client-sdk-testsuite/dist
|
||||
RUN_APP_ARGS ?=
|
||||
SHELL := bash
|
||||
|
||||
build: build-edge-cli
|
||||
build: build-edge-cli build-client-sdk-test-app
|
||||
|
||||
watch:
|
||||
go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest
|
||||
@ -28,10 +33,12 @@ build-edge-cli: build-sdk
|
||||
-o ./bin/cli \
|
||||
./cmd/cli
|
||||
|
||||
build-client-sdk-test-app:
|
||||
cd misc/client-sdk-testsuite && $(MAKE) dist
|
||||
|
||||
install-git-hooks:
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
|
||||
tools/esbuild/bin/esbuild:
|
||||
mkdir -p tools/esbuild/bin
|
||||
curl -fsSL https://esbuild.github.io/dl/$(ESBUILD_VERSION) | sh
|
||||
@ -46,39 +53,51 @@ pkg/sdk/client/dist/client.js: tools/esbuild/bin/esbuild node_modules
|
||||
pkg/sdk/client/src/index.ts \
|
||||
--bundle \
|
||||
--sourcemap \
|
||||
--target=es2020 \
|
||||
--target=es2015 \
|
||||
--format=iife \
|
||||
--global-name=Edge \
|
||||
--define:global=window \
|
||||
--platform=browser \
|
||||
--footer:js="Edge=Edge.default;" \
|
||||
--loader:.svg=dataurl \
|
||||
--outfile=pkg/sdk/client/dist/client.js
|
||||
|
||||
node_modules:
|
||||
npm ci
|
||||
|
||||
gitea-release: tools/gitea-release/bin/gitea-release.sh build
|
||||
run-app: .env
|
||||
( set -o allexport && source .env && set +o allexport && bin/cli app run -p $(APP_PATH) $$RUN_APP_ARGS )
|
||||
|
||||
.env:
|
||||
cp .env.dist .env
|
||||
|
||||
gitea-release: tools/yq/bin/yq tools/gitea-release/bin/gitea-release.sh build
|
||||
mkdir -p .gitea-release
|
||||
rm -rf .gitea-release/*
|
||||
|
||||
cp bin/cli .gitea-release/edge_cli_amd64
|
||||
|
||||
# Create client-sdk-testsuite package
|
||||
tools/yq/bin/yq -i '.version = "$(FULL_VERSION)"' ./misc/client-sdk-testsuite/dist/manifest.yml
|
||||
.gitea-release/edge_cli_amd64 app package -d ./misc/client-sdk-testsuite/dist -o .gitea-release
|
||||
|
||||
GITEA_RELEASE_PROJECT="edge" \
|
||||
GITEA_RELEASE_ORG="arcad" \
|
||||
GITEA_RELEASE_BASE_URL="https://forge.cadoles.com" \
|
||||
GITEA_RELEASE_VERSION="$(GIT_VERSION)" \
|
||||
GITEA_RELEASE_NAME="$(GIT_VERSION)" \
|
||||
GITEA_RELEASE_VERSION="$(FULL_VERSION)" \
|
||||
GITEA_RELEASE_NAME="$(FULL_VERSION)" \
|
||||
GITEA_RELEASE_COMMITISH_TARGET="$(GIT_VERSION)" \
|
||||
GITEA_RELEASE_IS_DRAFT="false" \
|
||||
GITEA_RELEASE_IS_PRERELEASE="true" \
|
||||
GITEA_RELEASE_BODY="" \
|
||||
GITEA_RELEASE_ATTACHMENTS="$(shell find .gitea-release/* -type f)" \
|
||||
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
|
||||
tools/gitea-release/bin/gitea-release.sh
|
||||
|
||||
tools/gitea-release/bin/gitea-release.sh:
|
||||
mkdir -p tools/gitea-release/bin
|
||||
curl --output tools/gitea-release/bin/gitea-release.sh https://forge.cadoles.com/Cadoles/Jenkins/raw/branch/master/resources/com/cadoles/gitea/gitea-release.sh
|
||||
chmod +x tools/gitea-release/bin/gitea-release.sh
|
||||
|
||||
tools/yq/bin/yq:
|
||||
mkdir -p tools/yq/bin
|
||||
curl -L --output tools/yq/bin/yq https://github.com/mikefarah/yq/releases/download/v4.31.1/yq_linux_amd64
|
||||
chmod +x tools/yq/bin/yq
|
11
cmd/cli/command/app/common.go
Normal file
11
cmd/cli/command/app/common.go
Normal file
@ -0,0 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/app/metadata"
|
||||
)
|
||||
|
||||
var manifestMetadataValidators = []app.MetadataValidator{
|
||||
metadata.WithMinimumRoleValidator("visitor", "user", "superuser", "admin", "superadmin"),
|
||||
metadata.WithNamedPathsValidator(metadata.NamedPathAdmin, metadata.NamedPathIcon),
|
||||
}
|
50
cmd/cli/command/app/default-accounts.json
Normal file
50
cmd/cli/command/app/default-accounts.json
Normal file
@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"username": "superadmin",
|
||||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$cWOxfEyBy4EyKZR5usB2Pw$xG+Z/E2DUJP9kF0s1fhZjIuP03gFQ65dP7pHRJz7eR8",
|
||||
"claims": {
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "superadmin",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "SuperAdmin",
|
||||
"sub": "superadmin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "admin",
|
||||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$WXXc4ECnkej6WO7f0Xya6Q$UG2wcGltJcuW0cNTR85mAl65tI1kFWMMw7ADS2FMOvY",
|
||||
"claims": {
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "admin",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "Admin",
|
||||
"sub": "admin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "superuser",
|
||||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$gkDAWCzfU23+un3x0ny+YA$L/NSPrd5iKPK/UnSCKfSz4EO+v94N3LTLky4QGJOfpI",
|
||||
"claims": {
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "superuser",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "SuperUser",
|
||||
"sub": "superuser"
|
||||
}
|
||||
},
|
||||
{
|
||||
"username": "user",
|
||||
"algo": "argon2id",
|
||||
"password": "$argon2id$v=19$m=65536,t=3,p=2$DhUm9qXUKP35Lzp5M37eZA$2+h6yDxSTHZqFZIuI7JZfFWozwrObna8a8yCgEEPlPE",
|
||||
"claims": {
|
||||
"edge_entrypoint": "edge",
|
||||
"edge_role": "user",
|
||||
"edge_tenant": "dev.cli",
|
||||
"preferred_username": "User",
|
||||
"sub": "user"
|
||||
}
|
||||
}
|
||||
]
|
47
cmd/cli/command/app/hash-password.go
Normal file
47
cmd/cli/command/app/hash-password.go
Normal file
@ -0,0 +1,47 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/argon2id"
|
||||
_ "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/plain"
|
||||
)
|
||||
|
||||
func HashPasswordCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "hash-password",
|
||||
Usage: "Hash the provided password with the specified algorithm",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "hash `PASSWORD`",
|
||||
Aliases: []string{"p"},
|
||||
Value: "",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "algorithm",
|
||||
Usage: fmt.Sprintf("use `ALGORITHM` to hash password (available: %v)", passwd.Algorithms()),
|
||||
Aliases: []string{"a"},
|
||||
Value: string(argon2id.Algo),
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
algo := ctx.String("algorithm")
|
||||
password := ctx.String("password")
|
||||
|
||||
hash, err := passwd.Hash(passwd.Algo(algo), password)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
fmt.Println(hash)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
@ -47,11 +47,15 @@ func PackageCommand() *cli.Command {
|
||||
|
||||
bundle := bundle.NewDirectoryBundle(appDir)
|
||||
|
||||
manifest, err := app.LoadAppManifest(bundle)
|
||||
manifest, err := app.LoadManifest(bundle)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load app manifest")
|
||||
}
|
||||
|
||||
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
|
||||
return errors.Wrap(err, "invalid app manifest")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return errors.Wrapf(err, "could not create directory ''%s'", outputDir)
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ func Root() *cli.Command {
|
||||
Subcommands: []*cli.Command{
|
||||
RunCommand(),
|
||||
PackageCommand(),
|
||||
HashPasswordCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +1,47 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
appHTTP "forge.cadoles.com/arcad/edge/pkg/http"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth"
|
||||
appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
|
||||
appModuleMemory "forge.cadoles.com/arcad/edge/pkg/module/app/memory"
|
||||
authModule "forge.cadoles.com/arcad/edge/pkg/module/auth"
|
||||
authHTTP "forge.cadoles.com/arcad/edge/pkg/module/auth/http"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/blob"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/cast"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/net"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/fetch"
|
||||
netModule "forge.cadoles.com/arcad/edge/pkg/module/net"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
_ "embed"
|
||||
|
||||
_ "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/argon2id"
|
||||
_ "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd/plain"
|
||||
)
|
||||
|
||||
func RunCommand() *cli.Command {
|
||||
@ -35,15 +49,15 @@ func RunCommand() *cli.Command {
|
||||
Name: "run",
|
||||
Usage: "Run the specified app bundle",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "path",
|
||||
Usage: "use `PATH` as app bundle (zipped bundle or directory)",
|
||||
Aliases: []string{"p"},
|
||||
Value: ".",
|
||||
Value: cli.NewStringSlice("."),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Usage: "use `ADDRESS` as http server listening address",
|
||||
Usage: "use `ADDRESS` as http server base listening address",
|
||||
Aliases: []string{"a"},
|
||||
Value: ":8080",
|
||||
},
|
||||
@ -60,172 +74,323 @@ func RunCommand() *cli.Command {
|
||||
&cli.StringFlag{
|
||||
Name: "storage-file",
|
||||
Usage: "use `FILE` for SQLite storage database",
|
||||
Value: "data.sqlite",
|
||||
Value: ".edge/%APPID%/data.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "auth-subject",
|
||||
Usage: "set the `SUBJECT` associated with the simulated connected user",
|
||||
Value: "jdoe",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "auth-role",
|
||||
Usage: "set the `ROLE` associated with the simulated connected user",
|
||||
Value: "user",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "auth-preferred-username",
|
||||
Usage: "set the `PREFERRED_USERNAME` associated with the simulated connected user",
|
||||
Value: "Jane Doe",
|
||||
Name: "accounts-file",
|
||||
Usage: "use `FILE` as local accounts",
|
||||
Value: ".edge/%APPID%/accounts.json",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
address := ctx.String("address")
|
||||
path := ctx.String("path")
|
||||
paths := ctx.StringSlice("path")
|
||||
|
||||
logFormat := ctx.String("log-format")
|
||||
logLevel := ctx.Int("log-level")
|
||||
|
||||
storageFile := ctx.String("storage-file")
|
||||
|
||||
authSubject := ctx.String("auth-subject")
|
||||
authRole := ctx.String("auth-role")
|
||||
authPreferredUsername := ctx.String("auth-preferred-username")
|
||||
accountsFile := ctx.String("accounts-file")
|
||||
|
||||
logger.SetFormat(logger.Format(logFormat))
|
||||
logger.SetLevel(logger.Level(logLevel))
|
||||
|
||||
cmdCtx := ctx.Context
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not resolve path '%s'", path)
|
||||
}
|
||||
|
||||
logger.Info(cmdCtx, "opening app bundle", logger.F("path", absPath))
|
||||
|
||||
bundle, err := bundle.FromPath(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not open path '%s' as an app bundle", path)
|
||||
}
|
||||
|
||||
mux := chi.NewMux()
|
||||
|
||||
mux.Use(middleware.Logger)
|
||||
mux.Use(dummyAuthMiddleware(authSubject, authRole, authPreferredUsername))
|
||||
|
||||
bus := memory.NewBus()
|
||||
|
||||
db, err := sql.Open("sqlite", storageFile)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not open database with path '%s'", storageFile)
|
||||
}
|
||||
|
||||
ds := sqlite.NewDocumentStoreWithDB(db)
|
||||
bs := sqlite.NewBlobStoreWithDB(db)
|
||||
|
||||
handler := appHTTP.NewHandler(
|
||||
appHTTP.WithBus(bus),
|
||||
appHTTP.WithServerModules(getServerModules(bus, ds, bs)...),
|
||||
)
|
||||
if err := handler.Load(bundle); err != nil {
|
||||
return errors.Wrap(err, "could not load app bundle")
|
||||
}
|
||||
|
||||
mux.Handle("/*", handler)
|
||||
|
||||
logger.Info(cmdCtx, "listening", logger.F("address", address))
|
||||
|
||||
if err := http.ListenAndServe(address, mux); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
port, err := strconv.ParseUint(portStr, 10, 32)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
manifests := make([]*app.Manifest, len(paths))
|
||||
for idx, pth := range paths {
|
||||
bdl, err := bundle.FromPath(pth)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
manifest, err := app.LoadManifest(bdl)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
manifests[idx] = manifest
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for idx, p := range paths {
|
||||
wg.Add(1)
|
||||
|
||||
go func(path string, basePort uint64, appIndex int) {
|
||||
defer wg.Done()
|
||||
|
||||
port := basePort + uint64(appIndex)
|
||||
address := fmt.Sprintf("%s:%d", host, port)
|
||||
appsRepository := newAppRepository(host, basePort, manifests...)
|
||||
|
||||
appCtx := logger.With(cmdCtx, logger.F("address", address))
|
||||
|
||||
if err := runApp(appCtx, path, address, storageFile, accountsFile, appsRepository); err != nil {
|
||||
logger.Error(appCtx, "could not run app", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}(p, port, idx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore) []app.ServerModuleFactory {
|
||||
func runApp(ctx context.Context, path string, address string, storageFile string, accountsFile string, appRepository appModule.Repository) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not resolve path '%s'", path)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "opening app bundle", logger.F("path", absPath))
|
||||
|
||||
bundle, err := bundle.FromPath(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not open path '%s' as an app bundle", path)
|
||||
}
|
||||
|
||||
manifest, err := app.LoadManifest(bundle)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load manifest from app bundle")
|
||||
}
|
||||
|
||||
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
|
||||
return errors.Wrap(err, "invalid app manifest")
|
||||
}
|
||||
|
||||
ctx = logger.With(ctx, logger.F("appID", manifest.ID))
|
||||
|
||||
storageFile = injectAppID(storageFile, manifest.ID)
|
||||
|
||||
if err := ensureDir(storageFile); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
db, err := sqlite.Open(storageFile)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
accountsFile = injectAppID(accountsFile, manifest.ID)
|
||||
|
||||
accounts, err := loadLocalAccounts(accountsFile)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not load local accounts")
|
||||
}
|
||||
|
||||
// Add auth handler
|
||||
key, err := dummyKey()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ds := sqlite.NewDocumentStoreWithDB(db)
|
||||
bs := sqlite.NewBlobStoreWithDB(db)
|
||||
bus := memory.NewBus()
|
||||
|
||||
handler := appHTTP.NewHandler(
|
||||
appHTTP.WithBus(bus),
|
||||
appHTTP.WithServerModules(getServerModules(bus, ds, bs, appRepository)...),
|
||||
appHTTP.WithHTTPMounts(
|
||||
appModule.Mount(appRepository),
|
||||
authModule.Mount(
|
||||
authHTTP.NewLocalHandler(
|
||||
jwa.HS256, key,
|
||||
authHTTP.WithRoutePrefix("/auth"),
|
||||
authHTTP.WithAccounts(accounts...),
|
||||
),
|
||||
authModule.WithJWT(dummyKeySet),
|
||||
),
|
||||
),
|
||||
)
|
||||
if err := handler.Load(bundle); err != nil {
|
||||
return errors.Wrap(err, "could not load app bundle")
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
// Add app handler
|
||||
router.Handle("/*", handler)
|
||||
|
||||
logger.Info(ctx, "listening", logger.F("address", address))
|
||||
|
||||
if err := http.ListenAndServe(address, router); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, appRepository appModule.Repository) []app.ServerModuleFactory {
|
||||
return []app.ServerModuleFactory{
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
cast.CastModuleFactory(),
|
||||
module.LifecycleModuleFactory(),
|
||||
net.ModuleFactory(bus),
|
||||
netModule.ModuleFactory(bus),
|
||||
module.RPCModuleFactory(bus),
|
||||
module.StoreModuleFactory(ds),
|
||||
module.BlobModuleFactory(bus, bs),
|
||||
module.Extends(
|
||||
auth.ModuleFactory(
|
||||
auth.WithJWT(dummyKeyFunc),
|
||||
),
|
||||
func(o *goja.Object) {
|
||||
if err := o.Set("CLAIM_ROLE", "role"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_ROLE' property"))
|
||||
}
|
||||
|
||||
if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil {
|
||||
panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property"))
|
||||
}
|
||||
},
|
||||
blob.ModuleFactory(bus, bs),
|
||||
authModule.ModuleFactory(
|
||||
authModule.WithJWT(dummyKeySet),
|
||||
),
|
||||
appModule.ModuleFactory(appRepository),
|
||||
fetch.ModuleFactory(bus),
|
||||
}
|
||||
}
|
||||
|
||||
var dummySecret = []byte("not_so_secret")
|
||||
|
||||
func dummyKeyFunc(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
|
||||
func dummyKey() (jwk.Key, error) {
|
||||
key, err := jwk.FromRaw(dummySecret)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return dummySecret, nil
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func dummyAuthMiddleware(subject, role, username string) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
unauthenticated := subject == "" && role == "" && username == ""
|
||||
func dummyKeySet() (jwk.Set, error) {
|
||||
key, err := dummyKey()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if unauthenticated {
|
||||
h.ServeHTTP(w, r)
|
||||
if err := key.Set(jwk.AlgorithmKey, jwa.HS256); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return
|
||||
set := jwk.NewSet()
|
||||
|
||||
if err := set.AddKey(key); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func ensureDir(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func injectAppID(str string, appID app.ID) string {
|
||||
return strings.ReplaceAll(str, "%APPID%", string(appID))
|
||||
}
|
||||
|
||||
//go:embed default-accounts.json
|
||||
var defaultAccounts []byte
|
||||
|
||||
func loadLocalAccounts(path string) ([]authHTTP.LocalAccount, error) {
|
||||
if err := ensureDir(path); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := ioutil.WriteFile(path, defaultAccounts, 0o640); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"nbf": time.Now().UTC().Unix(),
|
||||
}
|
||||
data = defaultAccounts
|
||||
} else {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if subject != "" {
|
||||
claims["sub"] = subject
|
||||
}
|
||||
var accounts []authHTTP.LocalAccount
|
||||
|
||||
if role != "" {
|
||||
claims["role"] = role
|
||||
}
|
||||
if err := json.Unmarshal(data, &accounts); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
claims["preferred_username"] = username
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
func findMatchingDeviceAddress(ctx context.Context, from string, defaultAddr string) (string, error) {
|
||||
if from == "" {
|
||||
return defaultAddr, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
fromIP := net.ParseIP(from)
|
||||
|
||||
rawToken, err := token.SignedString(dummySecret)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not sign token", logger.E(errors.WithStack(err)))
|
||||
if fromIP == nil {
|
||||
return defaultAddr, nil
|
||||
}
|
||||
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
for _, ifa := range ifaces {
|
||||
addrs, err := ifa.Addrs()
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not retrieve iface adresses",
|
||||
logger.E(errors.WithStack(err)), logger.F("iface", ifa.Name),
|
||||
)
|
||||
|
||||
r.Header.Add("Authorization", "Bearer "+rawToken)
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
continue
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
for _, addr := range addrs {
|
||||
ip, network, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not parse address",
|
||||
logger.E(errors.WithStack(err)), logger.F("address", addr.String()),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !network.Contains(fromIP) {
|
||||
continue
|
||||
}
|
||||
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return defaultAddr, nil
|
||||
}
|
||||
|
||||
func newAppRepository(host string, basePort uint64, manifests ...*app.Manifest) *appModuleMemory.Repository {
|
||||
return appModuleMemory.NewRepository(
|
||||
func(ctx context.Context, id app.ID, from string) (string, error) {
|
||||
appIndex := 0
|
||||
for i := 0; i < len(manifests); i++ {
|
||||
if manifests[i].ID == id {
|
||||
appIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
addr, err := findMatchingDeviceAddress(ctx, from, host)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s:%d", addr, int(basePort)+appIndex), nil
|
||||
},
|
||||
manifests...,
|
||||
)
|
||||
}
|
||||
|
40
cmd/cli/command/cast/load_url.go
Normal file
40
cmd/cli/command/cast/load_url.go
Normal file
@ -0,0 +1,40 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/cast"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func LoadURLCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "load-url",
|
||||
Usage: "Load `URL` in casting device",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Aliases: []string{"d"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "url",
|
||||
Aliases: []string{"u"},
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
device := ctx.String("device")
|
||||
url := ctx.String("url")
|
||||
|
||||
if err := cast.StopCast(ctx.Context, device); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := cast.LoadURL(ctx.Context, device, url); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
16
cmd/cli/command/cast/root.go
Normal file
16
cmd/cli/command/cast/root.go
Normal file
@ -0,0 +1,16 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cast",
|
||||
Usage: "Cast related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
ScanCommand(),
|
||||
LoadURLCommand(),
|
||||
},
|
||||
}
|
||||
}
|
37
cmd/cli/command/cast/scan.go
Normal file
37
cmd/cli/command/cast/scan.go
Normal file
@ -0,0 +1,37 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/barnybug/go-cast/discovery"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func ScanCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "scan",
|
||||
Usage: "Scan network for casting devices",
|
||||
Flags: []cli.Flag{},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
service := discovery.NewService(ctx.Context)
|
||||
defer service.Stop()
|
||||
|
||||
go func() {
|
||||
if err := service.Run(ctx.Context, time.Second); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
found := service.Found()
|
||||
|
||||
for device := range found {
|
||||
log.Printf("[DEVICE] %s %s %s:%d", device.Uuid(), device.Name(), device.IP().String(), device.Port())
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
@ -3,8 +3,9 @@ package main
|
||||
import (
|
||||
"forge.cadoles.com/arcad/edge/cmd/cli/command"
|
||||
"forge.cadoles.com/arcad/edge/cmd/cli/command/app"
|
||||
"forge.cadoles.com/arcad/edge/cmd/cli/command/cast"
|
||||
)
|
||||
|
||||
func main() {
|
||||
command.Main(app.Root())
|
||||
command.Main(app.Root(), cast.Root())
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ Une **Edge App** est une application capable de s'exécuter dans un environnemen
|
||||
|
||||
### Référence
|
||||
|
||||
- [Fichier `manifest.yml`](./apps/manifest.md)
|
||||
- [API Client](./apps/client-api/README.md)
|
||||
- [API Serveur](./apps/server-api/README.md)
|
||||
|
||||
|
@ -1,64 +1,15 @@
|
||||
# API Client
|
||||
|
||||
## Méthodes
|
||||
## Usage
|
||||
|
||||
### `Edge.connect(): Promise`
|
||||
Afin de pouvoir utiliser le SDK "client", vous devez inclure dans la page HTML de votre application la balise `<script>` suivante:
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.disconnect(): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.send(message: Object): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.rpc(method: string, params: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
#### Exemple
|
||||
|
||||
**Côté serveur**
|
||||
|
||||
```js
|
||||
function onInit() {
|
||||
rpc.register(echo);
|
||||
}
|
||||
|
||||
function echo(ctx, params) {
|
||||
return params;
|
||||
}
|
||||
```html
|
||||
<script src="/edge/sdk/client.js"></script>
|
||||
```
|
||||
|
||||
**Côté client**
|
||||
Vous pourrez ensuite accéder aux variables globales suivantes:
|
||||
|
||||
```js
|
||||
Edge.connect().then(() => {
|
||||
Edge.rpc("echo", { hello: "world!" })
|
||||
.then(result => console.log(result))
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
```
|
||||
|
||||
### `Edge.upload(blob: Blob, metadata: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.blobUrl(bucketName: string, blobId: string): string`
|
||||
|
||||
> `TODO`
|
||||
|
||||
## Événements
|
||||
|
||||
### `"message"`
|
||||
|
||||
> `TODO`
|
||||
|
||||
#### Exemple
|
||||
|
||||
```js
|
||||
Edge.addEventListener("message", evt => console.log(evt.detail));
|
||||
```
|
||||
- [`Edge.Client`](./edge-client.md) - Client principal d'échange avec le serveur
|
||||
- [`Edge.Frame`](./edge-frame.md) - Utilitaire de communication avec une frame parente
|
||||
- [`Edge.Menu`](./edge-menu.md) - Gestionnaire de menu
|
68
doc/apps/client-api/edge-client.md
Normal file
68
doc/apps/client-api/edge-client.md
Normal file
@ -0,0 +1,68 @@
|
||||
# `Edge.Client`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `Edge.Client.connect(): Promise`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.Client.disconnect(): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.Client.send(message: Object): void`
|
||||
|
||||
> `TODO`
|
||||
|
||||
|
||||
### `Edge.Client.rpc(method: string, params: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
#### Exemple
|
||||
|
||||
**Côté serveur**
|
||||
|
||||
```js
|
||||
function onInit() {
|
||||
rpc.register(echo);
|
||||
}
|
||||
|
||||
function echo(ctx, params) {
|
||||
return params;
|
||||
}
|
||||
```
|
||||
|
||||
**Côté client**
|
||||
|
||||
```js
|
||||
Edge.Client.connect().then(() => {
|
||||
Edge.Client.rpc("echo", { hello: "world!" })
|
||||
.then(result => console.log(result))
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
```
|
||||
|
||||
### `Edge.Client.upload(blob: Blob, metadata: Object): Promise`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.Client.blobUrl(bucketName: string, blobId: string): string`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `Edge.Client.externalUrl(url: string): string`
|
||||
|
||||
Retourne une URL "locale" permettant d'accéder à une ressource externe, en fonction de règles propres à l'application. Voir module [`fetch`](../server-api/fetch.md).
|
||||
|
||||
## Événements
|
||||
|
||||
### `"message"`
|
||||
|
||||
> `TODO`
|
||||
|
||||
#### Exemple
|
||||
|
||||
```js
|
||||
Edge.Client.addEventListener("message", evt => console.log(evt.detail));
|
||||
```
|
30
doc/apps/client-api/edge-frame.md
Normal file
30
doc/apps/client-api/edge-frame.md
Normal file
@ -0,0 +1,30 @@
|
||||
# `Edge.Frame`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `Edge.Frame.addEventListener(name: string, listener: (event) => void)`
|
||||
|
||||
> `TODO`
|
||||
|
||||
## Événements
|
||||
|
||||
### `"title_changed"`
|
||||
|
||||
```typescript
|
||||
interface TitleChangedEvent {
|
||||
detail: {
|
||||
title: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `"size_changed"`
|
||||
|
||||
```typescript
|
||||
interface SizeChangedEvent {
|
||||
detail: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
```
|
27
doc/apps/client-api/edge-menu.md
Normal file
27
doc/apps/client-api/edge-menu.md
Normal file
@ -0,0 +1,27 @@
|
||||
# `Edge.Menu`
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `Edge.Menu.show()`
|
||||
|
||||
Afficher le menu.
|
||||
|
||||
### `Edge.Menu.hide()`
|
||||
|
||||
Cacher le menu.
|
||||
|
||||
### `setItem(name: string, label:string, options?: { iconUrl?: string, linkUrl?: string, order?: number })`
|
||||
|
||||
Créer/mettre à jour l'item nommé de la section du menu associée à l'application.
|
||||
|
||||
### `removeItem(name: string)`
|
||||
|
||||
Supprimer l'item de la section du menu associée à l'application.
|
||||
|
||||
### `setAppIconUrl(url: string)`
|
||||
|
||||
Mettre à jour l'URL de l'icône de la section du menu associée à l'application.
|
||||
|
||||
### `setAppTitle(title: string)`
|
||||
|
||||
Mettre à jour le titre de la section du menu associée à l'application.
|
36
doc/apps/manifest.md
Normal file
36
doc/apps/manifest.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Le fichier `manifest.yml`
|
||||
|
||||
Le fichier `manifest.yml` à la racine du bundle de votre application contient des informations décrivant celles ci. Vous trouverez ci dessous un exemple commenté.
|
||||
|
||||
```yaml
|
||||
# REQUIS - L'identifiant de votre application. Il doit être globalement unique.
|
||||
# Un identifiant du type nom de domaine inversé est en général conseillé (ex: tld.mycompany.myapp)
|
||||
id: tld.mycompany.myapp
|
||||
|
||||
# REQUIS - Le numéro de version de votre application
|
||||
# Celui ci devrait respecter le format "semver 2" (voir https://semver.org/)
|
||||
version: 0.0.0
|
||||
|
||||
# REQUIS - Le titre de votre application.
|
||||
title: My App
|
||||
|
||||
# OPTIONNEL - Les mots-clés associés à votre applications.
|
||||
tags: ["chat"]
|
||||
|
||||
# OPTIONNEL - La description de votre application.
|
||||
# Vous pouvez utiliser la syntaxe Markdown pour la mettre en forme.
|
||||
description: |>
|
||||
A simple demo application
|
||||
|
||||
# OPTIONNEL - Métadonnées associées à l'application
|
||||
metadata:
|
||||
# OPTIONNEL - Liste des chemins permettant d'accéder à certains URLs identifiées (page d'administration, icône si existante, etc)
|
||||
paths:
|
||||
# Si défini, chemin vers la page d'administration de l'application
|
||||
admin: /admin
|
||||
# Si défini, chemin vers l'icône associée à l'application
|
||||
icon: /my-app-icon.png
|
||||
|
||||
# OPTIONNEL - Role minimum requis pour pouvoir accéder à l'application
|
||||
minimumRole: visitor
|
||||
```
|
@ -22,23 +22,7 @@ my-app
|
||||
|
||||
Ce fichier est le manifeste de votre application. Il permet au serveur d'identifier celle ci et de récupérer des informations la concernant.
|
||||
|
||||
```yaml
|
||||
---
|
||||
# L'identifiant de votre application. Il doit être globalement unique.
|
||||
# Un identifiant du type nom de domaine inversé est en général conseillé (ex: tld.mycompany.myapp)
|
||||
id: tld.mycompany.myapp
|
||||
|
||||
# Le titre de votre application.
|
||||
title: My App
|
||||
|
||||
# Les mots-clés associés à votre applications.
|
||||
tags: ["chat"]
|
||||
|
||||
# La description de votre application.
|
||||
# Vous pouvez utiliser la syntaxe Markdown pour la mettre en forme.
|
||||
description: |>
|
||||
A simple demo application
|
||||
```
|
||||
[Voir le fichier `manifest.yml` d'exemple](./manifest.md)
|
||||
|
||||
## 4. Créer la page d'accueil
|
||||
|
||||
@ -56,13 +40,13 @@ Créer le fichier `my-app/public/index.html`:
|
||||
<script type="text/javascript">
|
||||
// On utilise le SDK via la variable globale "Edge"
|
||||
// pour se connecter au serveur de notre application.
|
||||
Edge.connect().then(() => {
|
||||
Edge.Client.connect().then(() => {
|
||||
// Une fois connecté, on envoie un message au serveur.
|
||||
Edge.send({ "hello": "world" });
|
||||
Edge.Client.send({ "hello": "world" });
|
||||
});
|
||||
|
||||
// On écoute les messages en provenance du serveur.
|
||||
Edge.addEventListener("message", (evt) => {
|
||||
Edge.Client.addEventListener("message", (evt) => {
|
||||
console.log("New server message", evt.detail)
|
||||
});
|
||||
</script>
|
||||
|
@ -20,11 +20,13 @@ function onInit() {
|
||||
|
||||
Listes des modules disponibles côté serveur.
|
||||
|
||||
- [`app`](./app.md)
|
||||
- [`auth`](./auth.md)
|
||||
- [`blob`](./blob.md)
|
||||
- [`cast`](./cast.md)
|
||||
- [`console`](./console.md)
|
||||
- [`context`](./context.md)
|
||||
- [`fetch`](./fetch.md)
|
||||
- [`net`](./net.md)
|
||||
- [`rpc`](./rpc.md)
|
||||
- [`store`](./store.md)
|
||||
|
59
doc/apps/server-api/app.md
Normal file
59
doc/apps/server-api/app.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Module `app`
|
||||
|
||||
Ce module permet de récupérer des informations sur les applications actives dans l'environnement Edge courant.
|
||||
|
||||
## Méthodes
|
||||
|
||||
### `app.list(ctx: Context): []Manifest`
|
||||
|
||||
Récupère la liste des applications actives.
|
||||
|
||||
#### Arguments
|
||||
|
||||
- `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md)
|
||||
|
||||
#### Valeur de retour
|
||||
|
||||
Liste des objets `Manifest` décrivant chaque application active.
|
||||
|
||||
### `app.get(ctx: Context, appId: string): Manifest`
|
||||
|
||||
Récupère les informations de l'application identifiée par `appId`.
|
||||
|
||||
#### Arguments
|
||||
|
||||
- `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md)
|
||||
- `appId` **string** Identifiant de l'application
|
||||
|
||||
#### Valeur de retour
|
||||
|
||||
Objet `Manifest` associé à l'application, ou `null` si aucune application n'a été trouvée correspondant à l'identifiant.
|
||||
|
||||
### `app.getUrl(ctx: Context, appId: string, from: string = ''): Manifest`
|
||||
|
||||
Retourne l'URL permettant d'accéder à l'application identifiée par `appId`.
|
||||
|
||||
#### Arguments
|
||||
|
||||
- `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md)
|
||||
- `appId` **string** Identifiant de l'application
|
||||
- `from` **string** Adresse IP qui accédera à l'application (permet de générer la bonne URL vis à vis du réseau d'origine)
|
||||
|
||||
#### Valeur de retour
|
||||
|
||||
URL associée à l'application, ou `null` si aucune application n'a été trouvée correspondant à l'identifiant.
|
||||
|
||||
## Objets
|
||||
|
||||
### `Manifest`
|
||||
|
||||
```typescript
|
||||
interface Manifest {
|
||||
id: string // Identifiant de l'application
|
||||
version: string // Version de l'application
|
||||
title: string // Titre associé à l'application
|
||||
description: string // Description associée à l'application
|
||||
tags: string[] // Mots clés associés à l'application
|
||||
metadata: { [key: string]: any } // Métadonnées associées à l'application. Voir ../manifest.md
|
||||
}
|
||||
```
|
@ -38,11 +38,15 @@ function onBlobDownload(ctx, bucketName, blobId) {
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `blob.writeBlob(ctx: Context, bucketName: string, blobId: string)`
|
||||
### `blob.getBlobInfo(ctx: Context, bucketName: string, blobId: string): BlobInfo`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `blob.readBlob(ctx: Context, bucketName: string, blobId: string)`
|
||||
### `blob.writeBlob(ctx: Context, bucketName: string, blobId: string, data: any)`
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `blob.readBlob(ctx: Context, bucketName: string, blobId: string): ArrayBuffer`
|
||||
|
||||
> `TODO`
|
||||
|
||||
@ -58,7 +62,7 @@ function onBlobDownload(ctx, bucketName, blobId) {
|
||||
|
||||
> `TODO`
|
||||
|
||||
### `blob.getBlobInfo(ctx: Context, bucketName: string, blobId: string): BlobInfo`
|
||||
### `blob.getBucketSize(ctx: Context, bucketName: string): number`
|
||||
|
||||
> `TODO`
|
||||
|
||||
@ -70,4 +74,16 @@ Voir la documentation de l'objet [`Context`](./context.md#Context).
|
||||
|
||||
### `BlobInfo`
|
||||
|
||||
```typescript
|
||||
interface BlobInfo {
|
||||
id: string // Identifiant du blob
|
||||
bucket: string // Nom du bucket contenant le blob
|
||||
size: number // Taille du blob
|
||||
modTime: number // Timestamp Unix de dernière modification du blob
|
||||
contentType: string // Type MIME du contenu du blob
|
||||
}
|
||||
```
|
||||
|
||||
### `Metadata`
|
||||
|
||||
L'objet `Metadata` est un objet clé/valeur arbitraire transmis avec la requête de téléversement. Voir la méthode [`Edge.Client.upload(blob, metadata)`](../client-api/README.md#edge-upload-blob-blob-metadata-object-promise) du SDK client.
|
33
doc/apps/server-api/fetch.md
Normal file
33
doc/apps/server-api/fetch.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Module `fetch`
|
||||
|
||||
Ce module permet l'accès à des ressources distantes (sur Internet) depuis votre application.
|
||||
|
||||
## Fonctions de rappel
|
||||
|
||||
Pour permettre aux utilisateurs d'accéder à des ressources distantes, vous devez déclarer la fonction `onClientFetch(ctx: Context, url: string, remoteAddr: string)` dans le fichier `server/main.js` de votre application.
|
||||
|
||||
### `onClientFetch(ctx: Context, url: string, remoteAddr: string)`
|
||||
|
||||
#### Usage
|
||||
|
||||
**Côté client**
|
||||
```js
|
||||
// Création d'une URL "locale" permettant d'accéder à la ressource distante
|
||||
var url = Edge.Client.externalUrl("http://example.com")
|
||||
|
||||
// Vous pouvez utiliser l'URL comme attribut `src` d'une balise <img> par exemple
|
||||
// ou effectuer une requête fetch() avec celle ci.
|
||||
fetch(url).then(res => res.text()).then(content => console.log(content));
|
||||
```
|
||||
|
||||
**Côté serveur**
|
||||
```js
|
||||
function onClientFetch(ctx, url, remoteAddr) {
|
||||
// Autoriser la récupération de l'URL demandée ou non
|
||||
// Dans cet exemple, seule l'URL externe 'http://example.com' est autorisée
|
||||
// Les autres URLs recevront une erreur HTTP 403 - Forbidden
|
||||
var authorized = url === "http://example.com"
|
||||
|
||||
return { allow: authorized };
|
||||
}
|
||||
```
|
@ -32,9 +32,9 @@ Aucune
|
||||
```js
|
||||
// Les données envoyées par le serveur sont accessibles
|
||||
// via la propriété evt.detail.
|
||||
Edge.on('message', evt => console.log(evt.detail));
|
||||
Edge.Client.on('message', evt => console.log(evt.detail));
|
||||
|
||||
Edge.connect();
|
||||
Edge.Client.connect();
|
||||
```
|
||||
|
||||
**Côté serveur**
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Module `rpc`
|
||||
|
||||
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
|
||||
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.Client.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
|
||||
|
||||
## Méthodes
|
||||
|
||||
@ -31,8 +31,8 @@ function echo(ctx, params) {
|
||||
**Côté client**
|
||||
|
||||
```js
|
||||
Edge.connect().then(() => {
|
||||
Edge.rpc("echo", { hello: "world!" })
|
||||
Edge.Client.connect().then(() => {
|
||||
Edge.Client.rpc("echo", { hello: "world!" })
|
||||
.then(result => console.log(result))
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
|
@ -178,6 +178,6 @@ var results = store.query(ctx, "myCollection", {
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
orderBy: "foo",
|
||||
orderDirection: store.ASC,
|
||||
orderDirection: store.DIRECTION_ASC,
|
||||
});
|
||||
```
|
||||
|
57
go.mod
57
go.mod
@ -2,14 +2,29 @@ module forge.cadoles.com/arcad/edge
|
||||
|
||||
go 1.19
|
||||
|
||||
require modernc.org/sqlite v1.20.4
|
||||
require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.8
|
||||
modernc.org/sqlite v1.20.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/brutella/dnssd v1.2.6 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
|
||||
github.com/go-playground/locales v0.12.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.16.0 // indirect
|
||||
github.com/goccy/go-json v0.9.11 // indirect
|
||||
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 // indirect
|
||||
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
|
||||
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 // indirect
|
||||
github.com/leodido/go-urn v1.1.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.4 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.0 // indirect
|
||||
github.com/miekg/dns v1.1.50 // indirect
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@ -20,40 +35,40 @@ require (
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/dop251/goja v0.0.0-20230203172422-5460598cfa32 // indirect
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230207183254-2229640ea097 // indirect
|
||||
github.com/dop251/goja v0.0.0-20230203172422-5460598cfa32
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230207183254-2229640ea097
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/fatih/color v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.0.8 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/go-chi/chi/v5 v5.0.8
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/igm/sockjs-go/v3 v3.0.2 // indirect
|
||||
github.com/igm/sockjs-go/v3 v3.0.2
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.0 // indirect
|
||||
github.com/orcaman/concurrent-map v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/orcaman/concurrent-map v1.0.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spf13/afero v1.9.3 // indirect
|
||||
github.com/urfave/cli/v2 v2.24.3 // indirect
|
||||
github.com/urfave/cli/v2 v2.24.3
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 // indirect
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
golang.org/x/net v0.4.0 // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/term v0.3.0 // indirect
|
||||
golang.org/x/text v0.5.0 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/mod v0.8.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/term v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
|
61
go.sum
61
go.sum
@ -54,6 +54,8 @@ github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MR
|
||||
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
|
||||
github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692 h1:JW4WZlqyaNWUUahfr7MigeDW6jmtam5cTzzo1lwsFhE=
|
||||
github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692/go.mod h1:Au0ipPuCBA7zsOC61SnyrYetm8VT3vo1UJtwHeYke44=
|
||||
github.com/brutella/dnssd v1.2.6 h1:/0P13JkHLRzeLQkWRPEn4hJCr4T3NfknIFw3aNPIC34=
|
||||
github.com/brutella/dnssd v1.2.6/go.mod h1:JoW2sJUrmVIef25G6lrLj7HS6Xdwh6q8WUIvMkkBYXs=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
@ -72,6 +74,9 @@ github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e/go.mod h1:J7Y8Yc
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
|
||||
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
|
||||
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
@ -104,10 +109,14 @@ github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITL
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
|
||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
|
||||
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e h1:eeyMpoxANuWNQ9O2auv4wXxJsrXzLUhdHaOmNWEGkRY=
|
||||
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
@ -203,7 +212,20 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
|
||||
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
|
||||
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
|
||||
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.8 h1:jCFT8oc0hEDVjgUgsBy1F9cbjsjAVZSXNi7JaU9HR/Q=
|
||||
github.com/lestrrat-go/jwx/v2 v2.0.8/go.mod h1:zLxnyv9rTlEvOUHbc48FAfIL8iYu2hHvIRaTFGc8mT0=
|
||||
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
|
||||
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
|
||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
@ -215,6 +237,8 @@ github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peK
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 h1:ALvJ9V8nNf04PFHMR2sot56N/pjrx5LzZGvUlnhdiCE=
|
||||
github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
|
||||
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
@ -243,12 +267,18 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
|
||||
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
|
||||
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/urfave/cli/v2 v2.24.3 h1:7Q1w8VN8yE0MJEHP06bv89PjYsN4IHWED2s1v/Zlfm0=
|
||||
github.com/urfave/cli/v2 v2.24.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
@ -260,9 +290,12 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 h1:6JlkcdjYVQglPWYuemK2MoZAtRE4vFx85zLXflGIyI8=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b h1:nkvOl8TCj/mErADnwFFynjxBtC+hHsrESw6rw56JGmg=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@ -280,6 +313,10 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
|
||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f h1:OeJjE6G4dgCY4PIXvIRQbE8+RX+uXZyGhUy/ksMGJoc=
|
||||
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@ -313,8 +350,11 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20161013035702-8b4af36cd21a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -347,10 +387,15 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@ -370,6 +415,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -407,19 +453,27 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@ -432,6 +486,8 @@ golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@ -482,8 +538,11 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@ -582,11 +641,13 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
@ -5,3 +5,8 @@ version: 0.0.0
|
||||
description: |
|
||||
Suite de tests pour le SDK client
|
||||
tags: ["test"]
|
||||
|
||||
metadata:
|
||||
paths:
|
||||
icon: /icon.png
|
||||
minimumRole: superadmin
|
BIN
misc/client-sdk-testsuite/src/public/icon.png
Normal file
BIN
misc/client-sdk-testsuite/src/public/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 77 KiB |
@ -4,10 +4,14 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>Client SDK Test suite</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/icon.png">
|
||||
<link rel="stylesheet" href="/vendor/mocha.css" />
|
||||
<style>
|
||||
body {
|
||||
background-color: white;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
body:not([edge-auto-padding="false"]) #mocha-stats {
|
||||
top: 75px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -22,8 +26,24 @@
|
||||
<script src="/edge/sdk/client.js"></script>
|
||||
<script src="/test/client-sdk.js"></script>
|
||||
<script src="/test/auth-module.js"></script>
|
||||
<script src="/test/net-module.js"></script>
|
||||
<script src="/test/rpc-module.js"></script>
|
||||
<script src="/test/file-module.js"></script>
|
||||
<script src="/test/app-module.js"></script>
|
||||
<script src="/test/fetch-module.js"></script>
|
||||
<script class="mocha-exec">
|
||||
mocha.run();
|
||||
|
||||
Edge.Menu
|
||||
.setAppIconUrl('/icon.png')
|
||||
.setAppTitle('SDK Tests')
|
||||
.setItem('client', 'Client', { linkUrl: '/?grep=Edge', order: 1 })
|
||||
.setItem('auth-module', 'Auth Module', { linkUrl: '/?grep=Auth%20Module' , order: 4})
|
||||
.setItem('net-module', 'Net Module', { linkUrl: '/?grep=Net%20Module' , order: 3})
|
||||
.setItem('rpc', 'Remote Procedure Call', { linkUrl: '/?grep=Remote%20Procedure%20Call' , order: 5})
|
||||
.setItem('file-module', 'File Module', { linkUrl: '/?grep=File%20Module', order: 6})
|
||||
.setItem('app-module', 'App Module', { linkUrl: '/?grep=App%20Module' , order: 7})
|
||||
.setItem('fetch-module', 'Fetch Module', { linkUrl: '/?grep=Fetch%20Module' , order: 8})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
45
misc/client-sdk-testsuite/src/public/test/app-module.js
Normal file
45
misc/client-sdk-testsuite/src/public/test/app-module.js
Normal file
@ -0,0 +1,45 @@
|
||||
describe('App Module', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should list apps', function() {
|
||||
return Edge.Client.rpc("listApps")
|
||||
.then(apps => {
|
||||
console.log("listApps result:", apps);
|
||||
chai.assert.isNotNull(apps);
|
||||
chai.assert.isAtLeast(apps.length, 1);
|
||||
})
|
||||
});
|
||||
|
||||
it('should retrieve requested app', function() {
|
||||
return Edge.Client.rpc("getApp", { appId: "edge.sdk.client.test" })
|
||||
.then(app => {
|
||||
console.log("getApp result:", app);
|
||||
chai.assert.isNotNull(app);
|
||||
chai.assert.equal(app.id, "edge.sdk.client.test");
|
||||
})
|
||||
});
|
||||
|
||||
it('should retrieve requested app url without from address', function() {
|
||||
return Edge.Client.rpc("getAppUrl", { appId: "edge.sdk.client.test" })
|
||||
.then(url => {
|
||||
console.log("getAppUrl result:", url);
|
||||
chai.assert.isNotEmpty(url);
|
||||
})
|
||||
});
|
||||
|
||||
it('should retrieve requested app url with from address', function() {
|
||||
return Edge.Client.rpc("getAppUrl", { appId: "edge.sdk.client.test", from: "127.0.0.2" })
|
||||
.then(url => {
|
||||
console.log("getAppUrl result:", url);
|
||||
chai.assert.isNotEmpty(url);
|
||||
})
|
||||
});
|
||||
|
||||
});
|
@ -1,19 +1,20 @@
|
||||
describe('Auth Module', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should retrieve user informations', function() {
|
||||
return Edge.rpc("getUserInfo")
|
||||
return Edge.Client.rpc("getUserInfo")
|
||||
.then(userInfo => {
|
||||
chai.assert.isNotNull(userInfo.subject);
|
||||
chai.assert.isNotNull(userInfo.role);
|
||||
chai.assert.isNotNull(userInfo.preferredUsername);
|
||||
console.log("getUserInfo result:", userInfo);
|
||||
chai.assert.property(userInfo, 'subject');
|
||||
chai.assert.property(userInfo, 'role');
|
||||
chai.assert.property(userInfo, 'preferredUsername');
|
||||
})
|
||||
});
|
||||
|
||||
|
@ -1,148 +1,26 @@
|
||||
Edge.debug = true;
|
||||
Edge.Client.debug = true;
|
||||
Edge.Frame.debug = true;
|
||||
|
||||
describe('Edge', function() {
|
||||
|
||||
describe('#connect()', function() {
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should open the connection', function() {
|
||||
return Edge.connect()
|
||||
return Edge.Client.connect()
|
||||
.then(() => {
|
||||
chai.assert.isNotNull(Edge._conn);
|
||||
chai.assert.isNotNull(Edge.Client._conn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#disconnect()', function() {
|
||||
it('should close the connection', function() {
|
||||
Edge.disconnect();
|
||||
chai.assert.isNull(Edge._conn);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#send()', function() {
|
||||
this.timeout(5000);
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
});
|
||||
|
||||
it('should send a message to the server and echo back', function(done) {
|
||||
const now = new Date();
|
||||
const handler = evt => {
|
||||
chai.assert.equal(evt.detail.now, now.toJSON());
|
||||
Edge.removeEventListener('message', handler);
|
||||
done();
|
||||
}
|
||||
|
||||
// Server should echo back message
|
||||
Edge.addEventListener('message', handler);
|
||||
|
||||
// Send message to server
|
||||
Edge.send({ now });
|
||||
Edge.Client.disconnect();
|
||||
chai.assert.isNull(Edge.Client._conn);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Remote Procedure Call', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
});
|
||||
|
||||
it('should call the remote echo() method and resolve the returned value', function() {
|
||||
const foo = "bar";
|
||||
|
||||
return Edge.rpc('echo', { foo })
|
||||
.then(result => {
|
||||
chai.assert.equal(result.foo, foo);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the remote throwErrorFromClient() method and reject with an error', function() {
|
||||
return Edge.rpc('throwErrorFromClient')
|
||||
.catch(err => {
|
||||
// Assert that it's an "internal" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
chai.assert.equal(err.code, -32603);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call an unregistered method and reject with an error', function() {
|
||||
return Edge.rpc('unregisteredMethod')
|
||||
.catch(err => {
|
||||
// Assert that it's an "method not found" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
chai.assert.equal(err.code, -32601);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should call the add() method repetitively and keep count of the sent values', function() {
|
||||
this.timeout(10000);
|
||||
|
||||
const values = [];
|
||||
for(let i = 0; i <= 1000; i++) {
|
||||
values.push((Math.random() * 1000 | 0));
|
||||
}
|
||||
return Edge.rpc('reset')
|
||||
.then(() => {
|
||||
return Promise.all(values.map(v => Edge.rpc("add", {value: v})));
|
||||
})
|
||||
.then(() => Edge.rpc('total'))
|
||||
.then(remoteTotal => {
|
||||
const localTotal = values.reduce((t, v) => t+v);
|
||||
console.log("Remote total:", remoteTotal, "Local total:", localTotal);
|
||||
chai.assert.equal(remoteTotal, localTotal)
|
||||
})
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('File Module', function() {
|
||||
|
||||
before(() => {
|
||||
return Edge.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.disconnect();
|
||||
});
|
||||
|
||||
it('should upload then download a blob', function() {
|
||||
const content = JSON.stringify({"date": new Date()});
|
||||
const blob = new Blob([content], {type: "application/json"});
|
||||
|
||||
return Edge.upload(blob)
|
||||
.then(upload => upload.result())
|
||||
.then(result => {
|
||||
|
||||
chai.assert.isNotEmpty(result.blobId);
|
||||
chai.assert.isNotEmpty(result.bucket);
|
||||
|
||||
const blobUrl = Edge.blobUrl(result.bucket, result.blobId);
|
||||
chai.assert.isNotEmpty(blobUrl);
|
||||
|
||||
return fetch(blobUrl)
|
||||
.then(res => res.text())
|
||||
.then(blobContent => {
|
||||
chai.assert.equal(content, blobContent);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
chai.assert.fail(err);
|
||||
})
|
||||
});
|
||||
|
||||
});
|
||||
|
33
misc/client-sdk-testsuite/src/public/test/fetch-module.js
Normal file
33
misc/client-sdk-testsuite/src/public/test/fetch-module.js
Normal file
@ -0,0 +1,33 @@
|
||||
describe('Fetch Module', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should fetch an authorized external url', function () {
|
||||
var externalUrl = Edge.Client.externalUrl("http://example.com");
|
||||
|
||||
return fetch(externalUrl)
|
||||
.then(res => {
|
||||
chai.assert.equal(res.status, 200)
|
||||
return res.text()
|
||||
})
|
||||
.then(content => {
|
||||
chai.assert.include(content, '<h1>Example Domain</h1>')
|
||||
})
|
||||
});
|
||||
|
||||
it('should not fetch an unauthorized external url', function () {
|
||||
var externalUrl = Edge.Client.externalUrl("https://google.com");
|
||||
|
||||
return fetch(externalUrl)
|
||||
.then(res => {
|
||||
chai.assert.equal(res.status, 403)
|
||||
})
|
||||
});
|
||||
|
||||
});
|
36
misc/client-sdk-testsuite/src/public/test/file-module.js
Normal file
36
misc/client-sdk-testsuite/src/public/test/file-module.js
Normal file
@ -0,0 +1,36 @@
|
||||
describe('File Module', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should upload then download a blob', function () {
|
||||
const content = JSON.stringify({ "date": new Date() });
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
|
||||
return Edge.Client.upload(blob)
|
||||
.then(upload => upload.result())
|
||||
.then(result => {
|
||||
|
||||
chai.assert.isNotEmpty(result.blobId);
|
||||
chai.assert.isNotEmpty(result.bucket);
|
||||
|
||||
const blobUrl = Edge.Client.blobUrl(result.bucket, result.blobId);
|
||||
chai.assert.isNotEmpty(blobUrl);
|
||||
|
||||
return fetch(blobUrl)
|
||||
.then(res => res.text())
|
||||
.then(blobContent => {
|
||||
chai.assert.equal(content, blobContent);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
chai.assert.fail(err);
|
||||
})
|
||||
});
|
||||
|
||||
});
|
49
misc/client-sdk-testsuite/src/public/test/net-module.js
Normal file
49
misc/client-sdk-testsuite/src/public/test/net-module.js
Normal file
@ -0,0 +1,49 @@
|
||||
describe('Net Module', function () {
|
||||
this.timeout(5000);
|
||||
|
||||
before(() => {
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should broadcast a message from server', function (done) {
|
||||
const message = { test: 'broadcast', now: Date.now() };
|
||||
|
||||
const handler = (evt) => {
|
||||
const receivedMessage = evt.detail;
|
||||
if (receivedMessage.test !== 'broadcast') return;
|
||||
|
||||
chai.assert.deepEqual(message, evt.detail);
|
||||
|
||||
Edge.Client.removeEventListener('message', handler);
|
||||
done();
|
||||
};
|
||||
|
||||
Edge.Client.addEventListener("message", handler);
|
||||
Edge.Client.send(message);
|
||||
});
|
||||
|
||||
it('should send a message to the server and echo back', function(done) {
|
||||
const now = new Date();
|
||||
|
||||
const handler = evt => {
|
||||
const receivedMessage = evt.detail;
|
||||
if (receivedMessage.test !== 'echo') return;
|
||||
|
||||
chai.assert.equal(receivedMessage.now, now.toJSON());
|
||||
|
||||
Edge.Client.removeEventListener('message', handler);
|
||||
done();
|
||||
}
|
||||
|
||||
// Server should echo back message
|
||||
Edge.Client.addEventListener('message', handler);
|
||||
|
||||
// Send message to server
|
||||
Edge.Client.send({ test: 'echo', now });
|
||||
});
|
||||
|
||||
});
|
59
misc/client-sdk-testsuite/src/public/test/rpc-module.js
Normal file
59
misc/client-sdk-testsuite/src/public/test/rpc-module.js
Normal file
@ -0,0 +1,59 @@
|
||||
describe('Remote Procedure Call', function () {
|
||||
|
||||
before(() => {
|
||||
return Edge.Client.connect();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
Edge.Client.disconnect();
|
||||
});
|
||||
|
||||
it('should call the remote echo() method and resolve the returned value', function () {
|
||||
const foo = "bar";
|
||||
|
||||
return Edge.Client.rpc('echo', { foo })
|
||||
.then(result => {
|
||||
console.log(result);
|
||||
chai.assert.equal(result.foo, foo);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the remote throwErrorFromClient() method and reject with an error', function () {
|
||||
return Edge.Client.rpc('throwErrorFromClient')
|
||||
.catch(err => {
|
||||
// Assert that it's an "internal" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
chai.assert.equal(err.code, -32603);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call an unregistered method and reject with an error', function () {
|
||||
return Edge.Client.rpc('unregisteredMethod')
|
||||
.catch(err => {
|
||||
// Assert that it's an "method not found" error
|
||||
// See https://www.jsonrpc.org/specification#error_object
|
||||
chai.assert.equal(err.code, -32601);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should call the add() method repetitively and keep count of the sent values', function () {
|
||||
this.timeout(30000);
|
||||
|
||||
const values = [];
|
||||
for (let i = 0; i <= 1000; i++) {
|
||||
values.push((Math.random() * 1000 | 0));
|
||||
}
|
||||
return Edge.Client.rpc('reset')
|
||||
.then(() => {
|
||||
return Promise.all(values.map(v => Edge.Client.rpc("add", { value: v })));
|
||||
})
|
||||
.then(() => Edge.Client.rpc('total'))
|
||||
.then(remoteTotal => {
|
||||
const localTotal = values.reduce((t, v) => t + v);
|
||||
console.log("Remote total:", remoteTotal, "Local total:", localTotal);
|
||||
chai.assert.equal(remoteTotal, localTotal)
|
||||
})
|
||||
});
|
||||
|
||||
});
|
@ -11,12 +11,23 @@ function onInit() {
|
||||
rpc.register("reset", reset);
|
||||
rpc.register("total", total);
|
||||
rpc.register("getUserInfo", getUserInfo);
|
||||
|
||||
rpc.register("listApps");
|
||||
rpc.register("getApp");
|
||||
rpc.register("getAppUrl");
|
||||
}
|
||||
|
||||
// Called for each client message
|
||||
function onClientMessage(ctx, data) {
|
||||
console.log("onClientMessage", data.now);
|
||||
net.send(ctx, { now: data.now });
|
||||
function onClientMessage(ctx, message) {
|
||||
console.log("onClientMessage", message);
|
||||
|
||||
switch (message.test) {
|
||||
case "broadcast":
|
||||
net.broadcast(message);
|
||||
break;
|
||||
default:
|
||||
net.send(ctx, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Called for each blob upload request
|
||||
@ -73,3 +84,23 @@ function getUserInfo(ctx, params) {
|
||||
preferredUsername: preferredUsername,
|
||||
};
|
||||
}
|
||||
|
||||
function listApps(ctx) {
|
||||
return app.list(ctx);
|
||||
}
|
||||
|
||||
function getApp(ctx, params) {
|
||||
var appId = params.appId;
|
||||
return app.get(ctx, appId);
|
||||
}
|
||||
|
||||
function getAppUrl(ctx, params) {
|
||||
var appId = params.appId;
|
||||
var from = params.from;
|
||||
|
||||
return app.getUrl(ctx, appId, from ? from : '');
|
||||
}
|
||||
|
||||
function onClientFetch(ctx, url, remoteAddr) {
|
||||
return { allow: url === 'http://example.com' };
|
||||
}
|
28
misc/jenkins/Dockerfile
Normal file
28
misc/jenkins/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
FROM reg.cadoles.com/proxy_cache/library/ubuntu:22.04
|
||||
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG http_proxy=
|
||||
ARG https_proxy=
|
||||
ARG GO_VERSION=1.19.2
|
||||
|
||||
# Install dev environment dependencies
|
||||
RUN export DEBIAN_FRONTEND=noninteractive &&\
|
||||
apt-get update -y &&\
|
||||
apt-get install -y --no-install-recommends curl ca-certificates build-essential wget unzip tar git jq
|
||||
|
||||
# Install Go
|
||||
RUN mkdir -p /tmp \
|
||||
&& wget -O /tmp/go${GO_VERSION}.linux-amd64.tar.gz https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz \
|
||||
&& rm -rf /usr/local/go \
|
||||
&& mkdir -p /usr/local \
|
||||
&& tar -C /usr/local -xzf /tmp/go${GO_VERSION}.linux-amd64.tar.gz
|
||||
|
||||
ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
|
||||
# Add LetsEncrypt certificates
|
||||
RUN curl -k https://forge.cadoles.com/Cadoles/Jenkins/raw/branch/master/resources/com/cadoles/common/add-letsencrypt-ca.sh | bash
|
||||
|
||||
# Install NodeJS
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs
|
14
modd.conf
14
modd.conf
@ -1,12 +1,16 @@
|
||||
**/*.go
|
||||
pkg/app/sdk/client/src/**/*.js
|
||||
pkg/app/sdk/client/src/**/*.ts
|
||||
**/*.tmpl
|
||||
pkg/sdk/client/src/**/*.js
|
||||
pkg/sdk/client/src/**/*.ts
|
||||
misc/client-sdk-testsuite/src/**/*
|
||||
modd.conf
|
||||
{
|
||||
prep: make build-sdk
|
||||
prep: cd misc/client-sdk-testsuite && make dist
|
||||
prep: make GOTEST_ARGS="-short" test
|
||||
prep: make build-client-sdk-test-app
|
||||
prep: make build
|
||||
daemon: bin/cli app run -p misc/client-sdk-testsuite/dist --storage-file ./sdk-testsuite.sqlite
|
||||
daemon: make run-app
|
||||
}
|
||||
|
||||
**/*.go {
|
||||
prep: make GOTEST_ARGS="-short" test
|
||||
}
|
120
package-lock.json
generated
120
package-lock.json
generated
@ -10,14 +10,50 @@
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"core-js": "^3.30.1",
|
||||
"lit": "^2.7.2",
|
||||
"sockjs-client": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
|
||||
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
|
||||
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sockjs-client": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
|
||||
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
|
||||
},
|
||||
"node_modules/@webcomponents/webcomponentsjs": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
|
||||
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
|
||||
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@ -55,6 +91,34 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
|
||||
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-element": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
|
||||
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-html": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
|
||||
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -139,11 +203,39 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
|
||||
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
|
||||
},
|
||||
"@lit/reactive-element": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
|
||||
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
|
||||
"requires": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"@types/sockjs-client": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
|
||||
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
|
||||
},
|
||||
"@types/trusted-types": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
|
||||
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
|
||||
},
|
||||
"@webcomponents/webcomponentsjs": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
|
||||
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
|
||||
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@ -175,6 +267,34 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"lit": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
|
||||
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
|
||||
"requires": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"lit-element": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
|
||||
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
|
||||
"requires": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"lit-html": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
|
||||
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
|
||||
"requires": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
@ -10,6 +10,9 @@
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"core-js": "^3.30.1",
|
||||
"lit": "^2.7.2",
|
||||
"sockjs-client": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
package app
|
||||
|
||||
type ID string
|
||||
|
||||
type Manifest struct {
|
||||
ID ID `yaml:"id"`
|
||||
Version string `yaml:"version"`
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ID ID
|
||||
Manifest *Manifest
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type FilesystemLoader struct {
|
||||
searchPatterns []string
|
||||
}
|
||||
|
||||
type LoadedApp struct {
|
||||
App *App
|
||||
Bundle bundle.Bundle
|
||||
}
|
||||
|
||||
func (l *FilesystemLoader) Load(ctx context.Context) ([]*LoadedApp, error) {
|
||||
apps := make([]*LoadedApp, 0)
|
||||
|
||||
for _, seachPattern := range l.searchPatterns {
|
||||
absSearchPattern, err := filepath.Abs(seachPattern)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not generate absolute path for '%s'", seachPattern)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "searching apps in filesystem", logger.F("searchPattern", absSearchPattern))
|
||||
|
||||
files, err := filepath.Glob(absSearchPattern)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not search files with pattern '%s'", absSearchPattern)
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
loopCtx := logger.With(ctx, logger.F("file", f))
|
||||
|
||||
logger.Debug(loopCtx, "found app bundle")
|
||||
|
||||
b, err := bundle.FromPath(f)
|
||||
if err != nil {
|
||||
logger.Error(loopCtx, "could not load bundle", logger.E(errors.WithStack(err)))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug(loopCtx, "loading app manifest")
|
||||
|
||||
appManifest, err := LoadAppManifest(b)
|
||||
if err != nil {
|
||||
logger.Error(loopCtx, "could not load app manifest", logger.E(errors.WithStack(err)))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
g := &App{
|
||||
ID: appManifest.ID,
|
||||
Manifest: appManifest,
|
||||
}
|
||||
|
||||
apps = append(apps, &LoadedApp{
|
||||
App: g,
|
||||
Bundle: b,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func NewFilesystemLoader(searchPatterns ...string) *FilesystemLoader {
|
||||
return &FilesystemLoader{
|
||||
searchPatterns: searchPatterns,
|
||||
}
|
||||
}
|
||||
|
||||
func LoadAppManifest(b bundle.Bundle) (*Manifest, error) {
|
||||
reader, _, err := b.File("manifest.yml")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read manifest.yml")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
manifest := &Manifest{}
|
||||
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
if err := decoder.Decode(manifest); err != nil {
|
||||
return nil, errors.Wrap(err, "could not decode manifest.yml")
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
85
pkg/app/manifest.go
Normal file
85
pkg/app/manifest.go
Normal file
@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bundle"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/mod/semver"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type ID string
|
||||
|
||||
type Manifest struct {
|
||||
ID ID `yaml:"id" json:"id"`
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
Metadata MapStr `yaml:"metadata" json:"metadata"`
|
||||
}
|
||||
|
||||
type MetadataValidator func(map[string]any) (bool, error)
|
||||
|
||||
func (m *Manifest) Validate(validators ...MetadataValidator) (bool, error) {
|
||||
if m.ID == "" {
|
||||
return false, errors.New("'id' property should not be empty")
|
||||
}
|
||||
|
||||
if m.Version == "" {
|
||||
return false, errors.New("'version' property should not be empty")
|
||||
}
|
||||
|
||||
version := m.Version
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
|
||||
if !semver.IsValid(version) {
|
||||
return false, errors.Errorf("version '%s' does not respect semver format", m.Version)
|
||||
}
|
||||
|
||||
if m.Title == "" {
|
||||
return false, errors.New("'title' property should not be empty")
|
||||
}
|
||||
|
||||
if m.Tags != nil {
|
||||
for _, t := range m.Tags {
|
||||
if strings.ContainsAny(t, " \t\n\r") {
|
||||
return false, errors.Errorf("tag '%s' should not contain any space or new line", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range validators {
|
||||
valid, err := v(m.Metadata)
|
||||
if !valid || err != nil {
|
||||
return valid, errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func LoadManifest(b bundle.Bundle) (*Manifest, error) {
|
||||
reader, _, err := b.File("manifest.yml")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read manifest.yml")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
manifest := &Manifest{}
|
||||
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
if err := decoder.Decode(manifest); err != nil {
|
||||
return nil, errors.Wrap(err, "could not decode manifest.yml")
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
61
pkg/app/map_str.go
Normal file
61
pkg/app/map_str.go
Normal file
@ -0,0 +1,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type MapStr map[string]interface{}
|
||||
|
||||
func MapStrUnion(dict1 MapStr, dict2 MapStr) MapStr {
|
||||
dict := MapStr{}
|
||||
|
||||
for k, v := range dict1 {
|
||||
dict[k] = v
|
||||
}
|
||||
|
||||
for k, v := range dict2 {
|
||||
dict[k] = v
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
func (ms *MapStr) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var result map[interface{}]interface{}
|
||||
err := unmarshal(&result)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
*ms = cleanUpInterfaceMap(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanUpInterfaceArray(in []interface{}) []interface{} {
|
||||
result := make([]interface{}, len(in))
|
||||
for i, v := range in {
|
||||
result[i] = cleanUpMapValue(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cleanUpInterfaceMap(in map[interface{}]interface{}) MapStr {
|
||||
result := make(MapStr)
|
||||
for k, v := range in {
|
||||
result[fmt.Sprintf("%v", k)] = cleanUpMapValue(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cleanUpMapValue(v interface{}) interface{} {
|
||||
switch v := v.(type) {
|
||||
case []interface{}:
|
||||
return cleanUpInterfaceArray(v)
|
||||
case map[interface{}]interface{}:
|
||||
return cleanUpInterfaceMap(v)
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
28
pkg/app/metadata/minimum_role.go
Normal file
28
pkg/app/metadata/minimum_role.go
Normal file
@ -0,0 +1,28 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func WithMinimumRoleValidator(roles ...string) app.MetadataValidator {
|
||||
return func(metadata map[string]any) (bool, error) {
|
||||
rawMinimumRole, exists := metadata["minimumRole"]
|
||||
if !exists {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
minimumRole, ok := rawMinimumRole.(string)
|
||||
if !ok {
|
||||
return false, errors.Errorf("metadata['minimumRole']: unexpected value type '%T'", rawMinimumRole)
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
if minimumRole == r {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, errors.Errorf("metadata['minimumRole']: unexpected role '%s'", minimumRole)
|
||||
}
|
||||
}
|
51
pkg/app/metadata/named_path.go
Normal file
51
pkg/app/metadata/named_path.go
Normal file
@ -0,0 +1,51 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type NamedPath string
|
||||
|
||||
const (
|
||||
NamedPathAdmin NamedPath = "admin"
|
||||
NamedPathIcon NamedPath = "icon"
|
||||
)
|
||||
|
||||
func WithNamedPathsValidator(names ...NamedPath) app.MetadataValidator {
|
||||
set := map[NamedPath]struct{}{}
|
||||
for _, n := range names {
|
||||
set[n] = struct{}{}
|
||||
}
|
||||
|
||||
return func(metadata map[string]any) (bool, error) {
|
||||
rawPaths, exists := metadata["paths"]
|
||||
if !exists {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
paths, ok := rawPaths.(app.MapStr)
|
||||
if !ok {
|
||||
return false, errors.Errorf("metadata['paths']: unexpected named path value type '%T'", rawPaths)
|
||||
}
|
||||
|
||||
for n, p := range paths {
|
||||
if _, exists := set[NamedPath(n)]; !exists {
|
||||
return false, errors.Errorf("metadata['paths']: unexpected named path '%s'", n)
|
||||
}
|
||||
|
||||
path, ok := p.(string)
|
||||
if !ok {
|
||||
return false, errors.Errorf("metadata['paths']['%s']: unexpected named path value type '%T'", n, path)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return false, errors.Errorf("metadata['paths']['%s']: named path value should start with a '/'", n)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
7
pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml
vendored
Normal file
7
pkg/app/metadata/testdata/manifests/invalid-minimum-role.yml
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
id: foo.arcad.app
|
||||
version: v0.0.0
|
||||
title: Foo
|
||||
description: A test app
|
||||
tags: ["test"]
|
||||
metadata:
|
||||
minimumRole: foo
|
10
pkg/app/metadata/testdata/manifests/invalid-paths.yml
vendored
Normal file
10
pkg/app/metadata/testdata/manifests/invalid-paths.yml
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
id: foo.arcad.app
|
||||
version: v0.0.0
|
||||
title: Foo
|
||||
description: A test app
|
||||
tags: ["test"]
|
||||
metadata:
|
||||
paths:
|
||||
invalid: /admin
|
||||
icon: /my-app-icon.png
|
||||
minimumRole: visitor
|
10
pkg/app/metadata/testdata/manifests/valid.yml
vendored
Normal file
10
pkg/app/metadata/testdata/manifests/valid.yml
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
id: foo.arcad.app
|
||||
version: v0.0.0
|
||||
title: Foo
|
||||
description: A test app
|
||||
tags: ["test"]
|
||||
metadata:
|
||||
paths:
|
||||
admin: /admin
|
||||
icon: /my-app-icon.png
|
||||
minimumRole: visitor
|
74
pkg/app/metadata/validator_test.go
Normal file
74
pkg/app/metadata/validator_test.go
Normal file
@ -0,0 +1,74 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type validatorTestCase struct {
|
||||
File string
|
||||
ExpectValid bool
|
||||
ExpectError bool
|
||||
}
|
||||
|
||||
var validatorTestCases = []validatorTestCase{
|
||||
{
|
||||
File: "valid.yml",
|
||||
ExpectValid: true,
|
||||
},
|
||||
{
|
||||
File: "invalid-paths.yml",
|
||||
ExpectValid: false,
|
||||
ExpectError: true,
|
||||
},
|
||||
{
|
||||
File: "invalid-minimum-role.yml",
|
||||
ExpectValid: false,
|
||||
ExpectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
var validators = []app.MetadataValidator{
|
||||
WithMinimumRoleValidator("visitor", "user", "superuser", "admin", "superadmin"),
|
||||
WithNamedPathsValidator(NamedPathAdmin, NamedPathIcon),
|
||||
}
|
||||
|
||||
func TestManifestValidate(t *testing.T) {
|
||||
for _, tc := range validatorTestCases {
|
||||
func(tc *validatorTestCase) {
|
||||
t.Run(tc.File, func(t *testing.T) {
|
||||
data, err := ioutil.ReadFile(filepath.Join("testdata/manifests", tc.File))
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
var manifest app.Manifest
|
||||
|
||||
if err := yaml.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
valid, err := manifest.Validate(validators...)
|
||||
|
||||
t.Logf("[RESULT] valid:%v, err:%v", valid, err)
|
||||
|
||||
if e, g := tc.ExpectValid, valid; e != g {
|
||||
t.Errorf("valid: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
if tc.ExpectError && err == nil {
|
||||
t.Error("err should not be nil")
|
||||
}
|
||||
|
||||
if !tc.ExpectError && err != nil {
|
||||
t.Errorf("err: expected nil, got '%+v'", err)
|
||||
}
|
||||
})
|
||||
}(&tc)
|
||||
}
|
||||
}
|
@ -1,12 +1,9 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type eventDispatcherSet struct {
|
||||
@ -89,8 +86,6 @@ func (d *eventDispatcher) IsOut(out <-chan bus.Message) bool {
|
||||
}
|
||||
|
||||
func (d *eventDispatcher) Run() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
msg, ok := <-d.in
|
||||
if !ok {
|
||||
@ -99,12 +94,7 @@ func (d *eventDispatcher) Run() {
|
||||
return
|
||||
}
|
||||
|
||||
timeout := time.After(2 * time.Second)
|
||||
select {
|
||||
case d.out <- msg:
|
||||
case <-timeout:
|
||||
logger.Error(ctx, "message out chan timed out", logger.F("message", msg))
|
||||
}
|
||||
d.out <- msg
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/blob"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
@ -68,7 +69,7 @@ func (h *Handler) handleAppUpload(w http.ResponseWriter, r *http.Request) {
|
||||
ContextKeyOriginRequest: r,
|
||||
})
|
||||
|
||||
requestMsg := module.NewMessageUploadRequest(ctx, fileHeader, metadata)
|
||||
requestMsg := blob.NewMessageUploadRequest(ctx, fileHeader, metadata)
|
||||
|
||||
reply, err := h.bus.Request(ctx, requestMsg)
|
||||
if err != nil {
|
||||
@ -80,7 +81,7 @@ func (h *Handler) handleAppUpload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
logger.Debug(ctx, "upload reply", logger.F("reply", reply))
|
||||
|
||||
responseMsg, ok := reply.(*module.MessageUploadResponse)
|
||||
responseMsg, ok := reply.(*blob.MessageUploadResponse)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx, "unexpected upload response message",
|
||||
@ -120,7 +121,7 @@ func (h *Handler) handleAppDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ContextKeyOriginRequest: r,
|
||||
})
|
||||
|
||||
requestMsg := module.NewMessageDownloadRequest(ctx, bucket, storage.BlobID(blobID))
|
||||
requestMsg := blob.NewMessageDownloadRequest(ctx, bucket, storage.BlobID(blobID))
|
||||
|
||||
reply, err := h.bus.Request(ctx, requestMsg)
|
||||
if err != nil {
|
||||
@ -130,7 +131,7 @@ func (h *Handler) handleAppDownload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
replyMsg, ok := reply.(*module.MessageDownloadResponse)
|
||||
replyMsg, ok := reply.(*blob.MessageDownloadResponse)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx, "unexpected download response message",
|
||||
|
112
pkg/http/fetch.go
Normal file
112
pkg/http/fetch.go
Normal file
@ -0,0 +1,112 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/fetch"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func (h *Handler) handleAppFetch(w http.ResponseWriter, r *http.Request) {
|
||||
h.mutex.RLock()
|
||||
defer h.mutex.RUnlock()
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
ctx = module.WithContext(ctx, map[module.ContextKey]any{
|
||||
ContextKeyOriginRequest: r,
|
||||
})
|
||||
|
||||
rawURL := r.URL.Query().Get("url")
|
||||
|
||||
url, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, errorCodeBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
requestMsg := fetch.NewMessageFetchRequest(ctx, r.RemoteAddr, url)
|
||||
|
||||
reply, err := h.bus.Request(ctx, requestMsg)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve fetch request reply", logger.E(errors.WithStack(err)))
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "fetch reply", logger.F("reply", reply))
|
||||
|
||||
responseMsg, ok := reply.(*fetch.MessageFetchResponse)
|
||||
if !ok {
|
||||
logger.Error(
|
||||
ctx, "unexpected fetch response message",
|
||||
logger.F("message", reply),
|
||||
)
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !responseMsg.Allow {
|
||||
jsonError(w, http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
proxyReq, err := http.NewRequest(http.MethodGet, url.String(), nil)
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not create proxy request",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for header, values := range r.Header {
|
||||
for _, value := range values {
|
||||
proxyReq.Header.Add(header, value)
|
||||
}
|
||||
}
|
||||
|
||||
proxyReq.Header.Add("X-Forwarded-From", r.RemoteAddr)
|
||||
|
||||
res, err := h.httpClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not execute proxy request",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
jsonError(w, http.StatusInternalServerError, errorCodeInternalError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not close response body",
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
for header, values := range res.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(header, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(res.StatusCode)
|
||||
|
||||
if _, err := io.Copy(w, res.Body); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
@ -31,6 +31,8 @@ type Handler struct {
|
||||
server *app.Server
|
||||
serverModuleFactories []app.ServerModuleFactory
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
@ -91,6 +93,7 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
|
||||
sockjsOpts: opts.SockJS,
|
||||
router: router,
|
||||
serverModuleFactories: opts.ServerModuleFactories,
|
||||
httpClient: opts.HTTPClient,
|
||||
bus: opts.Bus,
|
||||
}
|
||||
|
||||
@ -100,11 +103,19 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
|
||||
r.Get("/client.js.map", handler.handleSDKClientMap)
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/upload", handler.handleAppUpload)
|
||||
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Post("/v1/upload", handler.handleAppUpload)
|
||||
r.Get("/v1/download/{bucket}/{blobID}", handler.handleAppDownload)
|
||||
|
||||
r.Get("/v1/fetch", handler.handleAppFetch)
|
||||
})
|
||||
|
||||
for _, fn := range opts.HTTPMounts {
|
||||
r.Group(func(r chi.Router) {
|
||||
fn(r)
|
||||
})
|
||||
}
|
||||
|
||||
r.HandleFunc("/sock/*", handler.handleSockJS)
|
||||
})
|
||||
|
||||
|
@ -1,11 +1,13 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/igm/sockjs-go/v3/sockjs"
|
||||
)
|
||||
|
||||
@ -14,6 +16,8 @@ type HandlerOptions struct {
|
||||
SockJS sockjs.Options
|
||||
ServerModuleFactories []app.ServerModuleFactory
|
||||
UploadMaxFileSize int64
|
||||
HTTPClient *http.Client
|
||||
HTTPMounts []func(r chi.Router)
|
||||
}
|
||||
|
||||
func defaultHandlerOptions() *HandlerOptions {
|
||||
@ -27,6 +31,10 @@ func defaultHandlerOptions() *HandlerOptions {
|
||||
SockJS: sockjsOptions,
|
||||
ServerModuleFactories: make([]app.ServerModuleFactory, 0),
|
||||
UploadMaxFileSize: 10 << (10 * 2), // 10Mb
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
HTTPMounts: make([]func(r chi.Router), 0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,3 +63,15 @@ func WithUploadMaxFileSize(size int64) HandlerOptionFunc {
|
||||
opts.UploadMaxFileSize = size
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPClient(client *http.Client) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.HTTPClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPMounts(mounts ...func(r chi.Router)) HandlerOptionFunc {
|
||||
return func(opts *HandlerOptions) {
|
||||
opts.HTTPMounts = mounts
|
||||
}
|
||||
}
|
||||
|
5
pkg/module/app/error.go
Normal file
5
pkg/module/app/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package app
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
58
pkg/module/app/memory/module_test.go
Normal file
58
pkg/module/app/memory/module_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestAppModuleWithMemoryRepository(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := app.NewServer(
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
appModule.ModuleFactory(NewRepository(
|
||||
func(ctx context.Context, id app.ID, from string) (string, error) {
|
||||
return fmt.Sprintf("http//%s.example.com?from=%s", id, from), nil
|
||||
},
|
||||
&app.Manifest{
|
||||
ID: "dummy1.arcad.app",
|
||||
Version: "0.0.0",
|
||||
Title: "Dummy 1",
|
||||
Description: "Dummy App 1",
|
||||
Tags: []string{"dummy", "first"},
|
||||
},
|
||||
&app.Manifest{
|
||||
ID: "dummy2.arcad.app",
|
||||
Version: "0.0.0",
|
||||
Title: "Dummy 2",
|
||||
Description: "Dummy App 2",
|
||||
Tags: []string{"dummy", "second"},
|
||||
},
|
||||
)),
|
||||
)
|
||||
|
||||
file := "testdata/app.js"
|
||||
|
||||
data, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Load(file, string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer server.Stop()
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
}
|
54
pkg/module/app/memory/repository.go
Normal file
54
pkg/module/app/memory/repository.go
Normal file
@ -0,0 +1,54 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
module "forge.cadoles.com/arcad/edge/pkg/module/app"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetURLFunc func(context.Context, app.ID, string) (string, error)
|
||||
|
||||
type Repository struct {
|
||||
getURL GetURLFunc
|
||||
apps []*app.Manifest
|
||||
}
|
||||
|
||||
// GetURL implements app.Repository
|
||||
func (r *Repository) GetURL(ctx context.Context, id app.ID, from string) (string, error) {
|
||||
url, err := r.getURL(ctx, id, from)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// Get implements app.Repository
|
||||
func (r *Repository) Get(ctx context.Context, id app.ID) (*app.Manifest, error) {
|
||||
for _, app := range r.apps {
|
||||
if app.ID != id {
|
||||
continue
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
return nil, module.ErrNotFound
|
||||
}
|
||||
|
||||
// List implements app.Repository
|
||||
func (r *Repository) List(ctx context.Context) ([]*app.Manifest, error) {
|
||||
return r.apps, nil
|
||||
}
|
||||
|
||||
func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository {
|
||||
if manifests == nil {
|
||||
manifests = make([]*app.Manifest, 0)
|
||||
}
|
||||
|
||||
return &Repository{getURL, manifests}
|
||||
}
|
||||
|
||||
var _ module.Repository = &Repository{}
|
17
pkg/module/app/memory/testdata/app.js
vendored
Normal file
17
pkg/module/app/memory/testdata/app.js
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
var ctx = context.new();
|
||||
|
||||
var manifests = app.list(ctx);
|
||||
|
||||
if (manifests.length !== 2) {
|
||||
throw new Error("apps.length: expected '2', got '"+manifests.length+"'");
|
||||
}
|
||||
|
||||
var manifest = app.get(ctx, 'dummy2.arcad.app');
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error("manifest should not be null");
|
||||
}
|
||||
|
||||
if (manifest.id !== "dummy2.arcad.app") {
|
||||
throw new Error("manifest.id: expected 'dummy2.arcad.app', got '"+manifest.id+"'");
|
||||
}
|
124
pkg/module/app/module.go
Normal file
124
pkg/module/app/module.go
Normal file
@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type gojaManifest struct {
|
||||
ID string `goja:"id" json:"id"`
|
||||
Version string `goja:"version" json:"version"`
|
||||
Title string `goja:"title" json:"title"`
|
||||
Description string `goja:"description" json:"description"`
|
||||
Tags []string `goja:"tags" json:"tags"`
|
||||
Metadata map[string]any `goja:"metadata" json:"metadata"`
|
||||
}
|
||||
|
||||
func toGojaManifest(manifest *app.Manifest) *gojaManifest {
|
||||
return &gojaManifest{
|
||||
ID: string(manifest.ID),
|
||||
Version: manifest.Version,
|
||||
Title: manifest.Title,
|
||||
Description: manifest.Description,
|
||||
Tags: manifest.Tags,
|
||||
Metadata: manifest.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func toGojaManifests(manifests []*app.Manifest) []*gojaManifest {
|
||||
gojaManifests := make([]*gojaManifest, len(manifests))
|
||||
|
||||
for i, m := range manifests {
|
||||
gojaManifests[i] = toGojaManifest(m)
|
||||
}
|
||||
|
||||
return gojaManifests
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "app"
|
||||
}
|
||||
|
||||
func (m *Module) Export(export *goja.Object) {
|
||||
if err := export.Set("list", m.list); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'list' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("get", m.get); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'get' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("getUrl", m.getURL); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'list' function"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) list(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
|
||||
manifests, err := m.repository.List(ctx)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(toGojaManifests(manifests))
|
||||
}
|
||||
|
||||
func (m *Module) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
appID := assertAppID(call.Argument(1), rt)
|
||||
|
||||
manifest, err := m.repository.Get(ctx, appID)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(toGojaManifest(manifest))
|
||||
}
|
||||
|
||||
func (m *Module) getURL(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
appID := assertAppID(call.Argument(1), rt)
|
||||
|
||||
var from string
|
||||
if len(call.Arguments) > 2 {
|
||||
from = util.AssertString(call.Argument(2), rt)
|
||||
}
|
||||
|
||||
url, err := m.repository.GetURL(ctx, appID, from)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(url)
|
||||
}
|
||||
|
||||
func ModuleFactory(repository Repository) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &Module{
|
||||
repository: repository,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAppID(value goja.Value, rt *goja.Runtime) app.ID {
|
||||
appID, ok := value.Export().(app.ID)
|
||||
if !ok {
|
||||
rawAppID, ok := value.Export().(string)
|
||||
if !ok {
|
||||
panic(rt.NewTypeError(fmt.Sprintf("app id must be an appid or a string, got '%T'", value.Export())))
|
||||
}
|
||||
|
||||
appID = app.ID(rawAppID)
|
||||
}
|
||||
|
||||
return appID
|
||||
}
|
112
pkg/module/app/mount.go
Normal file
112
pkg/module/app/mount.go
Normal file
@ -0,0 +1,112 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func (h *Handler) serveApps(w http.ResponseWriter, r *http.Request) {
|
||||
manifests, err := h.repo.List(r.Context())
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifests []*app.Manifest `json:"manifests"`
|
||||
}{
|
||||
Manifests: manifests,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
manifest, err := h.repo.Get(r.Context(), appID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app manifest", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Manifest *app.Manifest `json:"manifest"`
|
||||
}{
|
||||
Manifest: manifest,
|
||||
})
|
||||
}
|
||||
|
||||
type serveAppURLRequest struct {
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) serveAppURL(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
req := &serveAppURLRequest{}
|
||||
if ok := api.Bind(w, r, req); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
appID := app.ID(chi.URLParam(r, "appID"))
|
||||
|
||||
from := req.From
|
||||
if from == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "could not split remote address", logger.E(errors.WithStack(err)))
|
||||
} else {
|
||||
from = host
|
||||
}
|
||||
}
|
||||
|
||||
url, err := h.repo.GetURL(ctx, appID, from)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(r.Context(), "could not retrieve app url", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(repository Repository) MountFunc {
|
||||
handler := &Handler{repository}
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/apps", handler.serveApps)
|
||||
r.Get("/api/v1/apps/{appID}", handler.serveApp)
|
||||
r.Post("/api/v1/apps/{appID}/url", handler.serveAppURL)
|
||||
}
|
||||
}
|
13
pkg/module/app/repository.go
Normal file
13
pkg/module/app/repository.go
Normal file
@ -0,0 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
List(context.Context) ([]*app.Manifest, error)
|
||||
Get(context.Context, app.ID) (*app.Manifest, error)
|
||||
GetURL(ctx context.Context, id app.ID, from string) (string, error)
|
||||
}
|
@ -2,7 +2,4 @@ package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrClaimNotFound = errors.New("claim not found")
|
||||
)
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
35
pkg/module/auth/http/jwt.go
Normal file
35
pkg/module/auth/http/jwt.go
Normal file
@ -0,0 +1,35 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func generateSignedToken(algo jwa.KeyAlgorithm, key jwk.Key, claims map[string]any) ([]byte, error) {
|
||||
token := jwt.New()
|
||||
|
||||
if err := token.Set(jwt.NotBeforeKey, time.Now()); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
for key, value := range claims {
|
||||
if err := token.Set(key, value); err != nil {
|
||||
return nil, errors.Wrapf(err, "could not set claim '%s' with value '%v'", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if err := token.Set(jwk.AlgorithmKey, jwa.HS256); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
rawToken, err := jwt.Sign(token, jwt.WithKey(algo, key))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return rawToken, nil
|
||||
}
|
27
pkg/module/auth/http/local_account.go
Normal file
27
pkg/module/auth/http/local_account.go
Normal file
@ -0,0 +1,27 @@
|
||||
package http
|
||||
|
||||
import "forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd"
|
||||
|
||||
type LocalAccount struct {
|
||||
Username string `json:"username"`
|
||||
Algo passwd.Algo `json:"algo"`
|
||||
Password string `json:"password"`
|
||||
Claims map[string]any `json:"claims"`
|
||||
}
|
||||
|
||||
func NewLocalAccount(username, password string, algo passwd.Algo, claims map[string]any) LocalAccount {
|
||||
return LocalAccount{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Algo: algo,
|
||||
Claims: claims,
|
||||
}
|
||||
}
|
||||
|
||||
func toAccountsMap(accounts []LocalAccount) map[string]LocalAccount {
|
||||
accountsMap := make(map[string]LocalAccount)
|
||||
for _, acc := range accounts {
|
||||
accountsMap[acc.Username] = acc
|
||||
}
|
||||
return accountsMap
|
||||
}
|
208
pkg/module/auth/http/local_handler.go
Normal file
208
pkg/module/auth/http/local_handler.go
Normal file
@ -0,0 +1,208 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
//go:embed templates/login.html.tmpl
|
||||
var rawLoginTemplate string
|
||||
var loginTemplate *template.Template
|
||||
|
||||
var (
|
||||
errNotFound = errors.New("not found")
|
||||
errInvalidPassword = errors.New("invalid password")
|
||||
)
|
||||
|
||||
func init() {
|
||||
loginTemplate = template.Must(template.New("").Parse(rawLoginTemplate))
|
||||
}
|
||||
|
||||
type LocalHandler struct {
|
||||
router chi.Router
|
||||
algo jwa.KeyAlgorithm
|
||||
key jwk.Key
|
||||
getCookieDomain GetCookieDomainFunc
|
||||
cookieDuration time.Duration
|
||||
accounts map[string]LocalAccount
|
||||
}
|
||||
|
||||
func (h *LocalHandler) initRouter(prefix string) {
|
||||
router := chi.NewRouter()
|
||||
|
||||
router.Route(prefix, func(r chi.Router) {
|
||||
r.Get("/login", h.serveForm)
|
||||
r.Post("/login", h.handleForm)
|
||||
r.Get("/logout", h.handleLogout)
|
||||
})
|
||||
|
||||
h.router = router
|
||||
}
|
||||
|
||||
type loginTemplateData struct {
|
||||
URL string
|
||||
Username string
|
||||
Password string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (h *LocalHandler) serveForm(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
data := loginTemplateData{
|
||||
URL: r.URL.String(),
|
||||
Username: "",
|
||||
Password: "",
|
||||
Message: "",
|
||||
}
|
||||
|
||||
if err := loginTemplate.Execute(w, data); err != nil {
|
||||
logger.Error(ctx, "could not execute login page template", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LocalHandler) handleForm(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Error(ctx, "could not parse form", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
username := r.Form.Get("username")
|
||||
password := r.Form.Get("password")
|
||||
|
||||
data := loginTemplateData{
|
||||
URL: r.URL.String(),
|
||||
Username: username,
|
||||
Password: password,
|
||||
Message: "",
|
||||
}
|
||||
|
||||
account, err := h.authenticate(username, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) || errors.Is(err, errInvalidPassword) {
|
||||
data.Message = "Invalid username or password."
|
||||
|
||||
if err := loginTemplate.Execute(w, data); err != nil {
|
||||
logger.Error(ctx, "could not execute login page template", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not authenticate account", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
account.Claims[auth.ClaimIssuer] = "local"
|
||||
|
||||
token, err := generateSignedToken(h.algo, h.key, account.Claims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookieDomain, err := h.getCookieDomain(r)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve cookie domain", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: string(token),
|
||||
Domain: cookieDomain,
|
||||
HttpOnly: false,
|
||||
Expires: time.Now().Add(h.cookieDuration),
|
||||
Path: "/",
|
||||
}
|
||||
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *LocalHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookieDomain, err := h.getCookieDomain(r)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not retrieve cookie domain", logger.E(errors.WithStack(err)))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName,
|
||||
Value: "",
|
||||
HttpOnly: false,
|
||||
Expires: time.Unix(0, 0),
|
||||
Domain: cookieDomain,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *LocalHandler) authenticate(username, password string) (*LocalAccount, error) {
|
||||
account, exists := h.accounts[username]
|
||||
if !exists {
|
||||
return nil, errors.WithStack(errNotFound)
|
||||
}
|
||||
|
||||
matches, err := passwd.Match(account.Algo, password, account.Password)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if !matches {
|
||||
return nil, errors.WithStack(errInvalidPassword)
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func NewLocalHandler(algo jwa.KeyAlgorithm, key jwk.Key, funcs ...LocalHandlerOptionFunc) *LocalHandler {
|
||||
opts := defaultLocalHandlerOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opts)
|
||||
}
|
||||
|
||||
handler := &LocalHandler{
|
||||
algo: algo,
|
||||
key: key,
|
||||
accounts: toAccountsMap(opts.Accounts),
|
||||
getCookieDomain: opts.GetCookieDomain,
|
||||
cookieDuration: opts.CookieDuration,
|
||||
}
|
||||
|
||||
handler.initRouter(opts.RoutePrefix)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *LocalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
var _ http.Handler = &LocalHandler{}
|
49
pkg/module/auth/http/options.go
Normal file
49
pkg/module/auth/http/options.go
Normal file
@ -0,0 +1,49 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetCookieDomainFunc func(r *http.Request) (string, error)
|
||||
|
||||
func defaultGetCookieDomain(r *http.Request) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type LocalHandlerOptions struct {
|
||||
RoutePrefix string
|
||||
Accounts []LocalAccount
|
||||
GetCookieDomain GetCookieDomainFunc
|
||||
CookieDuration time.Duration
|
||||
}
|
||||
|
||||
type LocalHandlerOptionFunc func(*LocalHandlerOptions)
|
||||
|
||||
func defaultLocalHandlerOptions() *LocalHandlerOptions {
|
||||
return &LocalHandlerOptions{
|
||||
RoutePrefix: "",
|
||||
Accounts: make([]LocalAccount, 0),
|
||||
GetCookieDomain: defaultGetCookieDomain,
|
||||
CookieDuration: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func WithAccounts(accounts ...LocalAccount) LocalHandlerOptionFunc {
|
||||
return func(opts *LocalHandlerOptions) {
|
||||
opts.Accounts = accounts
|
||||
}
|
||||
}
|
||||
|
||||
func WithRoutePrefix(prefix string) LocalHandlerOptionFunc {
|
||||
return func(opts *LocalHandlerOptions) {
|
||||
opts.RoutePrefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
func WithCookieOptions(getCookieDomain GetCookieDomainFunc, duration time.Duration) LocalHandlerOptionFunc {
|
||||
return func(opts *LocalHandlerOptions) {
|
||||
opts.GetCookieDomain = getCookieDomain
|
||||
opts.CookieDuration = duration
|
||||
}
|
||||
}
|
136
pkg/module/auth/http/passwd/argon2id/hasher.go
Normal file
136
pkg/module/auth/http/passwd/argon2id/hasher.go
Normal file
@ -0,0 +1,136 @@
|
||||
package argon2id
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
Algo passwd.Algo = "argon2id"
|
||||
)
|
||||
|
||||
func init() {
|
||||
passwd.Register(Algo, &Hasher{})
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidHash = errors.New("invalid hash")
|
||||
ErrIncompatibleVersion = errors.New("incompatible version")
|
||||
)
|
||||
|
||||
type params struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
parallelism uint8
|
||||
saltLength uint32
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
var defaultParams = params{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
|
||||
type Hasher struct{}
|
||||
|
||||
// Hash implements passwd.Hasher
|
||||
func (*Hasher) Hash(plaintext string) (string, error) {
|
||||
salt, err := generateRandomBytes(defaultParams.saltLength)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(plaintext), salt, defaultParams.iterations, defaultParams.memory, defaultParams.parallelism, defaultParams.keyLength)
|
||||
|
||||
// Base64 encode the salt and hashed password.
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
// Return a string using the standard encoded hash representation.
|
||||
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, defaultParams.memory, defaultParams.iterations, defaultParams.parallelism, b64Salt, b64Hash)
|
||||
|
||||
return encodedHash, nil
|
||||
}
|
||||
|
||||
// Match implements passwd.Hasher.
|
||||
func (*Hasher) Match(plaintext string, hash string) (bool, error) {
|
||||
matches, err := comparePasswordAndHash(plaintext, hash)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
var _ passwd.Hasher = &Hasher{}
|
||||
|
||||
func generateRandomBytes(n uint32) ([]byte, error) {
|
||||
buf := make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func comparePasswordAndHash(password, encodedHash string) (match bool, err error) {
|
||||
p, salt, hash, err := decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)
|
||||
|
||||
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func decodeHash(encodedHash string) (p *params, salt, hash []byte, err error) {
|
||||
vals := strings.Split(encodedHash, "$")
|
||||
if len(vals) != 6 {
|
||||
return nil, nil, nil, ErrInvalidHash
|
||||
}
|
||||
|
||||
var version int
|
||||
_, err = fmt.Sscanf(vals[2], "v=%d", &version)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, ErrIncompatibleVersion
|
||||
}
|
||||
|
||||
p = ¶ms{}
|
||||
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.saltLength = uint32(len(salt))
|
||||
|
||||
hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.keyLength = uint32(len(hash))
|
||||
|
||||
return p, salt, hash, nil
|
||||
}
|
8
pkg/module/auth/http/passwd/hasher.go
Normal file
8
pkg/module/auth/http/passwd/hasher.go
Normal file
@ -0,0 +1,8 @@
|
||||
package passwd
|
||||
|
||||
type Algo string
|
||||
|
||||
type Hasher interface {
|
||||
Hash(plaintext string) (string, error)
|
||||
Match(plaintext string, hash string) (bool, error)
|
||||
}
|
31
pkg/module/auth/http/passwd/plain/hasher.go
Normal file
31
pkg/module/auth/http/passwd/plain/hasher.go
Normal file
@ -0,0 +1,31 @@
|
||||
package plain
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/auth/http/passwd"
|
||||
)
|
||||
|
||||
const (
|
||||
Algo passwd.Algo = "plain"
|
||||
)
|
||||
|
||||
func init() {
|
||||
passwd.Register(Algo, &Hasher{})
|
||||
}
|
||||
|
||||
type Hasher struct{}
|
||||
|
||||
// Hash implements passwd.Hasher
|
||||
func (*Hasher) Hash(plaintext string) (string, error) {
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// Match implements passwd.Hasher.
|
||||
func (*Hasher) Match(plaintext string, hash string) (bool, error) {
|
||||
matches := subtle.ConstantTimeCompare([]byte(plaintext), []byte(hash)) == 1
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
var _ passwd.Hasher = &Hasher{}
|
87
pkg/module/auth/http/passwd/registry.go
Normal file
87
pkg/module/auth/http/passwd/registry.go
Normal file
@ -0,0 +1,87 @@
|
||||
package passwd
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrAlgoNotFound = errors.New("algo not found")
|
||||
|
||||
type Registry struct {
|
||||
hashers map[Algo]Hasher
|
||||
}
|
||||
|
||||
func (r *Registry) Register(algo Algo, hasher Hasher) {
|
||||
r.hashers[algo] = hasher
|
||||
}
|
||||
|
||||
func (r *Registry) Match(algo Algo, plaintext string, hash string) (bool, error) {
|
||||
hasher, exists := r.hashers[algo]
|
||||
if !exists {
|
||||
return false, errors.WithStack(ErrAlgoNotFound)
|
||||
}
|
||||
|
||||
matches, err := hasher.Match(plaintext, hash)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Hash(algo Algo, plaintext string) (string, error) {
|
||||
hasher, exists := r.hashers[algo]
|
||||
if !exists {
|
||||
return "", errors.WithStack(ErrAlgoNotFound)
|
||||
}
|
||||
|
||||
hash, err := hasher.Hash(plaintext)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Algorithms() []Algo {
|
||||
algorithms := make([]Algo, 0, len(r.hashers))
|
||||
|
||||
for algo := range r.hashers {
|
||||
algorithms = append(algorithms, algo)
|
||||
}
|
||||
|
||||
return algorithms
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
hashers: make(map[Algo]Hasher),
|
||||
}
|
||||
}
|
||||
|
||||
var defaultRegistry = NewRegistry()
|
||||
|
||||
func Match(algo Algo, plaintext string, hash string) (bool, error) {
|
||||
matches, err := defaultRegistry.Match(algo, plaintext, hash)
|
||||
if err != nil {
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func Hash(algo Algo, plaintext string) (string, error) {
|
||||
hash, err := defaultRegistry.Hash(algo, plaintext)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func Algorithms() []Algo {
|
||||
return defaultRegistry.Algorithms()
|
||||
}
|
||||
|
||||
func Register(algo Algo, hasher Hasher) {
|
||||
defaultRegistry.Register(algo, hasher)
|
||||
}
|
105
pkg/module/auth/http/templates/login.html.tmpl
Normal file
105
pkg/module/auth/http/templates/login.html.tmpl
Normal file
@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Login</title>
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.form-control > label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-control > input {
|
||||
width: 100%;
|
||||
line-height: 1.4em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
font-size: 1.2em;
|
||||
padding: 0 5px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#submit {
|
||||
float: right;
|
||||
background-color: #5e77ff;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#submit:hover {
|
||||
background-color: hsl(231deg 100% 71%);
|
||||
}
|
||||
|
||||
#login {
|
||||
padding: 1.5em 1em;
|
||||
border: 1px solid #e0e0e0;
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 2px 2px #cccccc1c;
|
||||
color: #333333 !important;
|
||||
}
|
||||
|
||||
#message {
|
||||
margin-bottom: 10px;
|
||||
color: red;
|
||||
text-shadow: 1px 1px #fff0f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<p id="message">{{ .Message }}</p>
|
||||
<div id="login">
|
||||
<form method="post" action="{{ .URL }}">
|
||||
<div class="form-control">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ .Username }}" required autofocus />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" value="{{ .Password }}" required />
|
||||
</div>
|
||||
<input id="submit" type="submit" value="Login" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -5,16 +5,24 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jws"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func WithJWT(keyFunc jwt.Keyfunc) OptionFunc {
|
||||
const (
|
||||
CookieName string = "edge-auth"
|
||||
)
|
||||
|
||||
type GetKeySetFunc func() (jwk.Set, error)
|
||||
|
||||
func WithJWT(getKeySet GetKeySetFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) {
|
||||
claim, err := getClaim[string](r, claimName, keyFunc)
|
||||
o.GetClaims = func(ctx context.Context, r *http.Request, names ...string) ([]string, error) {
|
||||
claim, err := getClaims[string](r, getKeySet, names...)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
@ -22,39 +30,80 @@ func WithJWT(keyFunc jwt.Keyfunc) OptionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getClaim[T any](r *http.Request, claimAttr string, keyFunc jwt.Keyfunc) (T, error) {
|
||||
rawToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
func FindToken(r *http.Request, getKeySet GetKeySetFunc) (jwt.Token, error) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
|
||||
// Retrieve token from Authorization header
|
||||
rawToken := strings.TrimPrefix(authorization, "Bearer ")
|
||||
|
||||
// Retrieve token from ?edge-auth=<value>
|
||||
if rawToken == "" {
|
||||
rawToken = r.URL.Query().Get("token")
|
||||
rawToken = r.URL.Query().Get(CookieName)
|
||||
}
|
||||
|
||||
if rawToken == "" {
|
||||
return *new(T), errors.WithStack(ErrUnauthenticated)
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if cookie != nil {
|
||||
rawToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(rawToken, keyFunc)
|
||||
if rawToken == "" {
|
||||
return nil, errors.WithStack(ErrUnauthenticated)
|
||||
}
|
||||
|
||||
keySet, err := getKeySet()
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return *new(T), errors.Errorf("invalid jwt token: '%v'", token.Raw)
|
||||
if keySet == nil {
|
||||
return nil, errors.New("no keyset")
|
||||
}
|
||||
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("unexpected claims type '%T'", token.Claims)
|
||||
token, err := jwt.Parse([]byte(rawToken),
|
||||
jwt.WithKeySet(keySet, jws.WithRequireKid(false)),
|
||||
jwt.WithValidate(true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
rawClaim, exists := mapClaims[claimAttr]
|
||||
if !exists {
|
||||
return *new(T), errors.WithStack(ErrClaimNotFound)
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
|
||||
}
|
||||
|
||||
return claim, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func getClaims[T any](r *http.Request, getKeySet GetKeySetFunc, names ...string) ([]T, error) {
|
||||
token, err := FindToken(r, getKeySet)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
mapClaims, err := token.AsMap(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
claims := make([]T, len(names))
|
||||
|
||||
for idx, n := range names {
|
||||
rawClaim, exists := mapClaims[n]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
claim, ok := rawClaim.(T)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", n, new(T), rawClaim)
|
||||
}
|
||||
|
||||
claims[idx] = claim
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
@ -8,15 +8,21 @@ import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
ClaimSubject = "sub"
|
||||
ClaimSubject = "sub"
|
||||
ClaimIssuer = "iss"
|
||||
ClaimPreferredUsername = "preferred_username"
|
||||
ClaimEdgeRole = "edge_role"
|
||||
ClaimEdgeTenant = "edge_tenant"
|
||||
ClaimEdgeEntrypoint = "edge_entrypoint"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
server *app.Server
|
||||
getClaimFunc GetClaimFunc
|
||||
server *app.Server
|
||||
getClaims GetClaimsFunc
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
@ -31,6 +37,22 @@ func (m *Module) Export(export *goja.Object) {
|
||||
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_TENANT", ClaimEdgeTenant); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_TENANT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ENTRYPOINT", ClaimEdgeEntrypoint); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ENTRYPOINT' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_ROLE", ClaimEdgeRole); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_ROLE' property"))
|
||||
}
|
||||
|
||||
if err := export.Set("CLAIM_PREFERRED_USERNAME", ClaimPreferredUsername); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'CLAIM_PREFERRED_USERNAME' property"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
@ -42,28 +64,33 @@ func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
panic(rt.ToValue(errors.New("could not find http request in context")))
|
||||
}
|
||||
|
||||
claim, err := m.getClaimFunc(ctx, req, claimName)
|
||||
claim, err := m.getClaims(ctx, req, claimName)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
return nil
|
||||
}
|
||||
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
logger.Error(ctx, "could not retrieve claim", logger.E(errors.WithStack(err)))
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim)
|
||||
if len(claim) == 0 || claim[0] == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return rt.ToValue(claim[0])
|
||||
}
|
||||
|
||||
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
|
||||
opt := &Option{}
|
||||
opt := defaultOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &Module{
|
||||
server: server,
|
||||
getClaimFunc: opt.GetClaim,
|
||||
server: server,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
@ -12,7 +11,9 @@ import (
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
edgeHTTP "forge.cadoles.com/arcad/edge/pkg/http"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
@ -22,12 +23,12 @@ func TestAuthModule(t *testing.T) {
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
keyFunc, secret := getKeyFunc()
|
||||
key := getDummyKey()
|
||||
|
||||
server := app.NewServer(
|
||||
module.ConsoleModuleFactory(),
|
||||
ModuleFactory(
|
||||
WithJWT(keyFunc),
|
||||
WithJWT(getDummyKeySet(key)),
|
||||
),
|
||||
)
|
||||
|
||||
@ -51,17 +52,22 @@ func TestAuthModule(t *testing.T) {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": "jdoe",
|
||||
"nbf": time.Now().UTC().Unix(),
|
||||
})
|
||||
token := jwt.New()
|
||||
|
||||
rawToken, err := token.SignedString(secret)
|
||||
if err := token.Set(jwt.SubjectKey, "jdoe"); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := token.Set(jwt.NotBeforeKey, time.Now()); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
rawToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, key))
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", "Bearer "+rawToken)
|
||||
req.Header.Add("Authorization", "Bearer "+string(rawToken))
|
||||
|
||||
ctx := context.WithValue(context.Background(), edgeHTTP.ContextKeyOriginRequest, req)
|
||||
|
||||
@ -75,11 +81,11 @@ func TestAuthAnonymousModule(t *testing.T) {
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
keyFunc, _ := getKeyFunc()
|
||||
key := getDummyKey()
|
||||
|
||||
server := app.NewServer(
|
||||
module.ConsoleModuleFactory(),
|
||||
ModuleFactory(WithJWT(keyFunc)),
|
||||
ModuleFactory(WithJWT(getDummyKeySet(key))),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/auth_anonymous.js")
|
||||
@ -109,16 +115,29 @@ func TestAuthAnonymousModule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func getKeyFunc() (jwt.Keyfunc, []byte) {
|
||||
func getDummyKey() jwk.Key {
|
||||
secret := []byte("not_so_secret")
|
||||
|
||||
keyFunc := func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
key, err := jwk.FromRaw(secret)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
return keyFunc, secret
|
||||
if err := key.Set(jwk.AlgorithmKey, jwa.HS256); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func getDummyKeySet(key jwk.Key) GetKeySetFunc {
|
||||
return func() (jwk.Set, error) {
|
||||
set := jwk.NewSet()
|
||||
|
||||
if err := set.AddKey(key); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return set, nil
|
||||
}
|
||||
}
|
||||
|
72
pkg/module/auth/mount.go
Normal file
72
pkg/module/auth/mount.go
Normal file
@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type MountFunc func(r chi.Router)
|
||||
|
||||
type Handler struct {
|
||||
getClaims GetClaimsFunc
|
||||
profileClaims []string
|
||||
}
|
||||
|
||||
func (h *Handler) serveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
claims, err := h.getClaims(ctx, r, h.profileClaims...)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthenticated) {
|
||||
api.ErrorResponse(
|
||||
w, http.StatusUnauthorized,
|
||||
api.ErrCodeUnauthorized,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not retrieve claims", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(
|
||||
w, http.StatusInternalServerError,
|
||||
api.ErrCodeUnknownError,
|
||||
nil,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
profile := make(map[string]any)
|
||||
|
||||
for idx, cl := range h.profileClaims {
|
||||
profile[cl] = claims[idx]
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Profile map[string]any `json:"profile"`
|
||||
}{
|
||||
Profile: profile,
|
||||
})
|
||||
}
|
||||
|
||||
func Mount(authHandler http.Handler, funcs ...OptionFunc) MountFunc {
|
||||
opt := defaultOptions()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
handler := &Handler{
|
||||
profileClaims: opt.ProfileClaims,
|
||||
getClaims: opt.GetClaims,
|
||||
}
|
||||
|
||||
return func(r chi.Router) {
|
||||
r.Get("/api/v1/profile", handler.serveProfile)
|
||||
r.Handle("/auth/*", authHandler)
|
||||
}
|
||||
}
|
@ -3,18 +3,45 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
|
||||
type GetClaimsFunc func(ctx context.Context, r *http.Request, claims ...string) ([]string, error)
|
||||
|
||||
type Option struct {
|
||||
GetClaim GetClaimFunc
|
||||
GetClaims GetClaimsFunc
|
||||
ProfileClaims []string
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func WithGetClaim(fn GetClaimFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaim = fn
|
||||
func defaultOptions() *Option {
|
||||
return &Option{
|
||||
GetClaims: dummyGetClaims,
|
||||
ProfileClaims: []string{
|
||||
ClaimSubject,
|
||||
ClaimIssuer,
|
||||
ClaimEdgeEntrypoint,
|
||||
ClaimEdgeRole,
|
||||
ClaimPreferredUsername,
|
||||
ClaimEdgeTenant,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
|
||||
return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
|
||||
}
|
||||
|
||||
func WithGetClaims(fn GetClaimsFunc) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.GetClaims = fn
|
||||
}
|
||||
}
|
||||
|
||||
func WithProfileClaims(claims ...string) OptionFunc {
|
||||
return func(o *Option) {
|
||||
o.ProfileClaims = claims
|
||||
}
|
||||
}
|
||||
|
@ -1,282 +0,0 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBlobBucket string = "default"
|
||||
)
|
||||
|
||||
type BlobModule struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
store storage.BlobStore
|
||||
}
|
||||
|
||||
func (m *BlobModule) Name() string {
|
||||
return "blob"
|
||||
}
|
||||
|
||||
func (m *BlobModule) Export(export *goja.Object) {
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleMessages() {
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
err := m.bus.Reply(ctx, MessageNamespaceUploadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
uploadRequest, ok := msg.(*MessageUploadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message upload request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleUploadRequest(uploadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle upload request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "upload request response", logger.F("response", res))
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
err := m.bus.Reply(ctx, MessageNamespaceDownloadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
downloadRequest, ok := msg.(*MessageDownloadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message download request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleDownloadRequest(downloadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle download request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleUploadRequest(req *MessageUploadRequest) (*MessageUploadResponse, error) {
|
||||
blobID := storage.NewBlobID()
|
||||
res := NewMessageUploadResponse(req.RequestID)
|
||||
|
||||
ctx := logger.With(req.Context, logger.F("blobID", blobID))
|
||||
|
||||
blobInfo := map[string]interface{}{
|
||||
"size": req.FileHeader.Size,
|
||||
"filename": req.FileHeader.Filename,
|
||||
"contentType": req.FileHeader.Header.Get("Content-Type"),
|
||||
}
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName(ctx, "onBlobUpload", ctx, blobID, blobInfo, req.Metadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobUpload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
if res.Allow {
|
||||
bucket := DefaultBlobBucket
|
||||
|
||||
rawBucket, exists := result["bucket"]
|
||||
if exists {
|
||||
bucket, ok = rawBucket.(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'bucket' result property: got type '%T', expected type '%T'", bucket, "")
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.saveBlob(ctx, bucket, blobID, *req.FileHeader); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
res.Bucket = bucket
|
||||
res.BlobID = blobID
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) saveBlob(ctx context.Context, bucketName string, blobID storage.BlobID, fileHeader multipart.FileHeader) error {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
writer, err := bucket.NewWriter(ctx, blobID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close writer", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(writer, file); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) handleDownloadRequest(req *MessageDownloadRequest) (*MessageDownloadResponse, error) {
|
||||
res := NewMessageDownloadResponse(req.RequestID)
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName(req.Context, "onBlobDownload", req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobDownload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
reader, info, err := m.openBlob(req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil && !errors.Is(err, storage.ErrBlobNotFound) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if reader != nil {
|
||||
res.Blob = reader
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
res.BlobInfo = info
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *BlobModule) openBlob(ctx context.Context, bucketName string, blobID storage.BlobID) (io.ReadSeekCloser, storage.BlobInfo, error) {
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)), logger.F("bucket", bucket))
|
||||
}
|
||||
}()
|
||||
|
||||
info, err := bucket.Get(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
reader, err := bucket.NewReader(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return reader, info, nil
|
||||
}
|
||||
|
||||
func BlobModuleFactory(bus bus.Bus, store storage.BlobStore) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
mod := &BlobModule{
|
||||
store: store,
|
||||
bus: bus,
|
||||
server: server,
|
||||
}
|
||||
|
||||
go mod.handleMessages()
|
||||
|
||||
return mod
|
||||
}
|
||||
}
|
21
pkg/module/blob/blob_info.go
Normal file
21
pkg/module/blob/blob_info.go
Normal file
@ -0,0 +1,21 @@
|
||||
package blob
|
||||
|
||||
import "forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
|
||||
type blobInfo struct {
|
||||
ID storage.BlobID `goja:"id"`
|
||||
Bucket string `goja:"bucket"`
|
||||
ModTime int64 `goja:"modTime"`
|
||||
Size int64 `goja:"size"`
|
||||
ContentType string `goja:"contentType"`
|
||||
}
|
||||
|
||||
func toGojaBlobInfo(blob storage.BlobInfo) blobInfo {
|
||||
return blobInfo{
|
||||
ID: blob.ID(),
|
||||
Bucket: blob.Bucket(),
|
||||
ModTime: blob.ModTime().Unix(),
|
||||
Size: blob.Size(),
|
||||
ContentType: blob.ContentType(),
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package module
|
||||
package blob
|
||||
|
||||
import (
|
||||
"context"
|
499
pkg/module/blob/module.go
Normal file
499
pkg/module/blob/module.go
Normal file
@ -0,0 +1,499 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module/util"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBlobBucket string = "default"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
store storage.BlobStore
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "blob"
|
||||
}
|
||||
|
||||
func (m *Module) Export(export *goja.Object) {
|
||||
funcs := map[string]any{
|
||||
"listBuckets": m.listBuckets,
|
||||
"deleteBucket": m.deleteBucket,
|
||||
"getBucketSize": m.getBucketSize,
|
||||
"listBlobs": m.listBlobs,
|
||||
"getBlobInfo": m.getBlobInfo,
|
||||
"readBlob": m.readBlob,
|
||||
"writeBlob": m.writeBlob,
|
||||
"deleteBlob": m.deleteBlob,
|
||||
}
|
||||
|
||||
for name, fn := range funcs {
|
||||
if err := export.Set(name, fn); err != nil {
|
||||
panic(errors.Wrapf(err, "could not set '%s' function", name))
|
||||
}
|
||||
}
|
||||
|
||||
if err := export.Set("DEFAULT_BUCKET", DefaultBlobBucket); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'DEFAULT_BUCKET' property"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) listBuckets(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
|
||||
buckets, err := m.store.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
defaultBucketIndex := sort.SearchStrings(buckets, DefaultBlobBucket)
|
||||
if defaultBucketIndex == 0 {
|
||||
buckets = append(buckets, DefaultBlobBucket)
|
||||
} else {
|
||||
buckets[defaultBucketIndex] = DefaultBlobBucket
|
||||
}
|
||||
|
||||
return rt.ToValue(buckets)
|
||||
}
|
||||
|
||||
func (m *Module) writeBlob(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
blobID := assertBlobID(call.Argument(2), rt)
|
||||
rawData := call.Argument(3).Export()
|
||||
|
||||
var data []byte
|
||||
switch typ := rawData.(type) {
|
||||
case []byte:
|
||||
data = typ
|
||||
case string:
|
||||
data = []byte(typ)
|
||||
default:
|
||||
data = []byte(fmt.Sprintf("%v", typ))
|
||||
}
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
writer, err := bucket.NewWriter(ctx, blobID)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||
logger.Error(ctx, "could not close blob writer", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) getBlobInfo(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
blobID := assertBlobID(call.Argument(2), rt)
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
blobInfo, err := bucket.Get(ctx, blobID)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(toGojaBlobInfo(blobInfo))
|
||||
}
|
||||
|
||||
func (m *Module) readBlob(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
blobID := assertBlobID(call.Argument(2), rt)
|
||||
|
||||
reader, _, err := m.openBlob(ctx, bucketName, blobID)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
|
||||
logger.Error(ctx, "could not close blob reader", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(rt.NewArrayBuffer(data))
|
||||
}
|
||||
|
||||
func (m *Module) deleteBlob(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
blobID := assertBlobID(call.Argument(2), rt)
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
if err := bucket.Delete(ctx, blobID); err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) listBlobs(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
blobInfos, err := bucket.List(ctx)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
gojaBlobInfos := make([]blobInfo, len(blobInfos))
|
||||
|
||||
for i, b := range blobInfos {
|
||||
gojaBlobInfos[i] = toGojaBlobInfo(b)
|
||||
}
|
||||
|
||||
return rt.ToValue(gojaBlobInfos)
|
||||
}
|
||||
|
||||
func (m *Module) deleteBucket(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
|
||||
if err := m.store.DeleteBucket(ctx, bucketName); err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) getBucketSize(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx := util.AssertContext(call.Argument(0), rt)
|
||||
bucketName := util.AssertString(call.Argument(1), rt)
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
size, err := bucket.Size(ctx)
|
||||
if err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
return rt.ToValue(size)
|
||||
}
|
||||
|
||||
func (m *Module) handleMessages() {
|
||||
ctx := context.Background()
|
||||
|
||||
go func() {
|
||||
err := m.bus.Reply(ctx, MessageNamespaceUploadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
uploadRequest, ok := msg.(*MessageUploadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message upload request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleUploadRequest(uploadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle upload request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "upload request response", logger.F("response", res))
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
err := m.bus.Reply(ctx, MessageNamespaceDownloadRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
downloadRequest, ok := msg.(*MessageDownloadRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message download request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleDownloadRequest(downloadRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle download request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) handleUploadRequest(req *MessageUploadRequest) (*MessageUploadResponse, error) {
|
||||
blobID := storage.NewBlobID()
|
||||
res := NewMessageUploadResponse(req.RequestID)
|
||||
|
||||
ctx := logger.With(req.Context, logger.F("blobID", blobID))
|
||||
|
||||
blobInfo := map[string]interface{}{
|
||||
"size": req.FileHeader.Size,
|
||||
"filename": req.FileHeader.Filename,
|
||||
"contentType": req.FileHeader.Header.Get("Content-Type"),
|
||||
}
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName(ctx, "onBlobUpload", ctx, blobID, blobInfo, req.Metadata)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobUpload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
if res.Allow {
|
||||
bucket := DefaultBlobBucket
|
||||
|
||||
rawBucket, exists := result["bucket"]
|
||||
if exists {
|
||||
bucket, ok = rawBucket.(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'bucket' result property: got type '%T', expected type '%T'", bucket, "")
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.saveBlob(ctx, bucket, blobID, *req.FileHeader); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
res.Bucket = bucket
|
||||
res.BlobID = blobID
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Module) saveBlob(ctx context.Context, bucketName string, blobID storage.BlobID, fileHeader multipart.FileHeader) error {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
writer, err := bucket.NewWriter(ctx, blobID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
if err := writer.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close writer", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(writer, file); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) handleDownloadRequest(req *MessageDownloadRequest) (*MessageDownloadResponse, error) {
|
||||
res := NewMessageDownloadResponse(req.RequestID)
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName(req.Context, "onBlobDownload", req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onBlobDownload result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
reader, info, err := m.openBlob(req.Context, req.Bucket, req.BlobID)
|
||||
if err != nil && !errors.Is(err, storage.ErrBlobNotFound) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if reader != nil {
|
||||
res.Blob = reader
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
res.BlobInfo = info
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Module) openBlob(ctx context.Context, bucketName string, blobID storage.BlobID) (io.ReadSeekCloser, storage.BlobInfo, error) {
|
||||
bucket, err := m.store.OpenBucket(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := bucket.Close(); err != nil {
|
||||
logger.Error(ctx, "could not close bucket", logger.E(errors.WithStack(err)), logger.F("bucket", bucket))
|
||||
}
|
||||
}()
|
||||
|
||||
info, err := bucket.Get(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
reader, err := bucket.NewReader(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return reader, info, nil
|
||||
}
|
||||
|
||||
func ModuleFactory(bus bus.Bus, store storage.BlobStore) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
mod := &Module{
|
||||
store: store,
|
||||
bus: bus,
|
||||
server: server,
|
||||
}
|
||||
|
||||
go mod.handleMessages()
|
||||
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
func assertBlobID(value goja.Value, rt *goja.Runtime) storage.BlobID {
|
||||
blobID, ok := value.Export().(storage.BlobID)
|
||||
if !ok {
|
||||
rawBlobID, ok := value.Export().(string)
|
||||
if !ok {
|
||||
panic(rt.NewTypeError(fmt.Sprintf("blob id must be a blob or a string, got '%T'", value.Export())))
|
||||
}
|
||||
|
||||
blobID = storage.BlobID(rawBlobID)
|
||||
}
|
||||
|
||||
return blobID
|
||||
}
|
44
pkg/module/blob/module_test.go
Normal file
44
pkg/module/blob/module_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestBlobModule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
bus := memory.NewBus()
|
||||
store := sqlite.NewBlobStore(":memory:?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000")
|
||||
|
||||
server := app.NewServer(
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
ModuleFactory(bus, store),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/blob.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Load("testdata/blob.js", string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer server.Stop()
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
}
|
79
pkg/module/blob/testdata/blob.js
vendored
Normal file
79
pkg/module/blob/testdata/blob.js
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
|
||||
var ctx = context.new();
|
||||
var buckets = blob.listBuckets(ctx);
|
||||
|
||||
if (!buckets || buckets.length === 0) {
|
||||
throw new Error("buckets should not be empty");
|
||||
}
|
||||
|
||||
var size = blob.getBucketSize(ctx, blob.DEFAULT_BUCKET);
|
||||
|
||||
if (size !== 0) {
|
||||
throw new Error("bucket size: expected '0', got '"+size+"'");
|
||||
}
|
||||
|
||||
var newBucket = "mybucket"
|
||||
var blobId = "foo"
|
||||
var data = (new Date()).toString();
|
||||
|
||||
blob.writeBlob(ctx, newBucket, blobId, data)
|
||||
|
||||
buckets = blob.listBuckets(ctx);
|
||||
|
||||
if (buckets.length !== 2) {
|
||||
throw new Error("buckets.length: expected '2', got '"+buckets.length+"'");
|
||||
}
|
||||
|
||||
size = blob.getBucketSize(ctx, newBucket);
|
||||
|
||||
if (size !== data.length) {
|
||||
throw new Error("bucket size: expected '"+data.length+"', got '"+size+"'");
|
||||
}
|
||||
|
||||
var blobInfos = blob.listBlobs(ctx, newBucket);
|
||||
|
||||
if (blobInfos.length !== 1) {
|
||||
throw new Error("blobInfos.length: expected '1', got '"+blobInfos.length+"'");
|
||||
}
|
||||
|
||||
if (blobInfos[0].id != blobId) {
|
||||
throw new Error("blobInfos[0].id: expected '"+blobId+"', got '"+blobInfos[0].id+"'");
|
||||
}
|
||||
|
||||
if (blobInfos[0].contentType != "text/plain; charset=utf-8") {
|
||||
throw new Error("blobInfos[0].contentType: expected 'text/plain; charset=utf-8', got '"+blobInfos[0].contentType+"'");
|
||||
}
|
||||
|
||||
if (blobInfos[0].size != data.length) {
|
||||
throw new Error("blobInfos[0].size: expected '"+data.length+"', got '"+blobInfos[0].size+"'");
|
||||
}
|
||||
|
||||
var readData = blob.readBlob(ctx, newBucket, blobId)
|
||||
|
||||
if (!readData) {
|
||||
throw new Error("readData should not be nil");
|
||||
}
|
||||
|
||||
var buckets = blob.listBuckets(ctx);
|
||||
|
||||
if (!buckets || buckets.length !== 2) {
|
||||
throw new Error("buckets.length should be 2");
|
||||
}
|
||||
|
||||
blob.deleteBlob(ctx, newBucket, blobId)
|
||||
|
||||
blobInfos = blob.listBlobs(ctx, newBucket);
|
||||
|
||||
console.log(blobInfos);
|
||||
|
||||
if (blobInfos.length !== 0) {
|
||||
throw new Error("blobInfos.length: expected '0', got '"+blobInfos.length+"'");
|
||||
}
|
||||
|
||||
blob.deleteBucket(ctx, newBucket)
|
||||
|
||||
buckets = blob.listBuckets(ctx);
|
||||
|
||||
if (buckets.length !== 1) {
|
||||
throw new Error("buckets.length: expected '1', got '"+buckets.length+"'");
|
||||
}
|
@ -39,7 +39,7 @@ const (
|
||||
)
|
||||
|
||||
func getDeviceClientByUUID(ctx context.Context, uuid string) (*cast.Client, error) {
|
||||
device, err := findDeviceByUUID(ctx, uuid)
|
||||
device, err := FindDeviceByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
@ -49,7 +49,7 @@ func getDeviceClientByUUID(ctx context.Context, uuid string) (*cast.Client, erro
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func findDeviceByUUID(ctx context.Context, uuid string) (*Device, error) {
|
||||
func FindDeviceByUUID(ctx context.Context, uuid string) (*Device, error) {
|
||||
service := discovery.NewService(ctx)
|
||||
defer service.Stop()
|
||||
|
||||
@ -83,7 +83,7 @@ LOOP:
|
||||
return nil, errors.WithStack(ErrDeviceNotFound)
|
||||
}
|
||||
|
||||
func findDevices(ctx context.Context) ([]*Device, error) {
|
||||
func FindDevices(ctx context.Context) ([]*Device, error) {
|
||||
service := discovery.NewService(ctx)
|
||||
defer service.Stop()
|
||||
|
||||
@ -124,7 +124,7 @@ LOOP:
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func loadURL(ctx context.Context, deviceUUID string, url string) error {
|
||||
func LoadURL(ctx context.Context, deviceUUID string, url string) error {
|
||||
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
@ -153,7 +153,7 @@ func isLoadURLContextExceeded(err error) bool {
|
||||
return err.Error() == "Failed to send load command: context deadline exceeded"
|
||||
}
|
||||
|
||||
func stopCast(ctx context.Context, deviceUUID string) error {
|
||||
func StopCast(ctx context.Context, deviceUUID string) error {
|
||||
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
|
@ -26,7 +26,7 @@ func TestCastLoadURL(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := findDevices(ctx)
|
||||
devices, err := FindDevices(ctx)
|
||||
if err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
@ -40,7 +40,7 @@ func TestCastLoadURL(t *testing.T) {
|
||||
ctx, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
if err := loadURL(ctx, dev.UUID, "https://go.dev"); err != nil {
|
||||
if err := LoadURL(ctx, dev.UUID, "https://go.dev"); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ func TestCastLoadURL(t *testing.T) {
|
||||
ctx, cancel4 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel4()
|
||||
|
||||
if err := stopCast(ctx, dev.UUID); err != nil {
|
||||
if err := StopCast(ctx, dev.UUID); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ func (m *Module) refreshDevices(call goja.FunctionCall, rt *goja.Runtime) goja.V
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
devices, err := findDevices(ctx)
|
||||
devices, err := FindDevices(ctx)
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error refreshing casting devices list", logger.E(errors.WithStack(err)))
|
||||
@ -128,7 +128,7 @@ func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
err := loadURL(ctx, deviceUUID, url)
|
||||
err := LoadURL(ctx, deviceUUID, url)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error while casting url", logger.E(err))
|
||||
@ -166,7 +166,7 @@ func (m *Module) stopCast(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
err := stopCast(ctx, deviceUUID)
|
||||
err := StopCast(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error while quitting casting device app", logger.E(errors.WithStack(err)))
|
||||
|
49
pkg/module/fetch/fetch_message.go
Normal file
49
pkg/module/fetch/fetch_message.go
Normal file
@ -0,0 +1,49 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageNamespaceFetchRequest bus.MessageNamespace = "fetchRequest"
|
||||
MessageNamespaceFetchResponse bus.MessageNamespace = "fetchResponse"
|
||||
)
|
||||
|
||||
type MessageFetchRequest struct {
|
||||
Context context.Context
|
||||
RequestID string
|
||||
URL *url.URL
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
func (m *MessageFetchRequest) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceFetchRequest
|
||||
}
|
||||
|
||||
func NewMessageFetchRequest(ctx context.Context, remoteAddr string, url *url.URL) *MessageFetchRequest {
|
||||
return &MessageFetchRequest{
|
||||
Context: ctx,
|
||||
RequestID: ulid.Make().String(),
|
||||
RemoteAddr: remoteAddr,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
type MessageFetchResponse struct {
|
||||
RequestID string
|
||||
Allow bool
|
||||
}
|
||||
|
||||
func (m *MessageFetchResponse) MessageNamespace() bus.MessageNamespace {
|
||||
return MessageNamespaceFetchResponse
|
||||
}
|
||||
|
||||
func NewMessageFetchResponse(requestID string) *MessageFetchResponse {
|
||||
return &MessageFetchResponse{
|
||||
RequestID: requestID,
|
||||
}
|
||||
}
|
122
pkg/module/fetch/module.go
Normal file
122
pkg/module/fetch/module.go
Normal file
@ -0,0 +1,122 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
server *app.Server
|
||||
bus bus.Bus
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "fetch"
|
||||
}
|
||||
|
||||
func (m *Module) Export(export *goja.Object) {
|
||||
funcs := map[string]any{
|
||||
"get": m.get,
|
||||
}
|
||||
|
||||
for name, fn := range funcs {
|
||||
if err := export.Set(name, fn); err != nil {
|
||||
panic(errors.Wrapf(err, "could not set '%s' function", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) get(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
// ctx := util.AssertContext(call.Argument(0), rt)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) handleMessages() {
|
||||
ctx := context.Background()
|
||||
|
||||
err := m.bus.Reply(ctx, MessageNamespaceFetchRequest, func(msg bus.Message) (bus.Message, error) {
|
||||
fetchRequest, ok := msg.(*MessageFetchRequest)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(bus.ErrUnexpectedMessage, "expected message fetch request, got '%T'", msg)
|
||||
}
|
||||
|
||||
res, err := m.handleFetchRequest(fetchRequest)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not handle fetch request", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "fetch request response", logger.F("response", res))
|
||||
|
||||
return res, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) handleFetchRequest(req *MessageFetchRequest) (*MessageFetchResponse, error) {
|
||||
res := NewMessageFetchResponse(req.RequestID)
|
||||
|
||||
ctx := logger.With(
|
||||
req.Context,
|
||||
logger.F("url", req.URL.String()),
|
||||
logger.F("remoteAddr", req.RemoteAddr),
|
||||
logger.F("requestID", req.RequestID),
|
||||
)
|
||||
|
||||
rawResult, err := m.server.ExecFuncByName(ctx, "onClientFetch", ctx, req.URL.String(), req.RemoteAddr)
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrFuncDoesNotExist) {
|
||||
res.Allow = false
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
result, ok := rawResult.Export().(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errors.Errorf(
|
||||
"unexpected onClientFetch result: expected 'map[string]interface{}', got '%T'",
|
||||
rawResult.Export(),
|
||||
)
|
||||
}
|
||||
|
||||
var allow bool
|
||||
|
||||
rawAllow, exists := result["allow"]
|
||||
if !exists {
|
||||
allow = false
|
||||
} else {
|
||||
allow, ok = rawAllow.(bool)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid 'allow' result property: got type '%T', expected type '%T'", rawAllow, false)
|
||||
}
|
||||
}
|
||||
|
||||
res.Allow = allow
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func ModuleFactory(bus bus.Bus) app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
mod := &Module{
|
||||
bus: bus,
|
||||
server: server,
|
||||
}
|
||||
|
||||
go mod.handleMessages()
|
||||
|
||||
return mod
|
||||
}
|
||||
}
|
84
pkg/module/fetch/module_test.go
Normal file
84
pkg/module/fetch/module_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/bus/memory"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestFetchModule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
bus := memory.NewBus()
|
||||
|
||||
server := app.NewServer(
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
ModuleFactory(bus),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/fetch.js")
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := server.Load("testdata/fetch.js", string(data)); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
defer server.Stop()
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
// Wait for module to startup
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
remoteAddr := "127.0.0.1"
|
||||
url, _ := url.Parse("http://example.com")
|
||||
|
||||
rawReply, err := bus.Request(ctx, NewMessageFetchRequest(ctx, remoteAddr, url))
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
reply, ok := rawReply.(*MessageFetchResponse)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected reply type '%T'", rawReply)
|
||||
}
|
||||
|
||||
if e, g := true, reply.Allow; e != g {
|
||||
t.Errorf("reply.Allow: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
url, _ = url.Parse("https://google.com")
|
||||
|
||||
rawReply, err = bus.Request(ctx, NewMessageFetchRequest(ctx, remoteAddr, url))
|
||||
if err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
reply, ok = rawReply.(*MessageFetchResponse)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected reply type '%T'", rawReply)
|
||||
}
|
||||
|
||||
if e, g := false, reply.Allow; e != g {
|
||||
t.Errorf("reply.Allow: expected '%v', got '%v'", e, g)
|
||||
}
|
||||
}
|
7
pkg/module/fetch/testdata/fetch.js
vendored
Normal file
7
pkg/module/fetch/testdata/fetch.js
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
var ctx = context.new();
|
||||
|
||||
function onClientFetch(ctx, url, remoteAddr) {
|
||||
if (url === 'http://example.com') return { allow: true };
|
||||
return { allow: false };
|
||||
}
|
@ -38,9 +38,10 @@ func (m *Module) broadcast(call goja.FunctionCall, rt *goja.Runtime) goja.Value
|
||||
}
|
||||
|
||||
data := call.Argument(0).Export()
|
||||
ctx := context.Background()
|
||||
|
||||
msg := module.NewServerMessage(nil, data)
|
||||
if err := m.bus.Publish(context.Background(), msg); err != nil {
|
||||
msg := module.NewServerMessage(ctx, data)
|
||||
if err := m.bus.Publish(ctx, msg); err != nil {
|
||||
panic(rt.ToValue(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ import (
|
||||
func TestStoreModule(t *testing.T) {
|
||||
logger.SetLevel(logger.LevelDebug)
|
||||
|
||||
store := sqlite.NewDocumentStore(":memory:")
|
||||
store := sqlite.NewDocumentStore(":memory:?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000")
|
||||
server := app.NewServer(
|
||||
module.ContextModuleFactory(),
|
||||
module.ConsoleModuleFactory(),
|
||||
|
@ -12,7 +12,7 @@ func AssertType[T any](v goja.Value, rt *goja.Runtime) T {
|
||||
return c
|
||||
}
|
||||
|
||||
panic(rt.ToValue(errors.Errorf("expected value to be a '%T', got '%T'", *new(T), v.Export())))
|
||||
panic(rt.ToValue(errors.Errorf("expected value to be a '%T', got '%T'", new(T), v.Export())))
|
||||
}
|
||||
|
||||
func AssertContext(v goja.Value, r *goja.Runtime) context.Context {
|
||||
|
24868
pkg/sdk/client/dist/client.js
vendored
Normal file
24868
pkg/sdk/client/dist/client.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
pkg/sdk/client/dist/client.js.map
vendored
Normal file
7
pkg/sdk/client/dist/client.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user