Compare commits

...

22 Commits

Author SHA1 Message Date
b5b4042cc7 feat(sdk,client): add menu to help navigation between apps
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-20 10:17:37 +02:00
9e3fc427bb feat(sdk,client): target es2015 for old chromecast compatibility
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-14 16:24:34 +02:00
d0b57ab15f fix(client,sdk): accept empty token from parent frame
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-13 13:41:35 +02:00
dc93c585eb fix(sdk,client): add listener to current frame window
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-13 12:07:52 +02:00
de330c0042 fix(sdk,client): use origin as postmessage target
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-13 11:35:31 +02:00
310dac296f feat(storage,sqlite): begin tx with context
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-13 11:23:34 +02:00
4db7576b12 feat(client,sdk): retrieve auth token from parent frame + better resize detection
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-13 11:02:24 +02:00
f5283b86ed fix(app,manifest): manifest serialization
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-11 15:08:07 +02:00
98ebd7a168 doc(app,manifest): add metadata attribute in manifest schema
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-11 11:11:00 +02:00
8ca31d05c0 feat(app,manifest): validation + extendable metadatas
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-11 11:05:09 +02:00
34c6a089b5 fix(client,sdk): permit cross-domain message communication
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 20:54:01 +02:00
da73b842e1 fix(sdk,client): initialize crossframe observers after window load event
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 19:18:36 +02:00
55d7241d95 chore(sdk,client): remove restrictive assertion
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 18:18:12 +02:00
240b07af66 feat(sdk,client): add edgeframe sdk api
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 18:16:17 +02:00
68e35bf5a6 fix(client,sdk): remove too specific assertion
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 15:59:09 +02:00
4bc2d864ad chore: add jenkins ci pipeline
All checks were successful
arcad/edge/pipeline/head This commit looks good
2023-04-06 14:58:12 +02:00
dc18381dea chore: add test timeout 2023-04-06 14:47:37 +02:00
1dde96043a chore: reenable tests in watch mode 2023-04-06 14:47:13 +02:00
f758acb4e5 fix(module,fetch): wait for module initialization to prevent false failure in test 2023-04-06 14:46:46 +02:00
054e80bbfb fix(storage,sqlite): prevent 'database is busy' error by using busy_timeout pragma 2023-04-06 14:45:50 +02:00
32c6f0a77e feat(cli,run): resolve app url based on available network interfaces 2023-04-06 11:52:04 +02:00
050e529f0a doc(module,app): add new parameter 'from' to app.getUrl() 2023-04-06 11:27:27 +02:00
92 changed files with 23350 additions and 733 deletions

1
.env.dist Normal file
View File

@ -0,0 +1 @@
RUN_APP_ARGS=""

49
Jenkinsfile vendored Normal file
View 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()
}
}
}

View File

