feat(sdk,client): add menu to help navigation between apps
arcad/edge/pipeline/head This commit looks good Details

This commit is contained in:
wpetit 2023-04-18 17:57:16 +02:00
parent 9e3fc427bb
commit b5b4042cc7
59 changed files with 22276 additions and 486 deletions

1
.env.dist Normal file
View File

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

View File

@ -9,6 +9,9 @@ ESBUILD_VERSION ?= v0.17.5
GIT_VERSION := $(shell git describe --always)
DATE_VERSION := $(shell date +%Y.%-m.%-d)
FULL_VERSION := v$(DATE_VERSION)-$(GIT_VERSION)$(if $(shell git diff --stat),-dirty,)
APP_PATH ?= misc/client-sdk-testsuite/dist
RUN_APP_ARGS ?=
SHELL := bash
build: build-edge-cli build-client-sdk-test-app
@ -55,12 +58,18 @@ pkg/sdk/client/dist/client.js: tools/esbuild/bin/esbuild node_modules
--global-name=Edge \
--define:global=window \
--platform=browser \
--footer:js="EdgeFrame=Edge.crossFrameMessenger;Edge=Edge.client" \
--loader:.svg=dataurl \
--outfile=pkg/sdk/client/dist/client.js
node_modules:
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
mkdir -p .gitea-release
rm -rf .gitea-release/*

View File

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

View File

@ -9,7 +9,9 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bus"
@ -18,7 +20,7 @@ import (
"forge.cadoles.com/arcad/edge/pkg/module"
appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
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"
"forge.cadoles.com/arcad/edge/pkg/module/blob"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
@ -29,7 +31,6 @@ import (
"gitlab.com/wpetit/goweb/logger"
"forge.cadoles.com/arcad/edge/pkg/bundle"
"github.com/dop251/goja"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/lestrrat-go/jwx/v2/jwa"
@ -48,15 +49,15 @@ func RunCommand() *cli.Command {
Name: "run",
Usage: "Run the specified app bundle",
Flags: []cli.Flag{
&cli.StringFlag{
&cli.StringSliceFlag{
Name: "path",
Usage: "use `PATH` as app bundle (zipped bundle or directory)",
Aliases: []string{"p"},
Value: ".",
Value: cli.NewStringSlice("."),
},
&cli.StringFlag{
Name: "address",
Usage: "use `ADDRESS` as http server listening address",
Usage: "use `ADDRESS` as http server base listening address",
Aliases: []string{"a"},
Value: ":8080",
},
@ -83,96 +84,157 @@ func RunCommand() *cli.Command {
},
Action: func(ctx *cli.Context) error {
address := ctx.String("address")
path := ctx.String("path")
paths := ctx.StringSlice("path")
logFormat := ctx.String("log-format")
logLevel := ctx.Int("log-level")
storageFile := ctx.String("storage-file")
accountsFile := ctx.String("accounts-file")
logger.SetFormat(logger.Format(logFormat))
logger.SetLevel(logger.Level(logLevel))
cmdCtx := ctx.Context
absPath, err := filepath.Abs(path)
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")
}
if valid, err := manifest.Validate(manifestMetadataValidators...); !valid {
return errors.Wrap(err, "invalid app manifest")
}
storageFile := injectAppID(ctx.String("storage-file"), manifest.ID)
if err := ensureDir(storageFile); err != nil {
return errors.WithStack(err)
}
db, err := sqlite.Open(storageFile)
host, portStr, err := net.SplitHostPort(address)
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, 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()
port, err := strconv.ParseUint(portStr, 10, 32)
if err != nil {
return errors.WithStack(err)
}
router.Handle("/auth/*", authHTTP.NewLocalHandler(
jwa.HS256, key,
authHTTP.WithRoutePrefix("/auth"),
authHTTP.WithAccounts(accounts...),
))
// Add app handler
router.Handle("/*", handler)
manifests := make([]*app.Manifest, len(paths))
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 {
return errors.WithStack(err)
manifests[idx] = manifest
}
var wg sync.WaitGroup
for idx, p := range paths {
wg.Add(1)
go func(path string, basePort uint64, appIndex int) {
defer wg.Done()
port := basePort + uint64(appIndex)
address := fmt.Sprintf("%s:%d", host, port)
appsRepository := newAppRepository(host, basePort, manifests...)
appCtx := logger.With(cmdCtx, logger.F("address", address))
if err := runApp(appCtx, path, address, storageFile, accountsFile, appsRepository); err != nil {
logger.Error(appCtx, "could not run app", logger.E(errors.WithStack(err)))
}
}(p, port, idx)
}
wg.Wait()
return nil
},
}
}
func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStore, 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{
module.ContextModuleFactory(),
module.ConsoleModuleFactory(),
@ -182,49 +244,10 @@ func getServerModules(bus bus.Bus, ds storage.DocumentStore, bs storage.BlobStor
module.RPCModuleFactory(bus),
module.StoreModuleFactory(ds),
blob.ModuleFactory(bus, bs),
module.Extends(
auth.ModuleFactory(
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"))
}
},
authModule.ModuleFactory(
authModule.WithJWT(dummyKeySet),
),
appModule.ModuleFactory(appModuleMemory.NewRepository(
func(ctx context.Context, id app.ID, from string) (string, error) {
addr := address
if strings.HasPrefix(addr, ":") {
addr = "0.0.0.0" + addr
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return "", errors.WithStack(err)
}
addr, err = findMatchingDeviceAddress(ctx, from, host)
if err != nil {
return "", errors.WithStack(err)
}
return fmt.Sprintf("http://%s:%s", addr, port), nil
},
manifest,
)),
appModule.ModuleFactory(appRepository),
fetch.ModuleFactory(bus),
}
}
@ -349,3 +372,25 @@ func findMatchingDeviceAddress(ctx context.Context, from string, defaultAddr str
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

@ -10,5 +10,6 @@ Afin de pouvoir utiliser le SDK "client", vous devez inclure dans la page HTML d
Vous pourrez ensuite accéder aux variables globales suivantes:
- [`Edge`](./edge.md) - Client principal d'échange avec le serveur
- [`EdgeFrame`](./edge-frame.md)
- [`Edge.Client`](./edge-client.md) - Client principal d'échange avec le serveur
- [`Edge.Frame`](./edge-frame.md) - Utilitaire de communication avec une frame parente
- [`Edge.Menu`](./edge-menu.md) - Gestionnaire de menu

View File

@ -1,22 +1,22 @@
# `Edge`
# `Edge.Client`
## Méthodes
### `Edge.connect(): Promise`
### `Edge.Client.connect(): Promise`
> `TODO`
### `Edge.disconnect(): void`
### `Edge.Client.disconnect(): void`
> `TODO`
### `Edge.send(message: Object): void`
### `Edge.Client.send(message: Object): void`
> `TODO`
### `Edge.rpc(method: string, params: Object): Promise`
### `Edge.Client.rpc(method: string, params: Object): Promise`
> `TODO`
#### Exemple
@ -36,22 +36,22 @@ function echo(ctx, params) {
**Côté client**
```js
Edge.connect().then(() => {
Edge.rpc("echo", { hello: "world!" })
Edge.Client.connect().then(() => {
Edge.Client.rpc("echo", { hello: "world!" })
.then(result => console.log(result))
.catch(err => console.error(err));
});
```
### `Edge.upload(blob: Blob, metadata: Object): Promise`
### `Edge.Client.upload(blob: Blob, metadata: Object): Promise`
> `TODO`
### `Edge.blobUrl(bucketName: string, blobId: string): string`
### `Edge.Client.blobUrl(bucketName: string, blobId: string): string`
> `TODO`
### `Edge.externalUrl(url: string): string`
### `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).
@ -64,5 +64,5 @@ Retourne une URL "locale" permettant d'accéder à une ressource externe, en fon
#### Exemple
```js
Edge.addEventListener("message", evt => console.log(evt.detail));
Edge.Client.addEventListener("message", evt => console.log(evt.detail));
```

View File

@ -1,8 +1,8 @@
# `EdgeFrame`
# `Edge.Frame`
## Méthodes
### `EdgeFrame.addEventListener(name: string, listener: (event) => void)`
### `Edge.Frame.addEventListener(name: string, listener: (event) => void)`
> `TODO`

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.

View File

@ -40,13 +40,13 @@ Créer le fichier `my-app/public/index.html`:
<script type="text/javascript">
// On utilise le SDK via la variable globale "Edge"
// pour se connecter au serveur de notre application.
Edge.connect().then(() => {
Edge.Client.connect().then(() => {
// Une fois connecté, on envoie un message au serveur.
Edge.send({ "hello": "world" });
Edge.Client.send({ "hello": "world" });
});
// On écoute les messages en provenance du serveur.
Edge.addEventListener("message", (evt) => {
Edge.Client.addEventListener("message", (evt) => {
console.log("New server message", evt.detail)
});
</script>

View File

@ -86,4 +86,4 @@ interface BlobInfo {
### `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**
```js
// 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
// ou effectuer une requête fetch() avec celle ci.

View File

@ -32,9 +32,9 @@ Aucune
```js
// Les données envoyées par le serveur sont accessibles
// via la propriété evt.detail.
Edge.on('message', evt => console.log(evt.detail));
Edge.Client.on('message', evt => console.log(evt.detail));
Edge.connect();
Edge.Client.connect();
```
**Côté serveur**

View File

@ -1,6 +1,6 @@
# Module `rpc`
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
Ce module permet de déclarer des méthodes côté serveur qui seront "invoquable" côté client via la méthode [`Edge.Client.rpc(method: string, params: Object): Promise`](../client-api/README.md#edgerpcmethod-string-params-object-promise).
## Méthodes
@ -31,8 +31,8 @@ function echo(ctx, params) {
**Côté client**
```js
Edge.connect().then(() => {
Edge.rpc("echo", { hello: "world!" })
Edge.Client.connect().then(() => {
Edge.Client.rpc("echo", { hello: "world!" })
.then(result => console.log(result))
.catch(err => console.error(err));
});

6
go.mod
View File

@ -10,17 +10,21 @@ require (
require (
github.com/brutella/dnssd v1.2.6 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
github.com/goccy/go-json v0.9.11 // indirect
github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 // indirect
github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect
github.com/leodido/go-urn v1.1.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.0 // indirect
github.com/miekg/dns v1.1.50 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
)
require (
@ -54,7 +58,7 @@ require (
github.com/spf13/afero v1.9.3 // indirect
github.com/urfave/cli/v2 v2.24.3
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
go.opencensus.io v0.22.5 // indirect
golang.org/x/crypto v0.7.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/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
@ -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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
@ -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=
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9 h1:6JlkcdjYVQglPWYuemK2MoZAtRE4vFx85zLXflGIyI8=
gitlab.com/wpetit/goweb v0.0.0-20230206085656-dec695f0e2e9/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b h1:nkvOl8TCj/mErADnwFFynjxBtC+hHsrESw6rw56JGmg=
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b/go.mod h1:3sus4zjoUv1GB7eDLL60QaPkUnXJCWBpjvbe0jWifeY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -9,4 +9,4 @@ tags: ["test"]
metadata:
paths:
icon: /icon.png
minimumRole: visitor
minimumRole: superadmin

View File

@ -8,7 +8,10 @@
<link rel="stylesheet" href="/vendor/mocha.css" />
<style>
body {
background-color: white;
background-color: #f7f7f7;
}
body:not([edge-auto-padding="false"]) #mocha-stats {
top: 75px !important;
}
</style>
</head>
@ -30,6 +33,17 @@
<script src="/test/fetch-module.js"></script>
<script class="mocha-exec">
mocha.run();
Edge.Menu
.setAppIconUrl('/icon.png')
.setAppTitle('SDK Tests')
.setItem('client', 'Client', { linkUrl: '/?grep=Edge', order: 1 })
.setItem('auth-module', 'Auth Module', { linkUrl: '/?grep=Auth%20Module' , order: 4})
.setItem('net-module', 'Net Module', { linkUrl: '/?grep=Net%20Module' , order: 3})
.setItem('rpc', 'Remote Procedure Call', { linkUrl: '/?grep=Remote%20Procedure%20Call' , order: 5})
.setItem('file-module', 'File Module', { linkUrl: '/?grep=File%20Module', order: 6})
.setItem('app-module', 'App Module', { linkUrl: '/?grep=App%20Module' , order: 7})
.setItem('fetch-module', 'Fetch Module', { linkUrl: '/?grep=Fetch%20Module' , order: 8})
</script>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
describe('Fetch Module', function () {
before(() => {
return Edge.connect();
return Edge.Client.connect();
});
after(() => {
Edge.disconnect();
Edge.Client.disconnect();
});
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)
.then(res => {
@ -22,7 +22,7 @@ describe('Fetch Module', 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)
.then(res => {

View File

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

View File

@ -2,11 +2,11 @@ describe('Net Module', function () {
this.timeout(5000);
before(() => {
return Edge.connect();
return Edge.Client.connect();
});
after(() => {
Edge.disconnect();
Edge.Client.disconnect();
});
it('should broadcast a message from server', function (done) {
@ -18,12 +18,12 @@ describe('Net Module', function () {
chai.assert.deepEqual(message, evt.detail);
Edge.removeEventListener('message', handler);
Edge.Client.removeEventListener('message', handler);
done();
};
Edge.addEventListener("message", handler);
Edge.send(message);
Edge.Client.addEventListener("message", handler);
Edge.Client.send(message);
});
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());
Edge.removeEventListener('message', handler);
Edge.Client.removeEventListener('message', handler);
done();
}
// Server should echo back message
Edge.addEventListener('message', handler);
Edge.Client.addEventListener('message', handler);
// 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 () {
before(() => {
return Edge.connect();
return Edge.Client.connect();
});
after(() => {
Edge.disconnect();
Edge.Client.disconnect();
});
it('should call the remote echo() method and resolve the returned value', function () {
const foo = "bar";
return Edge.rpc('echo', { foo })
return Edge.Client.rpc('echo', { foo })
.then(result => {
console.log(result);
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 () {
return Edge.rpc('throwErrorFromClient')
return Edge.Client.rpc('throwErrorFromClient')
.catch(err => {
// Assert that it's an "internal" error
// 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 () {
return Edge.rpc('unregisteredMethod')
return Edge.Client.rpc('unregisteredMethod')
.catch(err => {
// Assert that it's an "method not found" error
// See https://www.jsonrpc.org/specification#error_object
@ -44,11 +44,11 @@ describe('Remote Procedure Call', function () {
for (let i = 0; i <= 1000; i++) {
values.push((Math.random() * 1000 | 0));
}
return Edge.rpc('reset')
return Edge.Client.rpc('reset')
.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 => {
const localTotal = values.reduce((t, v) => t + v);
console.log("Remote total:", remoteTotal, "Local total:", localTotal);

View File

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

120
package-lock.json generated
View File

@ -10,14 +10,50 @@
"license": "AGPL-3.0",
"dependencies": {
"@types/sockjs-client": "^1.5.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"core-js": "^3.30.1",
"lit": "^2.7.2",
"sockjs-client": "^1.6.1"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
},
"node_modules/@lit/reactive-element": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@types/sockjs-client": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
},
"node_modules/@webcomponents/webcomponentsjs": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
},
"node_modules/core-js": {
"version": "3.30.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@ -55,6 +91,34 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/lit": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.7.0"
}
},
"node_modules/lit-element": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.7.0"
}
},
"node_modules/lit-html": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -139,11 +203,39 @@
}
},
"dependencies": {
"@lit-labs/ssr-dom-shim": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.0.tgz",
"integrity": "sha512-92uQ5ARf7UXYrzaFcAX3T2rTvaS9Z1//ukV+DqjACM4c8s0ZBQd7ayJU5Dh2AFLD/Ayuyz4uMmxQec8q3U4Ong=="
},
"@lit/reactive-element": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.1.tgz",
"integrity": "sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==",
"requires": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"@types/sockjs-client": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.1.tgz",
"integrity": "sha512-bmZM6A1GPdjF0bcuIUC+50hZEMGkzMsiG9by6X9U+7IZFOiPtz7MJ9h05FSpPVxlj4i+TzzoG3ESo1FJlbLb6A=="
},
"@types/trusted-types": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz",
"integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g=="
},
"@webcomponents/webcomponentsjs": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz",
"integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w=="
},
"core-js": {
"version": "3.30.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz",
"integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ=="
},
"debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@ -175,6 +267,34 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"lit": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.7.2.tgz",
"integrity": "sha512-9QnZmG5mIKPRja96cpndMclLSi0Qrz2BXD6EbqNqCKMMjOWVm/BwAeXufFk2jqFsNmY07HOzU8X+8aTSVt3yrA==",
"requires": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.7.0"
}
},
"lit-element": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.1.tgz",
"integrity": "sha512-Gl+2409uXWbf7n6cCl7Kzasm7zjT9xmdwi2BhLNi70sRKAgRkqueDu5mSIH3hPYMM0/vqBCdPXod3NbGkRA2ww==",
"requires": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.7.0"
}
},
"lit-html": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.7.2.tgz",
"integrity": "sha512-ZJCfKlA2XELu5tn7XuzOziGFGvf1SeQm+ngLWoJ8bXtSkRrrR3ms6SWy+gsdxeYwySLij5xAhdd2C3EX0ftxdQ==",
"requires": {
"@types/trusted-types": "^2.0.2"
}
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -10,6 +10,9 @@
"license": "AGPL-3.0",
"dependencies": {
"@types/sockjs-client": "^1.5.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"core-js": "^3.30.1",
"lit": "^2.7.2",
"sockjs-client": "^1.6.1"
}
}

View File

@ -103,13 +103,19 @@ func NewHandler(funcs ...HandlerOptionFunc) *Handler {
r.Get("/client.js.map", handler.handleSDKClientMap)
})
r.Route("/api/v1", func(r chi.Router) {
r.Post("/upload", handler.handleAppUpload)
r.Get("/download/{bucket}/{blobID}", handler.handleAppDownload)
r.Route("/api", func(r chi.Router) {
r.Post("/v1/upload", handler.handleAppUpload)
r.Get("/v1/download/{bucket}/{blobID}", handler.handleAppDownload)
r.Get("/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)
})

View File

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

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 {
List(context.Context) ([]*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"
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrClaimNotFound = errors.New("claim not found")
)
var ErrUnauthenticated = errors.New("unauthenticated")

View File

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

View File

@ -91,7 +91,7 @@
<form method="post" action="{{ .URL }}">
<div class="form-control">
<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 class="form-control">
<label for="password">Password</label>

View File

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

View File

@ -8,15 +8,21 @@ import (
"forge.cadoles.com/arcad/edge/pkg/module/util"
"github.com/dop251/goja"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const (
ClaimSubject = "sub"
ClaimSubject = "sub"
ClaimIssuer = "iss"
ClaimPreferredUsername = "preferred_username"
ClaimEdgeRole = "edge_role"
ClaimEdgeTenant = "edge_tenant"
ClaimEdgeEntrypoint = "edge_entrypoint"
)
type Module struct {
server *app.Server
getClaimFunc GetClaimFunc
server *app.Server
getClaims GetClaimsFunc
}
func (m *Module) Name() string {
@ -31,6 +37,22 @@ func (m *Module) Export(export *goja.Object) {
if err := export.Set("CLAIM_SUBJECT", ClaimSubject); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_SUBJECT' property"))
}
if err := export.Set("CLAIM_TENANT", ClaimEdgeTenant); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_TENANT' property"))
}
if err := export.Set("CLAIM_ENTRYPOINT", ClaimEdgeEntrypoint); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_ENTRYPOINT' property"))
}
if err := export.Set("CLAIM_ROLE", ClaimEdgeRole); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_ROLE' property"))
}
if err := export.Set("CLAIM_PREFERRED_USERNAME", ClaimPreferredUsername); err != nil {
panic(errors.Wrap(err, "could not set 'CLAIM_PREFERRED_USERNAME' property"))
}
}
func (m *Module) getClaim(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
@ -42,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")))
}
claim, err := m.getClaimFunc(ctx, req, claimName)
claim, err := m.getClaims(ctx, req, claimName)
if err != nil {
if errors.Is(err, ErrUnauthenticated) || errors.Is(err, ErrClaimNotFound) {
if errors.Is(err, ErrUnauthenticated) {
return nil
}
panic(rt.ToValue(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve claim", logger.E(errors.WithStack(err)))
return nil
}
return rt.ToValue(claim)
if len(claim) == 0 || claim[0] == "" {
return nil
}
return rt.ToValue(claim[0])
}
func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
@ -62,8 +89,8 @@ func ModuleFactory(funcs ...OptionFunc) app.ServerModuleFactory {
return func(server *app.Server) app.ServerModule {
return &Module{
server: server,
getClaimFunc: opt.GetClaim,
server: server,
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"
)
type GetClaimFunc func(ctx context.Context, r *http.Request, claimName string) (string, error)
type GetClaimsFunc func(ctx context.Context, r *http.Request, claims ...string) ([]string, error)
type Option struct {
GetClaim GetClaimFunc
GetClaims GetClaimsFunc
ProfileClaims []string
}
type OptionFunc func(*Option)
func defaultOptions() *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) {
return "", errors.Errorf("dummy getclaim func cannot retrieve claim '%s'", claimName)
func dummyGetClaims(ctx context.Context, r *http.Request, claims ...string) ([]string, error) {
return nil, errors.Errorf("dummy getclaim func cannot retrieve claims '%s'", claims)
}
func WithGetClaim(fn GetClaimFunc) OptionFunc {
func WithGetClaims(fn GetClaimsFunc) OptionFunc {
return func(o *Option) {
o.GetClaim = fn
o.GetClaims = fn
}
}
func WithProfileClaims(claims ...string) OptionFunc {
return func(o *Option) {
o.ProfileClaims = claims
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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

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,5 +1,16 @@
import { Client } from './client.js';
import { CrossFrameMessenger } from './crossframe-messenger.js';
import './polyfill';
export const client = new Client();
export const crossFrameMessenger = new CrossFrameMessenger();
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';

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"]
}