@ -2,15 +2,18 @@ LINT_ARGS ?= --timeout 5m
GITCHLOG_ARGS ?= GITCHLOG_ARGS ?=
SHELL := /bin/bash SHELL := /bin/bash
GOTEST_ARGS ?= -short GOTEST_ARGS ?= -short -timeout 60s
ESBUILD_VERSION ?= v0.17.5 ESBUILD_VERSION ?= v0.17.5
GIT_VERSION := $(shell git describe --always) GIT_VERSION := $(shell git describe --always)
DATE_VERSION := $(shell date +%Y.%-m.%-d) DATE_VERSION := $(shell date +%Y.%-m.%-d)
FULL_VERSION := v$(DATE_VERSION)-$(GIT_VERSION)$(if $(shell git diff --stat),-dirty,) 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: watch:
go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest
@ -30,10 +33,12 @@ build-edge-cli: build-sdk
-o ./bin/cli \ -o ./bin/cli \
./cmd/cli ./cmd/cli
build-client-sdk-test-app:
cd misc/client-sdk-testsuite && $(MAKE) dist
install-git-hooks: install-git-hooks:
git config core.hooksPath .githooks git config core.hooksPath .githooks
tools/esbuild/bin/esbuild: tools/esbuild/bin/esbuild:
mkdir -p tools/esbuild/bin mkdir -p tools/esbuild/bin
curl -fsSL https://esbuild.github.io/dl/$(ESBUILD_VERSION) | sh curl -fsSL https://esbuild.github.io/dl/$(ESBUILD_VERSION) | sh
@ -48,17 +53,23 @@ pkg/sdk/client/dist/client.js: tools/esbuild/bin/esbuild node_modules
pkg/sdk/client/src/index.ts \ pkg/sdk/client/src/index.ts \
--bundle \ --bundle \
--sourcemap \ --sourcemap \
--target=es2020 \ --target=es2015 \
--format=iife \ --format=iife \
--global-name=Edge \ --global-name=Edge \
--define:global=window \ --define:global=window \
--platform=browser \ --platform=browser \
--footer:js="Edge=Edge.default;" \ --loader:.svg=dataurl \
--outfile=pkg/sdk/client/dist/client.js --outfile=pkg/sdk/client/dist/client.js
node_modules: node_modules:
npm ci npm ci
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 gitea-release: tools/yq/bin/yq tools/gitea-release/bin/gitea-release.sh build
mkdir -p .gitea-release mkdir -p .gitea-release
rm -rf .gitea-release/* rm -rf .gitea-release/*

View 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),
}

View File

@ -4,9 +4,9 @@
"algo": "argon2id", "algo": "argon2id",
"password": "$argon2id$v=19$m=65536,t=3,p=2$cWOxfEyBy4EyKZR5usB2Pw$xG+Z/E2DUJP9kF0s1fhZjIuP03gFQ65dP7pHRJz7eR8", "password": "$argon2id$v=19$m=65536,t=3,p=2$cWOxfEyBy4EyKZR5usB2Pw$xG+Z/E2DUJP9kF0s1fhZjIuP03gFQ65dP7pHRJz7eR8",
"claims": { "claims": {
"arcad_entrypoint": "edge", "edge_entrypoint": "edge",
"arcad_role": "superadmin", "edge_role": "superadmin",
"arcad_tenant": "dev.cli", "edge_tenant": "dev.cli",
"preferred_username": "SuperAdmin", "preferred_username": "SuperAdmin",
"sub": "superadmin" "sub": "superadmin"
} }
@ -16,9 +16,9 @@
"algo": "argon2id", "algo": "argon2id",
"password": "$argon2id$v=19$m=65536,t=3,p=2$WXXc4ECnkej6WO7f0Xya6Q$UG2wcGltJcuW0cNTR85mAl65tI1kFWMMw7ADS2FMOvY", "password": "$argon2id$v=19$m=65536,t=3,p=2$WXXc4ECnkej6WO7f0Xya6Q$UG2wcGltJcuW0cNTR85mAl65tI1kFWMMw7ADS2FMOvY",
"claims": { "claims": {
"arcad_entrypoint": "edge", "edge_entrypoint": "edge",
"arcad_role": "admin", "edge_role": "admin",
"arcad_tenant": "dev.cli", "edge_tenant": "dev.cli",
"preferred_username": "Admin", "preferred_username": "Admin",
"sub": "admin" "sub": "admin"
} }
@ -28,9 +28,9 @@
"algo": "argon2id", "algo": "argon2id",
"password": "$argon2id$v=19$m=65536,t=3,p=2$gkDAWCzfU23+un3x0ny+YA$L/NSPrd5iKPK/UnSCKfSz4EO+v94N3LTLky4QGJOfpI", "password": "$argon2id$v=19$m=65536,t=3,p=2$gkDAWCzfU23+un3x0ny+YA$L/NSPrd5iKPK/UnSCKfSz4EO+v94N3LTLky4QGJOfpI",
"claims": { "claims": {
"arcad_entrypoint": "edge", "edge_entrypoint": "edge",
"arcad_role": "superuser", "edge_role": "superuser",
"arcad_tenant": "dev.cli", "edge_tenant": "dev.cli",
"preferred_username": "SuperUser", "preferred_username": "SuperUser",
"sub": "superuser" "sub": "superuser"
} }
@ -40,9 +40,9 @@
"algo": "argon2id", "algo": "argon2id",
"password": "$argon2id$v=19$m=65536,t=3,p=2$DhUm9qXUKP35Lzp5M37eZA$2+h6yDxSTHZqFZIuI7JZfFWozwrObna8a8yCgEEPlPE", "password": "$argon2id$v=19$m=65536,t=3,p=2$DhUm9qXUKP35Lzp5M37eZA$2+h6yDxSTHZqFZIuI7JZfFWozwrObna8a8yCgEEPlPE",
"claims": { "claims": {
"arcad_entrypoint": "edge", "edge_entrypoint": "edge",
"arcad_role": "user", "edge_role": "user",
"arcad_tenant": "dev.cli", "edge_tenant": "dev.cli",
"preferred_username": "User", "preferred_username": "User",
"sub": "user" "sub": "user"
} }

View File

@ -52,6 +52,10 @@ func PackageCommand() *cli.Command {
return errors.Wrap(err, "could not load app manifest") 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 { if err := os.MkdirAll(outputDir, 0o755); err != nil {
return errors.Wrapf(err, "could not create directory ''%s'", outputDir) return errors.Wrapf(err, "could not create directory ''%s'", outputDir)
} }

View File

@ -5,10 +5,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus" "forge.cadoles.com/arcad/edge/pkg/bus"
@ -17,18 +20,17 @@ import (
"forge.cadoles.com/arcad/edge/pkg/module" "forge.cadoles.com/arcad/edge/pkg/module"
appModule "forge.cadoles.com/arcad/edge/pkg/module/app" appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
appModuleMemory "forge.cadoles.com/arcad/edge/pkg/module/app/memory" appModuleMemory "forge.cadoles.com/arcad/edge/pkg/module/app/memory"
"forge.cadoles.com/arcad/edge/pkg/module/auth" authModule "forge.cadoles.com/arcad/edge/pkg/module/auth"
authHTTP "forge.cadoles.com/arcad/edge/pkg/module/auth/http" 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/blob"
"forge.cadoles.com/arcad/edge/pkg/module/cast" "forge.cadoles.com/arcad/edge/pkg/module/cast"
"forge.cadoles.com/arcad/edge/pkg/module/fetch" "forge.cadoles.com/arcad/edge/pkg/module/fetch"
"forge.cadoles.com/arcad/edge/pkg/module/net" netModule "forge.cadoles.com/arcad/edge/pkg/module/net"
"forge.cadoles.com/arcad/edge/pkg/storage" "forge.cadoles.com/arcad/edge/pkg/storage"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
"forge.cadoles.com/arcad/edge/pkg/bundle" "forge.cadoles.com/arcad/edge/pkg/bundle"
"github.com/dop251/goja"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwa"
@ -47,15 +49,15 @@ func RunCommand() *cli.Command {
Name: "run", Name: "run",
Usage: "Run the specified app bundle", Usage: "Run the specified app bundle",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringSliceFlag{
Name: "path", Name: "path",
Usage: "use `PATH` as app bundle (zipped bundle or directory)", Usage: "use `PATH` as app bundle (zipped bundle or directory)",
Aliases: []string{"p"}, Aliases: []string{"p"},
Value: ".", Value: cli.NewStringSlice("."),
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "address", Name: "address",
Usage: "use `ADDRESS` as http server listening address", Usage: "use `ADDRESS` as http server base listening address",
Aliases: []string{"a"}, Aliases: []string{"a"},
Value: ":8080", Value: ":8080",
}, },
@ -72,7 +74,7 @@ func RunCommand() *cli.Command {
&cli.StringFlag{ &cli.StringFlag{
Name: "storage-file", Name: "storage-file",
Usage: "use `FILE` for SQLite storage database", Usage: "use `FILE` for SQLite storage database",
Value: ".edge/%APPID%/data.sqlite", Value: ".edge/%APPID%/data.sqlite?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000",
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "accounts-file", Name: "accounts-file",
@ -82,133 +84,170 @@ func RunCommand() *cli.Command {
}, },
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
address := ctx.String("address") address := ctx.String("address")
path := ctx.String("path") paths := ctx.StringSlice("path")
logFormat := ctx.String("log-format") logFormat := ctx.String("log-format")
logLevel := ctx.Int("log-level") logLevel := ctx.Int("log-level")
storageFile := ctx.String("storage-file")
accountsFile := ctx.String("accounts-file")
logger.SetFormat(logger.Format(logFormat)) logger.SetFormat(logger.Format(logFormat))
logger.SetLevel(logger.Level(logLevel)) logger.SetLevel(logger.Level(logLevel))
cmdCtx := ctx.Context 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)
}
manifest, err := app.LoadManifest(bundle)
if err != nil {
return errors.Wrap(err, "could not load manifest from app bundle")
}
storageFile := injectAppID(ctx.String("storage-file"), manifest.ID)
if err := ensureDir(storageFile); err != nil {
return errors.WithStack(err)
}
db, err := sqlite.Open(storageFile)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
ds := sqlite.NewDocumentStoreWithDB(db) port, err := strconv.ParseUint(portStr, 10, 32)
bs := sqlite.NewBlobStoreWithDB(db)
bus := memory.NewBus()
handler := appHTTP.NewHandler(
appHTTP.WithBus(bus),
appHTTP.WithServerModules(getServerModules(bus, ds, bs, manifest, address)...),
)
if err := handler.Load(bundle); err != nil {
return errors.Wrap(err, "could not load app bundle")
}
router := chi.NewRouter()
router.Use(middleware.Logger)
accountsFile := injectAppID(ctx.String("accounts-file"), 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 { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
router.Handle("/auth/*", authHTTP.NewLocalHandler(
jwa.HS256, key,
authHTTP.WithRoutePrefix("/auth"),
authHTTP.WithAccounts(accounts...),
))
// Add app handler manifests := make([]*app.Manifest, len(paths))
router.Handle("/*", handler) for idx, pth := range paths {
bdl, err := bundle.FromPath(pth)
if err != nil {
return errors.WithStack(err)
}
logger.Info(cmdCtx, "listening", logger.F("address", address)) manifest, err := app.LoadManifest(bdl)
if err != nil {
return errors.WithStack(err)
}
if err := http.ListenAndServe(address, router); err != nil { manifests[idx] = manifest
return errors.WithStack(err)
} }
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 return nil
}, },
} }
} }
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, manifest *app.Manifest, address string) []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{ return []app.ServerModuleFactory{
module.ContextModuleFactory(), module.ContextModuleFactory(),
module.ConsoleModuleFactory(), module.ConsoleModuleFactory(),
cast.CastModuleFactory(), cast.CastModuleFactory(),
module.LifecycleModuleFactory(), module.LifecycleModuleFactory(),
net.ModuleFactory(bus), netModule.ModuleFactory(bus),
module.RPCModuleFactory(bus), module.RPCModuleFactory(bus),
module.StoreModuleFactory(ds), module.StoreModuleFactory(ds),
blob.ModuleFactory(bus, bs), blob.ModuleFactory(bus, bs),
module.Extends( authModule.ModuleFactory(
auth.ModuleFactory( authModule.WithJWT(dummyKeySet),
auth.WithJWT(dummyKeySet),
),
func(o *goja.Object) {
if err := o.Set("CLAIM_TENANT", "arcad_tenant"); err != nil {
panic(errors.New("could not set 'CLAIM_TENANT' property"))
}
if err := o.Set("CLAIM_ENTRYPOINT", "arcad_entrypoint"); err != nil {
panic(errors.New("could not set 'CLAIM_ENTRYPOINT' property"))
}
if err := o.Set("CLAIM_ROLE", "arcad_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"))
}
},
), ),
appModule.ModuleFactory(appModuleMemory.NewRepository( appModule.ModuleFactory(appRepository),
func(ctx context.Context, id app.ID, from string) (string, error) {
if strings.HasPrefix(address, ":") {
address = "0.0.0.0" + address
}
return fmt.Sprintf("http://%s", address), nil
},
manifest,
)),
fetch.ModuleFactory(bus), fetch.ModuleFactory(bus),
} }
} }
@ -284,3 +323,74 @@ func loadLocalAccounts(path string) ([]authHTTP.LocalAccount, error) {
return accounts, nil return accounts, nil
} }
func findMatchingDeviceAddress(ctx context.Context, from string, defaultAddr string) (string, error) {
if from == "" {
return defaultAddr, nil
}
fromIP := net.ParseIP(from)
if fromIP == nil {
return defaultAddr, nil
}
ifaces, err := net.Interfaces()
if err != nil {
return "", errors.WithStack(err)
}
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),
)
continue
}
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...,
)
}

View File

@ -6,6 +6,7 @@ Une **Edge App** est une application capable de s'exécuter dans un environnemen
### Référence ### Référence
- [Fichier `manifest.yml`](./apps/manifest.md)
- [API Client](./apps/client-api/README.md) - [API Client](./apps/client-api/README.md)
- [API Serveur](./apps/server-api/README.md) - [API Serveur](./apps/server-api/README.md)

View File

@ -1,68 +1,15 @@
# API Client # 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` ```html
<script src="/edge/sdk/client.js"></script>
### `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;
}
``` ```
**Côté client** Vous pourrez ensuite accéder aux variables globales suivantes:
```js - [`Edge.Client`](./edge-client.md) - Client principal d'échange avec le serveur
Edge.connect().then(() => { - [`Edge.Frame`](./edge-frame.md) - Utilitaire de communication avec une frame parente
Edge.rpc("echo", { hello: "world!" }) - [`Edge.Menu`](./edge-menu.md) - Gestionnaire de menu
.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`
### `Edge.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.addEventListener("message", evt => console.log(evt.detail));
```

View 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));
```

View 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
}
}
```

View 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
View 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
```

View File

@ -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. 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 [Voir le fichier `manifest.yml` d'exemple](./manifest.md)
---
# 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
```
## 4. Créer la page d'accueil ## 4. Créer la page d'accueil
@ -56,13 +40,13 @@ Créer le fichier `my-app/public/index.html`:
<script type="text/javascript"> <script type="text/javascript">
// On utilise le SDK via la variable globale "Edge" // On utilise le SDK via la variable globale "Edge"
// pour se connecter au serveur de notre application. // pour se connecter au serveur de notre application.
Edge.connect().then(() => { Edge.Client.connect().then(() => {
// Une fois connecté, on envoie un message au serveur. // 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. // On écoute les messages en provenance du serveur.
Edge.addEventListener("message", (evt) => { Edge.Client.addEventListener("message", (evt) => {
console.log("New server message", evt.detail) console.log("New server message", evt.detail)
}); });
</script> </script>

View File

@ -29,7 +29,7 @@ Récupère les informations de l'application identifiée par `appId`.
Objet `Manifest` associé à l'application, ou `null` si aucune application n'a été trouvée correspondant à l'identifiant. Objet `Manifest` associé à l'application, ou `null` si aucune application n'a été trouvée correspondant à l'identifiant.
### `app.getUrl(ctx: Context, appId: string): Manifest` ### `app.getUrl(ctx: Context, appId: string, from: string = ''): Manifest`
Retourne l'URL permettant d'accéder à l'application identifiée par `appId`. Retourne l'URL permettant d'accéder à l'application identifiée par `appId`.
@ -37,6 +37,7 @@ Retourne l'URL permettant d'accéder à l'application identifiée par `appId`.
- `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md) - `ctx` **Context** Le contexte d'exécution. Voir la documentation du module [`context`](./context.md)
- `appId` **string** Identifiant de l'application - `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 #### Valeur de retour
@ -48,10 +49,11 @@ URL associée à l'application, ou `null` si aucune application n'a été trouv
```typescript ```typescript
interface Manifest { interface Manifest {
id: string // Identifiant de l'application id: string // Identifiant de l'application
version: string // Version de l'application version: string // Version de l'application
title: string // Titre associé à l'application title: string // Titre associé à l'application
description: string // Description associée à l'application description: string // Description associée à l'application
tags: string[] // Mots clés associés à 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
} }
``` ```

View File

@ -86,4 +86,4 @@ interface BlobInfo {
### `Metadata` ### `Metadata`
L'objet `Metadata` est un objet clé/valeur arbitraire transmis avec la requête de téléversement. Voir la méthode [`Edge.upload(blob, metadata)`](../client-api/README.md#edge-upload-blob-blob-metadata-object-promise) du SDK client. 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.

View File

@ -13,7 +13,7 @@ Pour permettre aux utilisateurs d'accéder à des ressources distantes, vous dev
**Côté client** **Côté client**
```js ```js
// Création d'une URL "locale" permettant d'accéder à la ressource distante // Création d'une URL "locale" permettant d'accéder à la ressource distante
var url = Edge.externalUrl("http://example.com") var url = Edge.Client.externalUrl("http://example.com")
// Vous pouvez utiliser l'URL comme attribut `src` d'une balise <img> par exemple // Vous pouvez utiliser l'URL comme attribut `src` d'une balise <img> par exemple
// ou effectuer une requête fetch() avec celle ci. // ou effectuer une requête fetch() avec celle ci.

View File

@ -32,9 +32,9 @@ Aucune
```js ```js
// Les données envoyées par le serveur sont accessibles // Les données envoyées par le serveur sont accessibles
// via la propriété evt.detail. // 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** **Côté serveur**

View File

@ -1,6 +1,6 @@
# Module `rpc` # 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 ## Méthodes
@ -31,8 +31,8 @@ function echo(ctx, params) {
**Côté client** **Côté client**
```js ```js
Edge.connect().then(() => { Edge.Client.connect().then(() => {
Edge.rpc("echo", { hello: "world!" }) Edge.Client.rpc("echo", { hello: "world!" })
.then(result => console.log(result)) .then(result => console.log(result))
.catch(err => console.error(err)); .catch(err => console.error(err));
}); });

6
go.mod
View File

@ -10,17 +10,21 @@ require (
require ( require (
github.com/brutella/dnssd v1.2.6 // indirect github.com/brutella/dnssd v1.2.6 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // 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/goccy/go-json v0.9.11 // indirect
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // 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/go.net v0.0.0-20151006203346-104dcad90073 // indirect
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
github.com/leodido/go-urn v1.1.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc 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/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.0 // indirect github.com/lestrrat-go/option v1.0.0 // indirect
github.com/miekg/dns v1.1.50 // indirect github.com/miekg/dns v1.1.50 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
) )
require ( require (
@ -54,7 +58,7 @@ require (
github.com/spf13/afero v1.9.3 // indirect github.com/spf13/afero v1.9.3 // indirect
github.com/urfave/cli/v2 v2.24.3 github.com/urfave/cli/v2 v2.24.3
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
go.opencensus.io v0.22.5 // indirect go.opencensus.io v0.22.5 // indirect
golang.org/x/crypto v0.7.0 // indirect golang.org/x/crypto v0.7.0 // indirect
golang.org/x/mod v0.8.0 // indirect golang.org/x/mod v0.8.0 // indirect

6
go.sum
View File

@ -109,7 +109,9 @@ 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 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-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-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/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-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 h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
@ -210,6 +212,7 @@ 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/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.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/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 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
@ -291,6 +294,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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 h1:6JlkcdjYVQglPWYuemK2MoZAtRE4vFx85zLXflGIyI8=
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY= 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.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -636,6 +641,7 @@ 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/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/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/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/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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -4,4 +4,9 @@ title: SDK Test
version: 0.0.0 version: 0.0.0
description: | description: |
Suite de tests pour le SDK client Suite de tests pour le SDK client
tags: ["test"] tags: ["test"]
metadata:
paths:
icon: /icon.png
minimumRole: superadmin

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -4,10 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Client SDK Test suite</title> <title>Client SDK Test suite</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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" /> <link rel="stylesheet" href="/vendor/mocha.css" />
<style> <style>
body { body {
background-color: white; background-color: #f7f7f7;
}
body:not([edge-auto-padding="false"]) #mocha-stats {
top: 75px !important;
} }
</style> </style>
</head> </head>
@ -29,6 +33,17 @@
<script src="/test/fetch-module.js"></script> <script src="/test/fetch-module.js"></script>
<script class="mocha-exec"> <script class="mocha-exec">
mocha.run(); 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> </script>
</body> </body>
</html> </html>

View File

@ -1,15 +1,15 @@
describe('App Module', function() { describe('App Module', function() {
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should list apps', function() { it('should list apps', function() {
return Edge.rpc("listApps") return Edge.Client.rpc("listApps")
.then(apps => { .then(apps => {
console.log("listApps result:", apps); console.log("listApps result:", apps);
chai.assert.isNotNull(apps); chai.assert.isNotNull(apps);
@ -18,7 +18,7 @@ describe('App Module', function() {
}); });
it('should retrieve requested app', function() { it('should retrieve requested app', function() {
return Edge.rpc("getApp", { appId: "edge.sdk.client.test" }) return Edge.Client.rpc("getApp", { appId: "edge.sdk.client.test" })
.then(app => { .then(app => {
console.log("getApp result:", app); console.log("getApp result:", app);
chai.assert.isNotNull(app); chai.assert.isNotNull(app);
@ -26,8 +26,16 @@ describe('App Module', function() {
}) })
}); });
it('should retrieve requested app url', function() { it('should retrieve requested app url without from address', function() {
return Edge.rpc("getAppUrl", { appId: "edge.sdk.client.test" }) 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 => { .then(url => {
console.log("getAppUrl result:", url); console.log("getAppUrl result:", url);
chai.assert.isNotEmpty(url); chai.assert.isNotEmpty(url);

View File

@ -1,15 +1,15 @@
describe('Auth Module', function() { describe('Auth Module', function() {
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should retrieve user informations', function() { it('should retrieve user informations', function() {
return Edge.rpc("getUserInfo") return Edge.Client.rpc("getUserInfo")
.then(userInfo => { .then(userInfo => {
console.log("getUserInfo result:", userInfo); console.log("getUserInfo result:", userInfo);
chai.assert.property(userInfo, 'subject'); chai.assert.property(userInfo, 'subject');

View File

@ -1,24 +1,25 @@
Edge.debug = true; Edge.Client.debug = true;
Edge.Frame.debug = true;
describe('Edge', function() { describe('Edge', function() {
describe('#connect()', function() { describe('#connect()', function() {
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should open the connection', function() { it('should open the connection', function() {
return Edge.connect() return Edge.Client.connect()
.then(() => { .then(() => {
chai.assert.isNotNull(Edge._conn); chai.assert.isNotNull(Edge.Client._conn);
}); });
}); });
}); });
describe('#disconnect()', function() { describe('#disconnect()', function() {
it('should close the connection', function() { it('should close the connection', function() {
Edge.disconnect(); Edge.Client.disconnect();
chai.assert.isNull(Edge._conn); chai.assert.isNull(Edge.Client._conn);
}); });
}); });

View File

@ -1,15 +1,15 @@
describe('Fetch Module', function () { describe('Fetch Module', function () {
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should fetch an authorized external url', function () { it('should fetch an authorized external url', function () {
var externalUrl = Edge.externalUrl("http://example.com"); var externalUrl = Edge.Client.externalUrl("http://example.com");
return fetch(externalUrl) return fetch(externalUrl)
.then(res => { .then(res => {
@ -22,7 +22,7 @@ describe('Fetch Module', function () {
}); });
it('should not fetch an unauthorized external url', function () { it('should not fetch an unauthorized external url', function () {
var externalUrl = Edge.externalUrl("https://google.com"); var externalUrl = Edge.Client.externalUrl("https://google.com");
return fetch(externalUrl) return fetch(externalUrl)
.then(res => { .then(res => {

View File

@ -1,25 +1,25 @@
describe('File Module', function () { describe('File Module', function () {
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should upload then download a blob', function () { it('should upload then download a blob', function () {
const content = JSON.stringify({ "date": new Date() }); const content = JSON.stringify({ "date": new Date() });
const blob = new Blob([content], { type: "application/json" }); const blob = new Blob([content], { type: "application/json" });
return Edge.upload(blob) return Edge.Client.upload(blob)
.then(upload => upload.result()) .then(upload => upload.result())
.then(result => { .then(result => {
chai.assert.isNotEmpty(result.blobId); chai.assert.isNotEmpty(result.blobId);
chai.assert.isNotEmpty(result.bucket); chai.assert.isNotEmpty(result.bucket);
const blobUrl = Edge.blobUrl(result.bucket, result.blobId); const blobUrl = Edge.Client.blobUrl(result.bucket, result.blobId);
chai.assert.isNotEmpty(blobUrl); chai.assert.isNotEmpty(blobUrl);
return fetch(blobUrl) return fetch(blobUrl)

View File

@ -2,11 +2,11 @@ describe('Net Module', function () {
this.timeout(5000); this.timeout(5000);
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should broadcast a message from server', function (done) { it('should broadcast a message from server', function (done) {
@ -18,12 +18,12 @@ describe('Net Module', function () {
chai.assert.deepEqual(message, evt.detail); chai.assert.deepEqual(message, evt.detail);
Edge.removeEventListener('message', handler); Edge.Client.removeEventListener('message', handler);
done(); done();
}; };
Edge.addEventListener("message", handler); Edge.Client.addEventListener("message", handler);
Edge.send(message); Edge.Client.send(message);
}); });
it('should send a message to the server and echo back', function(done) { it('should send a message to the server and echo back', function(done) {
@ -35,15 +35,15 @@ describe('Net Module', function () {
chai.assert.equal(receivedMessage.now, now.toJSON()); chai.assert.equal(receivedMessage.now, now.toJSON());
Edge.removeEventListener('message', handler); Edge.Client.removeEventListener('message', handler);
done(); done();
} }
// Server should echo back message // Server should echo back message
Edge.addEventListener('message', handler); Edge.Client.addEventListener('message', handler);
// Send message to server // Send message to server
Edge.send({ test: 'echo', now }); Edge.Client.send({ test: 'echo', now });
}); });
}); });

View File

@ -1,17 +1,17 @@
describe('Remote Procedure Call', function () { describe('Remote Procedure Call', function () {
before(() => { before(() => {
return Edge.connect(); return Edge.Client.connect();
}); });
after(() => { after(() => {
Edge.disconnect(); Edge.Client.disconnect();
}); });
it('should call the remote echo() method and resolve the returned value', function () { it('should call the remote echo() method and resolve the returned value', function () {
const foo = "bar"; const foo = "bar";
return Edge.rpc('echo', { foo }) return Edge.Client.rpc('echo', { foo })
.then(result => { .then(result => {
console.log(result); console.log(result);
chai.assert.equal(result.foo, foo); chai.assert.equal(result.foo, foo);
@ -19,7 +19,7 @@ describe('Remote Procedure Call', function () {
}); });
it('should call the remote throwErrorFromClient() method and reject with an error', function () { it('should call the remote throwErrorFromClient() method and reject with an error', function () {
return Edge.rpc('throwErrorFromClient') return Edge.Client.rpc('throwErrorFromClient')
.catch(err => { .catch(err => {
// Assert that it's an "internal" error // Assert that it's an "internal" error
// See https://www.jsonrpc.org/specification#error_object // See https://www.jsonrpc.org/specification#error_object
@ -28,7 +28,7 @@ describe('Remote Procedure Call', function () {
}); });
it('should call an unregistered method and reject with an error', function () { it('should call an unregistered method and reject with an error', function () {
return Edge.rpc('unregisteredMethod') return Edge.Client.rpc('unregisteredMethod')
.catch(err => { .catch(err => {
// Assert that it's an "method not found" error // Assert that it's an "method not found" error
// See https://www.jsonrpc.org/specification#error_object // See https://www.jsonrpc.org/specification#error_object
@ -38,17 +38,17 @@ describe('Remote Procedure Call', function () {
it('should call the add() method repetitively and keep count of the sent values', function () { it('should call the add() method repetitively and keep count of the sent values', function () {
this.timeout(10000); this.timeout(30000);
const values = []; const values = [];
for (let i = 0; i <= 1000; i++) { for (let i = 0; i <= 1000; i++) {
values.push((Math.random() * 1000 | 0)); values.push((Math.random() * 1000 | 0));
} }
return Edge.rpc('reset') return Edge.Client.rpc('reset')
.then(() => { .then(() => {
return Promise.all(values.map(v => Edge.rpc("add", { value: v }))); return Promise.all(values.map(v => Edge.Client.rpc("add", { value: v })));
}) })
.then(() => Edge.rpc('total')) .then(() => Edge.Client.rpc('total'))
.then(remoteTotal => { .then(remoteTotal => {
const localTotal = values.reduce((t, v) => t + v); const localTotal = values.reduce((t, v) => t + v);
console.log("Remote total:", remoteTotal, "Local total:", localTotal); console.log("Remote total:", remoteTotal, "Local total:", localTotal);

View File

@ -96,7 +96,9 @@ function getApp(ctx, params) {
function getAppUrl(ctx, params) { function getAppUrl(ctx, params) {
var appId = params.appId; var appId = params.appId;
return app.getUrl(ctx, appId); var from = params.from;
return app.getUrl(ctx, appId, from ? from : '');
} }
function onClientFetch(ctx, url, remoteAddr) { function onClientFetch(ctx, url, remoteAddr) {

28
misc/jenkins/Dockerfile Normal file
View 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

View File

@ -6,8 +6,11 @@ misc/client-sdk-testsuite/src/**/*
modd.conf modd.conf
{ {
prep: make build-sdk prep: make build-sdk
prep: cd misc/client-sdk-testsuite && make dist prep: make build-client-sdk-test-app
prep: make build prep: make build
# prep: make GOTEST_ARGS="-short" test daemon: make run-app
daemon: bin/cli app run -p misc/client-sdk-testsuite/dist
} }
**/*.go {
prep: make GOTEST_ARGS="-short" test
}

120
package-lock.json generated
View File

@ -10,14 +10,50 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@types/sockjs-client": "^1.5.1", "@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" "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": { "node_modules/@types/sockjs-client": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A==" "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": { "node_modules/debug": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "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", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -139,11 +203,39 @@
} }
}, },
"dependencies": { "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": { "@types/sockjs-client": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A==" "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": { "debug": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "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", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "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": { "ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -10,6 +10,9 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@types/sockjs-client": "^1.5.1", "@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" "sockjs-client": "^1.6.1"
} }
} }

View File

@ -1,39 +0,0 @@
package app
import (
"forge.cadoles.com/arcad/edge/pkg/bundle"
"github.com/pkg/errors"
"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"`
}
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
}

85
pkg/app/manifest.go Normal file
View 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
View 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)
}
}

View 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)
}
}

View 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
}
}

View File

@ -0,0 +1,7 @@
id: foo.arcad.app
version: v0.0.0
title: Foo
description: A test app
tags: ["test"]
metadata:
minimumRole: foo

View 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

View 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

View 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)
}
}

View File

@ -103,13 +103,19 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
r.Get("/client.js.map", handler.handleSDKClientMap) r.Get("/client.js.map", handler.handleSDKClientMap)
}) })
r.Route("/api/v1", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Post("/upload", handler.handleAppUpload) r.Post("/v1/upload", handler.handleAppUpload)
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload) r.Get("/v1/download/{bucket}/{blobID}", handler.handleAppDownload)
r.Get("/fetch", handler.handleAppFetch) r.Get("/v1/fetch", handler.handleAppFetch)
}) })
for _, fn := range opts.HTTPMounts {
r.Group(func(r chi.Router) {
fn(r)
})
}
r.HandleFunc("/sock/*", handler.handleSockJS) r.HandleFunc("/sock/*", handler.handleSockJS)
}) })

View File

@ -7,6 +7,7 @@ import (
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus" "forge.cadoles.com/arcad/edge/pkg/bus"
"forge.cadoles.com/arcad/edge/pkg/bus/memory" "forge.cadoles.com/arcad/edge/pkg/bus/memory"
"github.com/go-chi/chi/v5"
"github.com/igm/sockjs-go/v3/sockjs" "github.com/igm/sockjs-go/v3/sockjs"
) )
@ -16,6 +17,7 @@ type HandlerOptions struct {
ServerModuleFactories []app.ServerModuleFactory ServerModuleFactories []app.ServerModuleFactory
UploadMaxFileSize int64 UploadMaxFileSize int64
HTTPClient *http.Client HTTPClient *http.Client
HTTPMounts []func(r chi.Router)
} }
func defaultHandlerOptions() *HandlerOptions { func defaultHandlerOptions() *HandlerOptions {
@ -32,6 +34,7 @@ func defaultHandlerOptions() *HandlerOptions {
HTTPClient: &http.Client{ HTTPClient: &http.Client{
Timeout: time.Second * 30, Timeout: time.Second * 30,
}, },
HTTPMounts: make([]func(r chi.Router), 0),
} }
} }
@ -66,3 +69,9 @@ func WithHTTPClient(client *http.Client) HandlerOptionFunc {
opts.HTTPClient = client opts.HTTPClient = client
} }
} }
func WithHTTPMounts(mounts ...func(r chi.Router)) HandlerOptionFunc {
return func(opts *HandlerOptions) {
opts.HTTPMounts = mounts
}
}

View File

@ -44,6 +44,10 @@ func (r *Repository) List(ctx context.Context) ([]*app.Manifest, error) {
} }
func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository { func NewRepository(getURL GetURLFunc, manifests ...*app.Manifest) *Repository {
if manifests == nil {
manifests = make([]*app.Manifest, 0)
}
return &Repository{getURL, manifests} return &Repository{getURL, manifests}
} }

View File

@ -14,11 +14,12 @@ type Module struct {
} }
type gojaManifest struct { type gojaManifest struct {
ID string `goja:"id" json:"id"` ID string `goja:"id" json:"id"`
Version string `goja:"version" json:"version"` Version string `goja:"version" json:"version"`
Title string `goja:"title" json:"title"` Title string `goja:"title" json:"title"`
Description string `goja:"description" json:"description"` Description string `goja:"description" json:"description"`
Tags []string `goja:"tags" json:"tags"` Tags []string `goja:"tags" json:"tags"`
Metadata map[string]any `goja:"metadata" json:"metadata"`
} }
func toGojaManifest(manifest *app.Manifest) *gojaManifest { func toGojaManifest(manifest *app.Manifest) *gojaManifest {
@ -28,6 +29,7 @@ func toGojaManifest(manifest *app.Manifest) *gojaManifest {
Title: manifest.Title, Title: manifest.Title,
Description: manifest.Description, Description: manifest.Description,
Tags: manifest.Tags, Tags: manifest.Tags,
Metadata: manifest.Metadata,
} }
} }

112
pkg/module/app/mount.go Normal file
View 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)
}
}

View File

@ -9,5 +9,5 @@ import (
type Repository interface { type Repository interface {
List(context.Context) ([]*app.Manifest, error) List(context.Context) ([]*app.Manifest, error)
Get(context.Context, app.ID) (*app.Manifest, error) Get(context.Context, app.ID) (*app.Manifest, error)
GetURL(context.Context, app.ID, string) (string, error) GetURL(ctx context.Context, id app.ID, from string) (string, error)
} }

View File

@ -2,7 +2,4 @@ package auth
import "errors" import "errors"
var ( var ErrUnauthenticated = errors.New("unauthenticated")
ErrUnauthenticated = errors.New("unauthenticated")
ErrClaimNotFound = errors.New("claim not found")
)

View File

@ -110,6 +110,8 @@ func (h *LocalHandler) handleForm(w http.ResponseWriter, r *http.Request) {
return return
} }
account.Claims[auth.ClaimIssuer] = "local"
token, err := generateSignedToken(h.algo, h.key, account.Claims) token, err := generateSignedToken(h.algo, h.key, account.Claims)
if err != nil { if err != nil {
logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err))) logger.Error(ctx, "could not generate signed token", logger.E(errors.WithStack(err)))

View File

@ -91,7 +91,7 @@
<form method="post" action="{{ .URL }}"> <form method="post" action="{{ .URL }}">
<div class="form-control"> <div class="form-control">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ .Username }}" required /> <input type="text" id="username" name="username" value="{{ .Username }}" required autofocus />
</div> </div>
<div class="form-control"> <div class="form-control">
<label for="password">Password</label> <label for="password">Password</label>

View File

@ -19,10 +19,10 @@ type GetKeySetFunc func() (jwk.Set, error)
func WithJWT(getKeySet GetKeySetFunc) OptionFunc { func WithJWT(getKeySet GetKeySetFunc) OptionFunc {
return func(o *Option) { return func(o *Option) {
o.GetClaim = func(ctx context.Context, r *http.Request, claimName string) (string, error) { o.GetClaims = func(ctx context.Context, r *http.Request, names ...string) ([]string, error) {
claim, err := getClaim[string](r, claimName, getKeySet) claim, err := getClaims[string](r, getKeySet, names...)
if err != nil { if err != nil {
return "", errors.WithStack(err) return nil, errors.WithStack(err)
} }
return claim, nil return claim, nil
@ -76,28 +76,34 @@ func FindToken(r *http.Request, getKeySet GetKeySetFunc) (jwt.Token, error) {
return token, nil return token, nil
} }
func getClaim[T any](r *http.Request, claimAttr string, getKeySet GetKeySetFunc) (T, error) { func getClaims[T any](r *http.Request, getKeySet GetKeySetFunc, names ...string) ([]T, error) {
token, err := FindToken(r, getKeySet) token, err := FindToken(r, getKeySet)
if err != nil { if err != nil {
return *new(T), errors.WithStack(err) return nil, errors.WithStack(err)
} }
ctx := r.Context() ctx := r.Context()
mapClaims, err := token.AsMap(ctx) mapClaims, err := token.AsMap(ctx)
if err != nil { if err != nil {
return *new(T), errors.WithStack(err) return nil, errors.WithStack(err)
} }
rawClaim, exists := mapClaims[claimAttr] claims := make([]T, len(names))
if !exists {
return *new(T), errors.WithStack(ErrClaimNotFound) 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
} }
claim, ok := rawClaim.(T) return claims, nil
if !ok {
return *new(T), errors.Errorf("unexpected claim '%s' to be of type '%T', got '%T'", claimAttr, new(T), rawClaim)
}
return claim, nil
} }

View File

@ -8,15 +8,21 @@ import (
"forge.cadoles.com/arcad/edge/pkg/module/util" "forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
const ( const (
ClaimSubject = "sub" ClaimSubject = "sub"
ClaimIssuer = "iss"
ClaimPreferredUsername = "preferred_username"
ClaimEdgeRole = "edge_role"
ClaimEdgeTenant = "edge_tenant"
ClaimEdgeEntrypoint = "edge_entrypoint"
) )
type Module struct { type Module struct {
server *app.Server server *app.Server
getClaimFunc GetClaimFunc getClaims GetClaimsFunc
} }
func (m *Module) Name() string { 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 { if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property")) 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 { func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
@ -42,16 +64,21 @@ func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
panic(rt.ToValue(errors.New("could not find http request in context"))) 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 err != nil {
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) { if errors.Is(err, ErrUnauthenticated) {
return nil 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 { func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
@ -62,8 +89,8 @@ func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule { return func(server *app.Server) app.ServerModule {
return &Module{ return &Module{
server: server, server: server,
getClaimFunc: opt.GetClaim, getClaims: opt.GetClaims,
} }
} }
} }

72
pkg/module/auth/mount.go Normal file
View 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)
}
}

View File

@ -7,26 +7,41 @@ import (
"github.com/pkg/errors" "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 { type Option struct {
GetClaim GetClaimFunc GetClaims GetClaimsFunc
ProfileClaims []string
} }
type OptionFunc func(*Option) type OptionFunc func(*Option)
func defaultOptions() *Option { func defaultOptions() *Option {
return &Option{ return &Option{
GetClaim: dummyGetClaim, GetClaims: dummyGetClaims,
ProfileClaims: []string{
ClaimSubject,
ClaimIssuer,
ClaimEdgeEntrypoint,
ClaimEdgeRole,
ClaimPreferredUsername,
ClaimEdgeTenant,
},
} }
} }
func dummyGetClaim(ctx context.Context, r *http.Request, claimName string) (string, error) { func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
return "", errors.Errorf("dummy getclaim func cannot retrieve claim '%s'", claimName) return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
} }
func WithGetClaim(fn GetClaimFunc) OptionFunc { func WithGetClaims(fn GetClaimsFunc) OptionFunc {
return func(o *Option) { return func(o *Option) {
o.GetClaim = fn o.GetClaims = fn
}
}
func WithProfileClaims(claims ...string) OptionFunc {
return func(o *Option) {
o.ProfileClaims = claims
} }
} }

View File

@ -19,7 +19,7 @@ func TestBlobModule(t *testing.T) {
logger.SetLevel(slog.LevelDebug) logger.SetLevel(slog.LevelDebug)
bus := memory.NewBus() bus := memory.NewBus()
store := sqlite.NewBlobStore(":memory:") store := sqlite.NewBlobStore(":memory:?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000")
server := app.NewServer( server := app.NewServer(
module.ContextModuleFactory(), module.ContextModuleFactory(),

View File

@ -5,6 +5,7 @@ import (
"io/ioutil" "io/ioutil"
"net/url" "net/url"
"testing" "testing"
"time"
"cdr.dev/slog" "cdr.dev/slog"
"forge.cadoles.com/arcad/edge/pkg/app" "forge.cadoles.com/arcad/edge/pkg/app"
@ -42,7 +43,12 @@ func TestFetchModule(t *testing.T) {
t.Fatalf("%+v", errors.WithStack(err)) t.Fatalf("%+v", errors.WithStack(err))
} }
ctx := context.Background() // 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" remoteAddr := "127.0.0.1"
url, _ := url.Parse("http://example.com") url, _ := url.Parse("http://example.com")

View File

@ -15,7 +15,7 @@ import (
func TestStoreModule(t *testing.T) { func TestStoreModule(t *testing.T) {
logger.SetLevel(logger.LevelDebug) logger.SetLevel(logger.LevelDebug)
store := sqlite.NewDocumentStore(":memory:") store := sqlite.NewDocumentStore(":memory:?_pragma=foreign_keys(1)&_pragma=busy_timeout=60000")
server := app.NewServer( server := app.NewServer(
module.ContextModuleFactory(), module.ContextModuleFactory(),
module.ConsoleModuleFactory(), module.ConsoleModuleFactory(),

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,8 @@ import SockJS from 'sockjs-client';
const EventTypeMessage = "message"; const EventTypeMessage = "message";
const EdgeAuth = "edge-auth" const EdgeAuth = "edge-auth"
const EdgeAuthTokenRequest = "edge_auth_token_request"
const EdgeAuthTokenResponse = "edge_auth_token_reponse"
export class Client extends EventTarget { export class Client extends EventTarget {
@ -19,80 +21,161 @@ export class Client extends EventTarget {
constructor(autoReconnect = true) { constructor(autoReconnect = true) {
super(); super();
this._conn = null; this._conn = null;
this._onConnectionClose = this._onConnectionClose.bind(this); this._onConnectionClose = this._onConnectionClose.bind(this);
this._onConnectionMessage = this._onConnectionMessage.bind(this); this._onConnectionMessage = this._onConnectionMessage.bind(this);
this._handleRPCResponse = this._handleRPCResponse.bind(this); this._handleRPCResponse = this._handleRPCResponse.bind(this);
this._handleEdgeAuthTokenRequest = this._handleEdgeAuthTokenRequest.bind(this);
this._rpcID = 0; this._rpcID = 0;
this._pendingRPC = {}; this._pendingRPC = {};
this._queue = []; this._queue = [];
this._reconnectionDelay = 250; this._reconnectionDelay = 250;
this._autoReconnect = autoReconnect; this._autoReconnect = autoReconnect;
this.debug = false; this.debug = false;
this.connect = this.connect.bind(this); this.connect = this.connect.bind(this);
this.disconnect = this.disconnect.bind(this); this.disconnect = this.disconnect.bind(this);
this.rpc = this.rpc.bind(this); this.rpc = this.rpc.bind(this);
this.send = this.send.bind(this); this.send = this.send.bind(this);
this.upload = this.upload.bind(this); this.upload = this.upload.bind(this);
this.addEventListener(EventTypeMessage, this._handleRPCResponse); this.addEventListener(EventTypeMessage, this._handleRPCResponse);
window.addEventListener('message', this._handleEdgeAuthTokenRequest);
} }
connect(token = "") { connect(token = ""): Promise<Client> {
return new Promise((resolve, reject) => { let getToken: Promise<string>
if (token == "") {
token = this._getAuthCookieToken()
}
const url = `//${document.location.host}/edge/sock?${EdgeAuth}=${token}`; if (token) {
this._log("opening connection to", url); getToken = Promise.resolve(token)
const conn: any = new SockJS(url); } else {
getToken = this._retrieveToken()
}
const onOpen = () => { return getToken.then(token => this._connect(token))
this._log('client connected');
resetHandlers();
conn.onclose = this._onConnectionClose;
conn.onmessage = this._onConnectionMessage;
this._conn = conn;
this._sendQueued();
setTimeout(() => {
this._dispatchConnect();
}, 0);
return resolve(this);
};
const onError = (evt) => {
resetHandlers();
this._scheduleReconnection();
return reject(evt);
};
const resetHandlers = () => {
conn.removeEventListener('open', onOpen);
conn.removeEventListener('close', onError);
conn.removeEventListener('error', onError);
};
conn.addEventListener('open', onOpen);
conn.addEventListener('error', onError);
conn.addEventListener('close', onError);
});
} }
disconnect() { disconnect() {
this._cleanupConnection(); this._cleanupConnection();
} }
_getAuthCookieToken() { _connect(token: string): Promise<Client> {
const cookie = document.cookie.split("; ") return new Promise((resolve, reject) => {
.find((row) => row.startsWith(EdgeAuth)); const url = `//${document.location.host}/edge/sock?${EdgeAuth}=${token}`;
this._log("opening connection to", url);
if (cookie) { const conn: any = new SockJS(url);
return cookie.split("=")[1];
const onOpen = () => {
this._log('client connected');
resetHandlers();
conn.onclose = this._onConnectionClose;
conn.onmessage = this._onConnectionMessage;
this._conn = conn;
this._sendQueued();
setTimeout(() => {
this._dispatchConnect();
}, 0);
return resolve(this);
};
const onError = (evt) => {
resetHandlers();
this._scheduleReconnection();
return reject(evt);
};
const resetHandlers = () => {
conn.removeEventListener('open', onOpen);
conn.removeEventListener('close', onError);
conn.removeEventListener('error', onError);
};
conn.addEventListener('open', onOpen);
conn.addEventListener('error', onError);
conn.addEventListener('close', onError);
})
}
_retrieveToken(): Promise<string> {
let token = this._getAuthCookieToken();
if (token) {
return Promise.resolve(token);
} }
return ""; return this._getParentFrameToken();;
}
_getAuthCookieToken(): string {
const cookie = document.cookie.split("; ")
.find((row) => row.startsWith(EdgeAuth));
let token = "";
if (cookie) {
token = cookie.split("=")[1];
}
return token;
}
_getParentFrameToken(timeout = 5000): Promise<string> {
if (!window.parent || window.parent === window) {
return Promise.resolve("");
}
return new Promise((resolve, reject) => {
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
reject(new Error("Edge auth token request timed out !"));
}, timeout);
const listener = (evt) => {
const message = evt.data;
if (!message || !message.type || !message.data) {
return
}
if (message.type !== EdgeAuthTokenResponse) {
return;
}
window.removeEventListener('message', listener);
clearTimeout(timeoutId);
if (timedOut) return;
if (!message.data) {
reject("Unexpected auth token request response !");
return;
}
resolve(message.data?.token || "");
}
window.addEventListener('message', listener);
const message = { type: EdgeAuthTokenRequest };
window.parent.postMessage(message, '*');
})
}
_handleEdgeAuthTokenRequest(evt: MessageEvent) {
const message = evt.data;
if (!message || !message.type || message.type !== EdgeAuthTokenRequest) {
return;
}
if (!evt.source) {
return;
}
const token = this._getAuthCookieToken();
// @ts-ignore
evt.source.postMessage({ type: EdgeAuthTokenResponse, data: { token }}, evt.origin);
} }
_onConnectionMessage(evt) { _onConnectionMessage(evt) {
@ -107,7 +190,7 @@ export class Client extends EventTarget {
_handleRPCResponse(evt) { _handleRPCResponse(evt) {
const { jsonrpc, id, error, result } = evt.detail; const { jsonrpc, id, error, result } = evt.detail;
if (jsonrpc !== '2.0' || id === undefined) return; if (jsonrpc !== '2.0' || id === undefined) return;
if (!evt.detail.hasOwnProperty("error") && !evt.detail.hasOwnProperty("result")) return; if (!evt.detail.hasOwnProperty("error") && !evt.detail.hasOwnProperty("result")) return;
@ -215,20 +298,20 @@ export class Client extends EventTarget {
return this._conn !== null; return this._conn !== null;
} }
upload(blob: string|Blob, metadata: any) { upload(blob: string | Blob, metadata: any) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const formData = new FormData(); const formData = new FormData();
formData.set("file", blob); formData.set("file", blob);
if (metadata) { if (metadata) {
try { try {
formData.set("metadata", JSON.stringify(metadata)); formData.set("metadata", JSON.stringify(metadata));
} catch(err) { } catch (err) {
return reject(err); return reject(err);
} }
} }
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const result = { const result = {
onProgress: null, onProgress: null,
abort: () => xhr.abort(), abort: () => xhr.abort(),
@ -238,7 +321,7 @@ export class Client extends EventTarget {
let data; let data;
try { try {
data = JSON.parse(xhr.responseText); data = JSON.parse(xhr.responseText);
} catch(err) { } catch (err) {
reject(err); reject(err);
return; return;
} }

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

View File

@ -0,0 +1,9 @@
import UserCircleIcon from './user-circle.svg';
import MenuIcon from './menu.svg';
import CloudIcon from './cloud.svg';
import LoginIcon from './login.svg';
import HomeIcon from './home.svg';
import LinkIcon from './link.svg';
import LogoutIcon from './logout.svg';
export { UserCircleIcon, MenuIcon, CloudIcon, LoginIcon, HomeIcon, LinkIcon, LogoutIcon }

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -0,0 +1,130 @@
import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';
export const EVENT_MENU_ITEM_SELECTED = 'menu-item-selected';
export const EVENT_MENU_ITEM_UNSELECTED = 'menu-item-unselected';
export class MenuItem extends LitElement {
@property({ attribute: 'icon-url', type: String })
iconUrl: string;
@property({ attribute: 'label', type: String })
label: string;
static styles = css`
:host {
display: inline-block;
height: 100%;
flex: 1;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid rgb(229,231,235);
border-top: 10px solid transparent;
transition: all 100ms ease-out;
background-color: #fff;
}
:host(:hover) {
background-color: rgb(249,250,251);
}
:host(.selected) {
border-top: 10px solid #03A9F4;
border-bottom: 1px solid transparent;
background-color: #fff;
}
:host(.unselected) {
background-color: hsl(210 20% 95% / 1);
}
.menu-item-icon {
height: 30px;
width: 30px;
overflow: hidden;
}
.menu-item-icon > img {
width: 100%;
height: 100%;
}
.menu-item-panel {
display: none;
position: fixed;
top: 65px;
left: 0;
right: 0;
z-index: 9999;
background-color: #fff;
box-shadow: 0px 4px 5px 0px hsl(0deg 0% 0% / 10%);
max-height: 75%;
overflow-y: auto;
}
:host(.selected) .menu-item-panel {
display: block;
}
.menu-item-label {
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
color: black;
font-size: 14px;
margin: 3px 0;
}
`;
@state()
selected: boolean
constructor() {
super();
this.addEventListener('click', this._handleClick.bind(this));
}
render() {
return html`
<div class="menu-item-icon">
${
this.iconUrl ?
html`<img src="${this.iconUrl}" />` :
''
}
</div>
<div class="menu-item-label">
${this.label}
</div>
<div class="menu-item-panel">
<slot></slot>
</div>
`
}
_handleClick() {
if (this.selected) {
this.unselect();
} else {
this.select();
}
}
select() {
this.selected = true;
const event = new CustomEvent(EVENT_MENU_ITEM_SELECTED, {
bubbles: true,
composed: true,
detail: {
element: this,
}
});
this.dispatchEvent(event);
}
unselect() {
this.selected = false;
const event = new CustomEvent(EVENT_MENU_ITEM_UNSELECTED, {
bubbles: true,
composed: true,
detail: {
element: this,
}
});
this.dispatchEvent(event);
}
}

View File

@ -0,0 +1,63 @@
import { LitElement, html, css } from 'lit';
import { property, state } from 'lit/decorators.js';
import { LinkIcon } from './icons';
export class MenuSubItem extends LitElement {
@property({ attribute: 'label' })
label: string;
@property({ attribute: 'icon-url' })
iconUrl: string;
@property({ attribute: 'link-url' })
linkUrl: string;
@property({ attribute: 'inactive', type: Boolean })
inactive: boolean;
static styles = css`
:host {
display: block;
flex: 1;
cursor: pointer;
transition: all 100ms ease-out;
border-bottom: 1px solid rgb(229,231,235);
padding: 5px 0 5px 7px;
border-left: 5px solid transparent;
}
:host([inactive]) {
cursor: initial;
}
:host(:hover) {
border-left: 5px solid #03A9F4;
background-color: rgb(28 169 247 / 10%);
}
a {
font-size: 20px;
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
text-decoration: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
height: 40px;
color: black;
}
.edge-menu-sub-item-icon {
height: 25px;
width: 25px;
}
.edge-menu-sub-item-label {
margin-left: 5px;
}
`;
render() {
return html`
<a href="${this.linkUrl ? this.linkUrl : '#'}">
<img class="edge-menu-sub-item-icon" src="${this.iconUrl ? this.iconUrl : LinkIcon}" />
<span class="edge-menu-sub-item-label">${this.label}</span>
</a>
`
}
}

View File

@ -0,0 +1,239 @@
import { LitElement, html, css } from 'lit';
import { property, queryAll } from 'lit/decorators.js';
import { CloudIcon, HomeIcon, LoginIcon, LinkIcon, MenuIcon, UserCircleIcon, LogoutIcon } from './icons'
import { EVENT_MENU_ITEM_SELECTED, EVENT_MENU_ITEM_UNSELECTED, MenuItem } from './menu-item';
import { MenuSubItem } from './menu-sub-item';
interface Manifest {
id: string
description: string
metadata: { [key: string]: any }
tags: string[]
title: string
version: string
url?: string
}
interface Profile {
sub?: string
preferred_username?: string
iss?: string
edge_role?: string
edge_tenant?: string
edge_entrypoint?: string
}
const BASE_API_URL = '/edge/api/v1';
enum Roles {
visitor = 0,
user = 1,
superuser = 2,
admin = 3,
superadmin = 4
}
export class Menu extends LitElement {
@property({ attribute: 'app-icon-url', type: String })
appIconUrl: string;
@property({ attribute: 'app-title', type: String })
appTitle: string;
@property({ attribute: 'hidden', type: Boolean })
hidden: boolean;
@property()
_apps: Manifest[] = []
@property()
_profile: Profile
static styles = css`
:host {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background-color: #fff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 9999;
}
:host([hidden]) {
display: none;
}
`;
@queryAll('edge-menu-item')
_menuItems: NodeListOf<MenuItem>
constructor() {
super();
this.addEventListener(EVENT_MENU_ITEM_SELECTED, this._handleMenuItemSelected.bind(this));
this.addEventListener(EVENT_MENU_ITEM_UNSELECTED, this._handleMenuItemUnselected.bind(this));
this._fetchApps();
this._fetchProfile();
}
render() {
const apps = this._renderApps()
return html`
<edge-menu-item name='menu' label="${ this.appTitle || "App" }" icon-url='${ this.appIconUrl || MenuIcon }'>
<edge-menu-sub-item name='home' label='Home' icon-url='${HomeIcon}' link-url='/'></edge-menu-sub-item>
<slot></slot>
</edge-menu-item>
${ this._renderApps() }
${ this._renderProfile() }
`;
}
_fetchApps() {
return fetch(`${BASE_API_URL}/apps`)
.then(res => res.json())
.then(result => {
if (result.error) {
throw new Error(`Unexpected server error: ${result.error.code}`);
}
return result.data?.manifests || [];
})
.then((manifests: Manifest[]) => {
const promises = manifests.map((m: Manifest) => {
const fetchOptions: RequestInit = {
method: 'POST',
body: JSON.stringify({}),
headers: {
'Content-Type': 'application/json',
}
};
return fetch(`${BASE_API_URL}/apps/${m.id}/url`, fetchOptions)
.then(res => res.json())
.then(result => {
if (result.error) {
throw new Error(`Unexpected server error: ${result.error.code}`);
}
m.url = result.data?.url;
return m;
})
;
});
return Promise.all(promises);
})
.then((manifests: Manifest[]) => {
this._apps = manifests;
})
.catch(err => console.error(err))
}
_fetchProfile() {
return fetch(`${BASE_API_URL}/profile`)
.then(res => res.json())
.then(result => {
if (result.error) {
switch (result.error.code) {
case "unauthorized":
return null;
default:
throw new Error(`Unexpected server error: ${result.error.code}`);
}
}
return result.data?.profile;
})
.then(profile => {
this._profile = profile;
})
.catch(err => console.error(err))
;
}
_renderApps() {
const apps = this._apps
.filter(manifest => this._canAccess(manifest))
.map(manifest => {
const iconUrl = ( ( manifest.url || '') + ( manifest.metadata?.paths?.icon || '' ) ) || LinkIcon;
return html`
<edge-menu-sub-item
name='${ manifest.id }'
label='${ manifest.title }'
icon-url='${ iconUrl }'
link-url='${ manifest.url || '#' }'>
</edge-menu-sub-item>
`
});
return html`
<edge-menu-item name='apps' label='Apps' icon-url='${CloudIcon}'>
${ apps }
</edge-menu-item>
`;
}
_canAccess(manifest: Manifest): boolean {
const currentRole = this._profile?.edge_role || 'visitor';
const minimumRole = manifest.metadata?.minimumRole || 'visitor';
return Roles[currentRole] >= Roles[minimumRole];
}
_renderProfile() {
const profile = this._profile;
return html`
<edge-menu-item name='profile' label="${profile?.preferred_username || 'Profile'}" icon-url='${UserCircleIcon}'>
${
profile ?
html`<edge-menu-sub-item name='login' label='Logout' icon-url='${LogoutIcon}' link-url='/edge/auth/logout'></edge-menu-sub-item>` :
html`<edge-menu-sub-item name='login' label='Login' icon-url='${LoginIcon}' link-url='/edge/auth/login'></edge-menu-sub-item>`
}
</edge-menu-item>
`;
}
_handleMenuItemSelected(evt: CustomEvent) {
const selectedMenuItem: HTMLElement = evt.detail.element;
selectedMenuItem.classList.add('selected');
selectedMenuItem.classList.remove('unselected');
for (let item, i = 0; (item = this._menuItems[i]); i++) {
if (item === selectedMenuItem) continue;
item.unselect();
item.classList.add('unselected');
}
}
_handleMenuItemUnselected(evt: CustomEvent) {
const unselectedMenuItem: HTMLElement = evt.detail.element;
unselectedMenuItem.classList.remove('selected');
const hasSelectedItem = this.renderRoot.querySelectorAll('edge-menu-item.selected').length !== 0
if (hasSelectedItem) {
return
}
for (let item, i = 0; (item = this._menuItems[i]); i++) {
item.classList.remove('unselected');
}
}
}
declare global {
interface HTMLElementTagNameMap {
"edge-menu": Menu;
"edge-menu-item": MenuItem;
"edge-menu-sub-item": MenuSubItem;
}
}

View File

@ -0,0 +1,87 @@
import { EventTarget } from "./event-target";
enum CrossFrameMessageType {
SIZE_CHANGED = "size_changed",
TITLE_CHANGED = "title_changed"
}
interface CrossFrameMessage {
type: CrossFrameMessageType
data: { [key: string]: any }
}
export class CrossFrameMessenger extends EventTarget {
debug: boolean;
constructor() {
super()
this.debug = false;
this._handleWindowMessage = this._handleWindowMessage.bind(this);
this._initObservers = this._initObservers.bind(this);
window.addEventListener('load', this._initObservers);
window.addEventListener('message', this._handleWindowMessage)
}
post(message: CrossFrameMessage, target: Window = window.parent) {
if (!target) return;
this._log("sending crossframe message", message);
target.postMessage(message, '*');
}
_log(...args) {
if (!this.debug) return;
console.log(...args);
}
_handleWindowMessage(evt: MessageEvent) {
const message = evt.data;
if (!message || !message.type || !message.data) {
return;
}
const event = new CustomEvent(message.type, {
cancelable: true,
detail: message.data
});
this.dispatchEvent(event);
}
_initObservers() {
this._initResizeObserver();
this._initTitleMutationObserver();
}
_initTitleMutationObserver() {
const titleObserver = new MutationObserver((mutations) => {
const title = mutations[0].target.textContent;
this.post({ type: CrossFrameMessageType.TITLE_CHANGED, data: { title }});
});
const title = document.querySelector('title');
if (!title) return;
this.post({ type: CrossFrameMessageType.TITLE_CHANGED, data: { title: title.textContent }});
titleObserver.observe(title, { subtree: true, characterData: true, childList: true });
}
_initResizeObserver() {
const resizeObserver = new ResizeObserver(() => {
const rect = document.documentElement.getBoundingClientRect();
const height = rect.height;
const width = rect.width;
this.post({ type: CrossFrameMessageType.SIZE_CHANGED, data: { height, width }});
});
const body = document.body;
if (!body) return;
resizeObserver.observe(document.documentElement);
}
}

4
pkg/sdk/client/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

View File

@ -1,3 +1,16 @@
import { Client } from './client.js'; import './polyfill';
export default new Client(); import { Client as EdgeClient } from './client.js';
import { Menu as MenuElement } from './components/menu.js';
import { MenuItem as MenuItemElement } from './components/menu-item.js';
import { MenuSubItem as MenuSubItemElement } from './components/menu-sub-item.js';
import { CrossFrameMessenger } from './crossframe-messenger.js';
import { MenuManager } from './menu-manager.js';
customElements.define('edge-menu', MenuElement);
customElements.define('edge-menu-item', MenuItemElement);
customElements.define('edge-menu-sub-item', MenuSubItemElement);
export const Client = new EdgeClient();
export const Frame = new CrossFrameMessenger();
export const Menu = new MenuManager();

View File

@ -0,0 +1,144 @@
import { Menu } from "./components/menu"
export interface MenuItem {
label: string
iconUrl: string
linkUrl: string
order: number
}
const EdgeBodyAutoPaddingAttrName = 'edge-auto-padding'
export class MenuManager {
_items: { [name:string]: MenuItem }
_menu: Menu
_appIconUrl: string
_appTitle: string
_hidden: boolean
_previousBodyAutoPadding: string
constructor() {
this._items = {};
this._handleLoad = this._handleLoad.bind(this);
window.addEventListener('load', this._handleLoad);
}
setItem(name: string, label:string, options?: { iconUrl?: string, linkUrl?: string, order?: number }) {
this._items[name] = {
label: label,
iconUrl: options?.iconUrl ? options?.iconUrl : '',
linkUrl: options?.linkUrl ? options?.linkUrl : '#',
order: options?.order ? options?.order : 0,
}
this._render();
return this;
}
removeItem(name: string) {
delete this._items[name];
this._render();
return this;
}
setAppIconUrl(url: string) {
this._appIconUrl = url;
this._render();
return this;
}
setAppTitle(title: string) {
this._appTitle = title;
this._render();
return this;
}
show() {
if (!this._hidden) return;
this._hidden = false;
if (this._previousBodyAutoPadding) {
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, this._previousBodyAutoPadding);
} else {
document.body.removeAttribute(EdgeBodyAutoPaddingAttrName);
}
this._render();
}
hide() {
if (this._hidden) return;
this._hidden = true;
this._previousBodyAutoPadding = document.body.getAttribute(EdgeBodyAutoPaddingAttrName);
document.body.setAttribute(EdgeBodyAutoPaddingAttrName, "false");
this._render();
}
_handleLoad() {
this._init();
}
_init() {
this._initMenu();
this._initGlobalStyle();
}
_initMenu() {
const menu = document.createElement('edge-menu');
document.body.appendChild(menu);
this._menu = menu;
this._render();
}
_initGlobalStyle() {
const style = document.createElement('style');
style.textContent = `
body:not([${EdgeBodyAutoPaddingAttrName}="false"]) {
padding-top: 60px;
}
`;
document.head.appendChild(style);
}
_render() {
if (!this._menu) return;
if (this._hidden) {
this._menu.setAttribute("hidden", "true");
} else {
this._menu.removeAttribute("hidden");
}
if (this._appIconUrl) {
this._menu.setAttribute("app-icon-url", this._appIconUrl);
} else {
this._menu.removeAttribute("app-icon-url");
}
if (this._appTitle) {
this._menu.setAttribute("app-title", this._appTitle);
} else {
this._menu.removeAttribute("app-title");
}
const children: Node[] = [];
const items: MenuItem[] = Object.keys(this._items)
.map(key => ({ name: key, ...this._items[key] }))
.sort((a, b) => a.order - b.order)
;
for (let item: MenuItem, i = 0; (item = items[i]); i++) {
const node = document.createElement('edge-menu-sub-item');
node.label = item.label;
node.iconUrl = item.iconUrl;
node.linkUrl = item.linkUrl;
children.push(node);
}
this._menu.replaceChildren(...children);
}
}

View File

@ -0,0 +1,4 @@
import 'core-js/actual';
import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js';
import 'lit/polyfill-support.js'
import '@webcomponents/webcomponentsjs/webcomponents-loader.js';

View File

@ -35,6 +35,10 @@ func (b *BlobBucket) Size(ctx context.Context) (int64, error) {
return errors.WithStack(err) return errors.WithStack(err)
} }
if err := row.Err(); err != nil {
return errors.WithStack(err)
}
size = nullSize.Int64 size = nullSize.Int64
return nil return nil
@ -111,6 +115,10 @@ func (b *BlobBucket) Get(ctx context.Context, id storage.BlobID) (storage.BlobIn
return errors.WithStack(err) return errors.WithStack(err)
} }
if err := row.Err(); err != nil {
return errors.WithStack(err)
}
blobInfo = &BlobInfo{ blobInfo = &BlobInfo{
id: id, id: id,
bucket: b.name, bucket: b.name,
@ -143,6 +151,12 @@ func (b *BlobBucket) List(ctx context.Context) ([]storage.BlobInfo, error) {
return errors.WithStack(err) return errors.WithStack(err)
} }
defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
blobs = make([]storage.BlobInfo, 0) blobs = make([]storage.BlobInfo, 0)
for rows.Next() { for rows.Next() {

View File

@ -1,8 +1,10 @@
package sqlite package sqlite
import ( import (
"fmt"
"os" "os"
"testing" "testing"
"time"
"forge.cadoles.com/arcad/edge/pkg/storage/testsuite" "forge.cadoles.com/arcad/edge/pkg/storage/testsuite"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -19,7 +21,8 @@ func TestBlobStore(t *testing.T) {
t.Fatalf("%+v", errors.WithStack(err)) t.Fatalf("%+v", errors.WithStack(err))
} }
store := NewBlobStore(file) dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
store := NewBlobStore(dsn)
testsuite.TestBlobStore(t, store) testsuite.TestBlobStore(t, store)
} }

View File

@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"math" "math"
"sync"
"time" "time"
"forge.cadoles.com/arcad/edge/pkg/storage" "forge.cadoles.com/arcad/edge/pkg/storage"
@ -18,10 +17,7 @@ import (
) )
type DocumentStore struct { type DocumentStore struct {
db *sql.DB getDB getDBFunc
path string
openOnce sync.Once
mutex sync.RWMutex
} }
// Delete implements storage.DocumentStore // Delete implements storage.DocumentStore
@ -74,6 +70,10 @@ func (s *DocumentStore) Get(ctx context.Context, collection string, id storage.D
return errors.WithStack(err) return errors.WithStack(err)
} }
if err := row.Err(); err != nil {
return errors.WithStack(err)
}
document = storage.Document(data) document = storage.Document(data)
document[storage.DocumentAttrID] = id document[storage.DocumentAttrID] = id
@ -160,7 +160,11 @@ func (s *DocumentStore) Query(ctx context.Context, collection string, filter *fi
return errors.WithStack(err) return errors.WithStack(err)
} }
defer rows.Close() defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
documents = make([]storage.Document, 0) documents = make([]storage.Document, 0)
@ -238,6 +242,10 @@ func (s *DocumentStore) Upsert(ctx context.Context, collection string, document
return errors.WithStack(err) return errors.WithStack(err)
} }
if err := row.Err(); err != nil {
return errors.WithStack(err)
}
upsertedDocument = storage.Document(data) upsertedDocument = storage.Document(data)
upsertedDocument[storage.DocumentAttrID] = id upsertedDocument[storage.DocumentAttrID] = id
@ -256,7 +264,7 @@ func (s *DocumentStore) Upsert(ctx context.Context, collection string, document
func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error { func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
var db *sql.DB var db *sql.DB
db, err := s.getDatabase(ctx) db, err := s.getDB(ctx)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -268,67 +276,7 @@ func (s *DocumentStore) withTx(ctx context.Context, fn func(tx *sql.Tx) error) e
return nil return nil
} }
func (s *DocumentStore) getDatabase(ctx context.Context) (*sql.DB, error) { func ensureTables(ctx context.Context, db *sql.DB) error {
s.mutex.RLock()
if s.db != nil {
defer s.mutex.RUnlock()
var err error
s.openOnce.Do(func() {
if err = s.ensureTables(ctx, s.db); err != nil {
err = errors.WithStack(err)
return
}
})
if err != nil {
return nil, errors.WithStack(err)
}
return s.db, nil
}
s.mutex.RUnlock()
var (
db *sql.DB
err error
)
s.openOnce.Do(func() {
db, err = sql.Open("sqlite", s.path)
if err != nil {
err = errors.WithStack(err)
return
}
if err = s.ensureTables(ctx, db); err != nil {
err = errors.WithStack(err)
return
}
})
if err != nil {
return nil, errors.WithStack(err)
}
if db != nil {
s.mutex.Lock()
s.db = db
s.mutex.Unlock()
}
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.db, nil
}
func (s *DocumentStore) ensureTables(ctx context.Context, db *sql.DB) error {
err := withTx(ctx, db, func(tx *sql.Tx) error { err := withTx(ctx, db, func(tx *sql.Tx) error {
query := ` query := `
CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS documents (
@ -396,18 +344,18 @@ func withLimitOffsetClause(query string, args []any, limit int, offset int) (str
} }
func NewDocumentStore(path string) *DocumentStore { func NewDocumentStore(path string) *DocumentStore {
getDB := newGetDBFunc(path, ensureTables)
return &DocumentStore{ return &DocumentStore{
db: nil, getDB: getDB,
path: path,
openOnce: sync.Once{},
} }
} }
func NewDocumentStoreWithDB(db *sql.DB) *DocumentStore { func NewDocumentStoreWithDB(db *sql.DB) *DocumentStore {
getDB := newGetDBFuncFromDB(db, ensureTables)
return &DocumentStore{ return &DocumentStore{
db: db, getDB: getDB,
path: "",
openOnce: sync.Once{},
} }
} }

View File

@ -1,8 +1,10 @@
package sqlite package sqlite
import ( import (
"fmt"
"os" "os"
"testing" "testing"
"time"
"forge.cadoles.com/arcad/edge/pkg/storage/testsuite" "forge.cadoles.com/arcad/edge/pkg/storage/testsuite"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -10,7 +12,7 @@ import (
) )
func TestDocumentStore(t *testing.T) { func TestDocumentStore(t *testing.T) {
// t.Parallel() t.Parallel()
logger.SetLevel(logger.LevelDebug) logger.SetLevel(logger.LevelDebug)
file := "./testdata/documentstore_test.sqlite" file := "./testdata/documentstore_test.sqlite"
@ -19,7 +21,8 @@ func TestDocumentStore(t *testing.T) {
t.Fatalf("%+v", errors.WithStack(err)) t.Fatalf("%+v", errors.WithStack(err))
} }
store := NewDocumentStore(file) dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
store := NewDocumentStore(dsn)
testsuite.TestDocumentStore(t, store) testsuite.TestDocumentStore(t, store)
} }

View File

@ -8,7 +8,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
"modernc.org/sqlite"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
) )
func Open(path string) (*sql.DB, error) { func Open(path string) (*sql.DB, error) {
@ -23,7 +25,7 @@ func Open(path string) (*sql.DB, error) {
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
var tx *sql.Tx var tx *sql.Tx
tx, err := db.Begin() tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -38,8 +40,27 @@ func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
} }
}() }()
if err = fn(tx); err != nil { for {
return errors.WithStack(err) if err = fn(tx); err != nil {
var sqlErr *sqlite.Error
if errors.As(err, &sqlErr) {
if sqlErr.Code() == sqlite3.SQLITE_BUSY {
logger.Warn(ctx, "database busy, retrying transaction")
if err := ctx.Err(); err != nil {
logger.Error(ctx, "could not execute transaction", logger.E(errors.WithStack(err)))
return errors.WithStack(err)
}
continue
}
}
return errors.WithStack(err)
}
break
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {

View File

@ -1 +1 @@
/*.sqlite /*.sqlite*

View File

@ -8,7 +8,7 @@ import (
func TestBlobStore(t *testing.T, store storage.BlobStore) { func TestBlobStore(t *testing.T, store storage.BlobStore) {
t.Run("Ops", func(t *testing.T) { t.Run("Ops", func(t *testing.T) {
// t.Parallel() t.Parallel()
testBlobStoreOps(t, store) testBlobStoreOps(t, store)
}) })
} }

View File

@ -8,7 +8,7 @@ import (
func TestDocumentStore(t *testing.T, store storage.DocumentStore) { func TestDocumentStore(t *testing.T, store storage.DocumentStore) {
t.Run("Ops", func(t *testing.T) { t.Run("Ops", func(t *testing.T) {
// t.Parallel() t.Parallel()
testDocumentStoreOps(t, store) testDocumentStoreOps(t, store)
}) })
} }

View File

@ -437,6 +437,7 @@ func testDocumentStoreOps(t *testing.T, store storage.DocumentStore) {
for _, tc := range documentStoreOpsTestCases { for _, tc := range documentStoreOpsTestCases {
func(tc documentStoreOpsTestCase) { func(tc documentStoreOpsTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if err := tc.Run(context.Background(), store); err != nil { if err := tc.Run(context.Background(), store); err != nil {
t.Errorf("%+v", errors.WithStack(err)) t.Errorf("%+v", errors.WithStack(err))
} }

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"lib": ["ES2015", "DOM"],
"experimentalDecorators": true
},
"include": ["pkg/sdk/client/src/index.d.ts", "pkg/sdk/client/src/**/*.ts", "pkg/sdk/client/src/**/*.svg"]
}