Initial QSfera import
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/x/path/filepathx"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidApp is the error when an app is invalid
|
||||
ErrInvalidApp = errors.New("invalid app")
|
||||
|
||||
// ErrMissingManifest is the error when the manifest is missing
|
||||
ErrMissingManifest = errors.New("missing manifest")
|
||||
|
||||
// ErrInvalidManifest is the error when the manifest is invalid
|
||||
ErrInvalidManifest = errors.New("invalid manifest")
|
||||
|
||||
// ErrEntrypointDoesNotExist is the error when the entrypoint does not exist or is not a file
|
||||
ErrEntrypointDoesNotExist = errors.New("entrypoint does not exist")
|
||||
|
||||
validate = validator.New(validator.WithRequiredStructEnabled())
|
||||
)
|
||||
|
||||
const (
|
||||
// _manifest is the name of the manifest file for an application
|
||||
_manifest = "manifest.json"
|
||||
|
||||
// _config contains the dedicated app configuration
|
||||
_config = "config.json"
|
||||
)
|
||||
|
||||
// Application contains the metadata of an application
|
||||
type Application struct {
|
||||
// ID is the unique identifier of the application
|
||||
ID string `json:"-"`
|
||||
|
||||
// Disabled is a flag to disable the application
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// Entrypoint is the entrypoint of the application within the bundle
|
||||
Entrypoint string `json:"entrypoint" validate:"required"`
|
||||
|
||||
// Config contains the application-specific configuration
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ToExternal converts an Application to an ExternalApp configuration
|
||||
func (a Application) ToExternal(entrypoint string) config.ExternalApp {
|
||||
return config.ExternalApp{
|
||||
ID: a.ID,
|
||||
Path: filepathx.JailJoin(entrypoint, a.Entrypoint),
|
||||
Config: a.Config,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns a list of applications from the given filesystems,
|
||||
// individual filesystems are searched for applications, and the list is merged.
|
||||
// Last finding gets priority in case of conflicts, so the order of the filesystems is important.
|
||||
func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Application {
|
||||
registry := map[string]Application{}
|
||||
|
||||
for _, fSystem := range fSystems {
|
||||
if fSystem == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(fSystem, ".")
|
||||
if err != nil {
|
||||
// skip non-directory listings, every app needs to be contained inside a directory
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
var appData config.App
|
||||
name := entry.Name()
|
||||
|
||||
// configuration for the application is optional, if it is not present, the default configuration is used
|
||||
if data, ok := data[name]; ok {
|
||||
appData = data
|
||||
}
|
||||
|
||||
application, err := build(fSystem, name, appData)
|
||||
if err != nil {
|
||||
// if app creation fails, log the error and continue with the next app
|
||||
logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application")
|
||||
continue
|
||||
}
|
||||
|
||||
if application.Disabled {
|
||||
// if the app is disabled, skip it
|
||||
continue
|
||||
}
|
||||
|
||||
// everything is fine, add the application to the list of applications
|
||||
registry[name] = application
|
||||
}
|
||||
}
|
||||
|
||||
return maps.Values(registry)
|
||||
}
|
||||
|
||||
func build(fSystem fs.FS, id string, globalConfig config.App) (Application, error) {
|
||||
// skip non-directory listings, every app needs to be contained inside a directory
|
||||
entry, err := fs.Stat(fSystem, id)
|
||||
if err != nil || !entry.IsDir() {
|
||||
return Application{}, ErrInvalidApp
|
||||
}
|
||||
|
||||
var application Application
|
||||
// build the application
|
||||
{
|
||||
r, err := fSystem.Open(path.Join(id, _manifest))
|
||||
if err != nil {
|
||||
return Application{}, errors.Join(err, ErrMissingManifest)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if json.NewDecoder(r).Decode(&application) != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
|
||||
if err := validate.Struct(application); err != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
var localConfig config.App
|
||||
// build the local configuration
|
||||
{
|
||||
r, err := fSystem.Open(path.Join(id, _config))
|
||||
if err == nil {
|
||||
defer r.Close()
|
||||
}
|
||||
|
||||
// as soon as we have a local configuration, we expect it to be valid
|
||||
if err == nil && json.NewDecoder(r).Decode(&localConfig) != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
// apply overloads the application with the source configuration,
|
||||
// not all fields are considered secure, therefore, the allowed values are hand-picked
|
||||
overloadApplication := func(source config.App) error {
|
||||
// overload the configuration,
|
||||
// configuration options are considered secure and can be overloaded
|
||||
err = mergo.Merge(&application.Config, source.Config, mergo.WithOverride)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// overload the disabled state, consider it secure and allow overloading
|
||||
application.Disabled = source.Disabled
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// overload the application configuration from the manifest with the local and global configuration
|
||||
// priority is local.config > global.config > manifest.config
|
||||
{
|
||||
// overload the default configuration with the global application-specific configuration,
|
||||
// that configuration has priority, and failing is fine here
|
||||
_ = overloadApplication(globalConfig)
|
||||
|
||||
// overload the default configuration with the local application-specific configuration,
|
||||
// that configuration has priority, and failing is fine here
|
||||
_ = overloadApplication(localConfig)
|
||||
}
|
||||
|
||||
// the entrypoint is jailed to the app directory
|
||||
application.Entrypoint = filepathx.JailJoin(id, application.Entrypoint)
|
||||
info, err := fs.Stat(fSystem, application.Entrypoint)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Application{}, errors.Join(err, ErrEntrypointDoesNotExist)
|
||||
case info.IsDir():
|
||||
return Application{}, ErrEntrypointDoesNotExist
|
||||
}
|
||||
|
||||
application.ID = id
|
||||
|
||||
return application, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package apps_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/web/pkg/apps"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
func TestApplication_ToExternal(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
app := apps.Application{
|
||||
ID: "app",
|
||||
Entrypoint: "entrypoint.js",
|
||||
Config: map[string]any{
|
||||
"foo": "bar",
|
||||
},
|
||||
}
|
||||
|
||||
externalApp := app.ToExternal("path")
|
||||
|
||||
g.Expect(externalApp.ID).To(gomega.Equal("app"))
|
||||
g.Expect(externalApp.Path).To(gomega.Equal("path/entrypoint.js"))
|
||||
g.Expect(externalApp.Config).To(gomega.Equal(app.Config))
|
||||
}
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
dir := &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": &fstest.MapFile{},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": dir,
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte("{}"),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
|
||||
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": &fstest.MapFile{},
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
|
||||
}
|
||||
|
||||
{
|
||||
application, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": &fstest.MapFile{},
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"k1": "1", "k2": "2", "k3": "3"}}`),
|
||||
},
|
||||
"app/config.json": &fstest.MapFile{
|
||||
Data: []byte(`{"config": {"k2": "overwritten-from-config.json", "injected_from_config_json": "11"}}`),
|
||||
},
|
||||
}, "app", config.App{Config: map[string]any{"k2": "overwritten-from-apps.yaml", "k3": "overwritten-from-apps.yaml", "injected_from_apps_yaml": "22"}})
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
|
||||
g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js"))
|
||||
g.Expect(application.Config).To(gomega.Equal(map[string]any{
|
||||
"k1": "1", "k2": "overwritten-from-config.json", "k3": "overwritten-from-apps.yaml", "injected_from_config_json": "11", "injected_from_apps_yaml": "22",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
|
||||
applications := apps.List(log.NopLogger(), map[string]config.App{})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{}, nil)
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{}, fstest.MapFS{})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
dir := &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
}
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app": {
|
||||
Disabled: true,
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app": dir,
|
||||
})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app": {},
|
||||
}, fstest.MapFS{
|
||||
"app": dir,
|
||||
})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app-3": {
|
||||
Config: map[string]any{
|
||||
"foo": "local conf 1",
|
||||
"bar": "local conf 2",
|
||||
},
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app-1": dir,
|
||||
"app-1/entrypoint.js": &fstest.MapFile{},
|
||||
"app-1/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
|
||||
},
|
||||
"app-2": dir,
|
||||
"app-2/entrypoint.js": &fstest.MapFile{},
|
||||
"app-2/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-2", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app-1": dir,
|
||||
"app-1/entrypoint.js": &fstest.MapFile{},
|
||||
"app-1/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
|
||||
},
|
||||
"app-3": dir,
|
||||
"app-3/entrypoint.js": &fstest.MapFile{},
|
||||
"app-3/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-3", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
|
||||
},
|
||||
})
|
||||
g.Expect(len(applications)).To(gomega.Equal(3))
|
||||
|
||||
for _, application := range applications {
|
||||
switch {
|
||||
case application.Entrypoint == "app-1/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("fs2"))
|
||||
case application.Entrypoint == "app-2/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("fs1"))
|
||||
case application.Entrypoint == "app-3/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("local conf 1"))
|
||||
g.Expect(application.Config["bar"]).To(gomega.Equal("local conf 2"))
|
||||
default:
|
||||
t.Fatalf("unexpected application %s", application.Entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package apps
|
||||
|
||||
var Build = build
|
||||
@@ -0,0 +1,114 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type fileServer struct {
|
||||
fsys http.FileSystem
|
||||
}
|
||||
|
||||
const (
|
||||
webTitle = "КуСфера"
|
||||
legacyUpdateURL = "https://update.opencloud.eu/"
|
||||
qsferaUpdateURL = "https://update.qsfera.eu/"
|
||||
)
|
||||
|
||||
// FileServer defines the http handler for the embedded files
|
||||
func FileServer(fsys fs.FS) http.Handler {
|
||||
return &fileServer{http.FS(fsys)}
|
||||
}
|
||||
|
||||
func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
uPath := path.Clean(path.Join("/", r.URL.Path))
|
||||
r.URL.Path = uPath
|
||||
|
||||
tryIndex := func() {
|
||||
r.URL.Path = "/index.html"
|
||||
|
||||
// not every fs contains a file named index.html,
|
||||
// therefore, we need to check if the file exists and stop the recursion if it doesn't
|
||||
file, err := f.fsys.Open(r.URL.Path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
f.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
asset, err := f.fsys.Open(uPath)
|
||||
if err != nil {
|
||||
tryIndex()
|
||||
return
|
||||
}
|
||||
defer asset.Close()
|
||||
|
||||
s, _ := asset.Stat()
|
||||
if s.IsDir() {
|
||||
tryIndex()
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(s.Name())))
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
switch s.Name() {
|
||||
case "index.html", "oidc-callback.html", "oidc-silent-redirect.html":
|
||||
w.Header().Del("Expires")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_ = withBase(buf, asset, "/")
|
||||
default:
|
||||
_, _ = buf.ReadFrom(asset)
|
||||
}
|
||||
|
||||
body := bytes.ReplaceAll(buf.Bytes(), []byte(legacyUpdateURL), []byte(qsferaUpdateURL))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func withBase(w io.Writer, r io.Reader, base string) error {
|
||||
doc, _ := html.Parse(r)
|
||||
var parse func(*html.Node)
|
||||
parse = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "head" {
|
||||
n.InsertBefore(&html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "base",
|
||||
Attr: []html.Attribute{
|
||||
{
|
||||
Key: "href",
|
||||
Val: base,
|
||||
},
|
||||
},
|
||||
}, n.FirstChild)
|
||||
}
|
||||
|
||||
if n.Type == html.ElementNode && n.Data == "title" {
|
||||
if n.FirstChild == nil {
|
||||
n.AppendChild(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: webTitle,
|
||||
})
|
||||
} else {
|
||||
n.FirstChild.Data = webTitle
|
||||
}
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
parse(c)
|
||||
}
|
||||
}
|
||||
parse(doc)
|
||||
|
||||
return html.Render(w, doc)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFileServer(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
recorderStatus := func(s int) string {
|
||||
return fmt.Sprintf("%03d %s", s, http.StatusText(s))
|
||||
}
|
||||
|
||||
{
|
||||
s := FileServer(fstest.MapFS{})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/foo", nil)
|
||||
//defer req.Body.Close()
|
||||
s.ServeHTTP(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
g.Expect(res.Status).To(gomega.Equal(recorderStatus(http.StatusNotFound)))
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
url string
|
||||
fs fstest.MapFS
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "not found fallback",
|
||||
url: "/index.txt",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "directory fallback",
|
||||
url: "/some-folder",
|
||||
fs: fstest.MapFS{
|
||||
"some-folder": &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
},
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "index.html",
|
||||
url: "/index.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("index file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>index file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "oidc-callback.html",
|
||||
url: "/oidc-callback.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("oidc-callback file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>oidc-callback file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "oidc-silent-redirect.html",
|
||||
url: "/oidc-silent-redirect.html",
|
||||
fs: fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte("oidc-silent-redirect file content"),
|
||||
},
|
||||
},
|
||||
expected: `<html><head><base href="/"/></head><body>oidc-silent-redirect file content</body></html>`,
|
||||
},
|
||||
{
|
||||
name: "some-file.txt",
|
||||
url: "/some-file.txt",
|
||||
fs: fstest.MapFS{
|
||||
"some-file.txt": &fstest.MapFile{
|
||||
Data: []byte("some file content"),
|
||||
},
|
||||
},
|
||||
expected: "some file content",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", tt.url, nil)
|
||||
FileServer(tt.fs).ServeHTTP(w, req)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
g.Expect(res.Status).To(gomega.Equal(recorderStatus(http.StatusOK)))
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
g.Expect(string(data)).To(gomega.Equal(tt.expected))
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
"github.com/qsfera/server/services/web/pkg/config/parser"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "check health status",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnError(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the web command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "web",
|
||||
Short: "Serve Web for КуСфера",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
"github.com/qsfera/server/services/web/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/web/pkg/metrics"
|
||||
"github.com/qsfera/server/services/web/pkg/server/debug"
|
||||
"github.com/qsfera/server/services/web/pkg/server/http"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// actually read the contents of the config file and override defaults
|
||||
if cfg.File != "" {
|
||||
contents, err := os.ReadFile(cfg.File)
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("error opening config file")
|
||||
return err
|
||||
}
|
||||
if err = json.Unmarshal(contents, &cfg.Web.Config); err != nil {
|
||||
logger.Fatal().Err(err).Msg("error unmarshalling config file")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
ctx := cfg.Context
|
||||
|
||||
m := metrics.New()
|
||||
|
||||
gr := runner.NewGroup()
|
||||
{
|
||||
server, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Namespace(cfg.HTTP.Namespace),
|
||||
http.Config(cfg),
|
||||
http.Metrics(m),
|
||||
http.TraceProvider(traceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().
|
||||
Err(err).
|
||||
Str("transport", "http").
|
||||
Msg("Failed to initialize server")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
|
||||
}
|
||||
|
||||
{
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
table.Header([]string{"Version", "Address", "Id"})
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;WEB_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Asset Asset `yaml:"asset"`
|
||||
File string `yaml:"file" env:"WEB_UI_CONFIG_FILE" desc:"Read the КуСфера Web json based configuration from this path/file. The config file takes precedence over WEB_OPTION_xxx environment variables. See the text description for more details." introductionVersion:"1.0.0"`
|
||||
Web Web `yaml:"web"`
|
||||
Apps map[string]App
|
||||
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
GatewayAddress string `yaml:"gateway_addr" env:"WEB_GATEWAY_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// Asset defines the available asset configuration.
|
||||
type Asset struct {
|
||||
CorePath string `yaml:"core_path" env:"WEB_ASSET_CORE_PATH" desc:"Serve КуСфера Web assets from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/web/assets/core" introductionVersion:"1.0.0"`
|
||||
ThemesPath string `yaml:"themes_path" env:"OC_ASSET_THEMES_PATH;WEB_ASSET_THEMES_PATH" desc:"Serve КуСфера themes from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/web/assets/themes" introductionVersion:"1.0.0"`
|
||||
AppsPath string `yaml:"apps_path" env:"WEB_ASSET_APPS_PATH" desc:"Serve КуСфера Web apps assets from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/web/assets/apps" introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// CustomStyle references additional css to be loaded into КуСфера Web.
|
||||
type CustomStyle struct {
|
||||
Href string `json:"href" yaml:"href"`
|
||||
}
|
||||
|
||||
// CustomScript references an additional script to be loaded into КуСфера Web.
|
||||
type CustomScript struct {
|
||||
Src string `json:"src" yaml:"src"`
|
||||
Async bool `json:"async,omitempty" yaml:"async"`
|
||||
}
|
||||
|
||||
// CustomTranslation references a json file for overwriting translations in КуСфера Web.
|
||||
type CustomTranslation struct {
|
||||
Url string `json:"url" yaml:"url"`
|
||||
}
|
||||
|
||||
// WebConfig defines the available web configuration for a dynamically rendered config.json.
|
||||
type WebConfig struct {
|
||||
Server string `json:"server,omitempty" yaml:"server" env:"OC_URL;WEB_UI_CONFIG_SERVER" desc:"URL, where the КуСфера APIs are reachable for КуСфера Web." introductionVersion:"1.0.0"`
|
||||
Theme string `json:"theme,omitempty" yaml:"-"`
|
||||
OpenIDConnect OIDC `json:"openIdConnect,omitempty" yaml:"oidc"`
|
||||
Apps []string `json:"apps" yaml:"apps"`
|
||||
Applications []Application `json:"applications,omitempty" yaml:"applications"`
|
||||
ExternalApps []ExternalApp `json:"external_apps,omitempty" yaml:"external_apps"`
|
||||
Options Options `json:"options,omitempty" yaml:"options"`
|
||||
Styles []CustomStyle `json:"styles,omitempty" yaml:"styles"`
|
||||
Scripts []CustomScript `json:"scripts,omitempty" yaml:"scripts"`
|
||||
Translations []CustomTranslation `json:"customTranslations,omitempty" yaml:"custom_translations"`
|
||||
}
|
||||
|
||||
// OIDC defines the available oidc configuration
|
||||
type OIDC struct {
|
||||
MetadataURL string `json:"metadata_url,omitempty" yaml:"metadata_url" env:"WEB_OIDC_METADATA_URL" desc:"URL for the OIDC well-known configuration endpoint. Defaults to the КуСфера API URL + '/.well-known/openid-configuration'." introductionVersion:"1.0.0"`
|
||||
Authority string `json:"authority,omitempty" yaml:"authority" env:"OC_URL;OC_OIDC_ISSUER;WEB_OIDC_AUTHORITY" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP." introductionVersion:"1.0.0"`
|
||||
ClientID string `json:"client_id,omitempty" yaml:"client_id" env:"OC_OIDC_CLIENT_ID;WEB_OIDC_CLIENT_ID" desc:"The OIDC client ID which КуСфера Web uses. This client needs to be set up in your IDP. Note that this setting has no effect when using the builtin IDP." introductionVersion:"1.0.0"`
|
||||
ResponseType string `json:"response_type,omitempty" yaml:"response_type" env:"WEB_OIDC_RESPONSE_TYPE" desc:"The OIDC response type to use for authentication." introductionVersion:"1.0.0"`
|
||||
Scope string `json:"scope,omitempty" yaml:"scope" env:"WEB_OIDC_SCOPE" desc:"OIDC scopes to request during authentication to authorize access to user details. Defaults to 'openid profile email'. Values are separated by blank. More example values but not limited to are 'address' or 'phone' etc." introductionVersion:"1.0.0"`
|
||||
PostLogoutRedirectURI string `json:"post_logout_redirect_uri,omitempty" yaml:"post_logout_redirect_uri" env:"WEB_OIDC_POST_LOGOUT_REDIRECT_URI" desc:"This value needs to point to a valid and reachable web page. The web client will trigger a redirect to that page directly after the logout action. The default value is empty and redirects to the login page." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Application defines an application for the Web app switcher.
|
||||
type Application struct {
|
||||
Icon string `json:"icon,omitempty" yaml:"icon"`
|
||||
Target string `json:"target,omitempty" yaml:"target"`
|
||||
Title map[string]string `json:"title,omitempty" yaml:"title"`
|
||||
Menu string `json:"menu,omitempty" yaml:"menu"`
|
||||
URL string `json:"url,omitempty" yaml:"url"`
|
||||
}
|
||||
|
||||
// ExternalApp defines an external web app.
|
||||
//
|
||||
// {
|
||||
// "name": "hello",
|
||||
// "path": "http://localhost:9105/hello.js",
|
||||
// "config": {
|
||||
// "url": "http://localhost:9105"
|
||||
// }
|
||||
// }
|
||||
type ExternalApp struct {
|
||||
ID string `json:"id,omitempty" yaml:"id"`
|
||||
Path string `json:"path,omitempty" yaml:"path"`
|
||||
// Config is completely dynamic, because it depends on the extension
|
||||
Config map[string]any `json:"config,omitempty" yaml:"config"`
|
||||
}
|
||||
|
||||
// ExternalAppConfig defines an external web app configuration.
|
||||
type ExternalAppConfig struct {
|
||||
URL string `json:"url,omitempty" yaml:"url"`
|
||||
}
|
||||
|
||||
// Web defines the available web configuration.
|
||||
type Web struct {
|
||||
ThemeServer string `yaml:"theme_server" env:"OC_URL;WEB_UI_THEME_SERVER" desc:"Base URL to load themes from. Will be prepended to the theme path." introductionVersion:"1.0.0"` // used to build Theme in WebConfig
|
||||
ThemePath string `yaml:"theme_path" env:"WEB_UI_THEME_PATH" desc:"Path to the theme json file. Will be appended to the URL of the theme server." introductionVersion:"1.0.0"` // used to build Theme in WebConfig
|
||||
Config WebConfig `yaml:"config"`
|
||||
}
|
||||
|
||||
// App defines the individual app configuration.
|
||||
type App struct {
|
||||
Disabled bool `yaml:"disabled"`
|
||||
Config map[string]any `yaml:"config"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;WEB_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"WEB_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `yaml:"token" env:"WEB_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"WEB_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"WEB_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns a fully initialized default configuration
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig returns a basic default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9104",
|
||||
Token: "",
|
||||
Pprof: false,
|
||||
Zpages: false,
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9100",
|
||||
Root: "/",
|
||||
Namespace: "qsfera.web",
|
||||
CacheTTL: 604800, // 7 days
|
||||
|
||||
CORS: config.CORS{
|
||||
AllowedOrigins: []string{"https://localhost:9200"},
|
||||
AllowedMethods: []string{
|
||||
"OPTIONS",
|
||||
"HEAD",
|
||||
"GET",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"POST",
|
||||
"DELETE",
|
||||
"MKCOL",
|
||||
"PROPFIND",
|
||||
"PROPPATCH",
|
||||
"MOVE",
|
||||
"COPY",
|
||||
"REPORT",
|
||||
"SEARCH",
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Origin",
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
"Depth",
|
||||
"Authorization",
|
||||
"Ocs-Apirequest",
|
||||
"If-None-Match",
|
||||
"If-Match",
|
||||
"Destination",
|
||||
"Overwrite",
|
||||
"X-Request-Id",
|
||||
"X-Requested-With",
|
||||
"Tus-Resumable",
|
||||
"Tus-Checksum-Algorithm",
|
||||
"Upload-Concat",
|
||||
"Upload-Length",
|
||||
"Upload-Metadata",
|
||||
"Upload-Defer-Length",
|
||||
"Upload-Expires",
|
||||
"Upload-Checksum",
|
||||
"Upload-Offset",
|
||||
"X-HTTP-Method-Override",
|
||||
},
|
||||
AllowCredentials: false,
|
||||
},
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "web",
|
||||
},
|
||||
Asset: config.Asset{
|
||||
CorePath: filepath.Join(defaults.BaseDataPath(), "web/assets/core"),
|
||||
AppsPath: filepath.Join(defaults.BaseDataPath(), "web/assets/apps"),
|
||||
ThemesPath: filepath.Join(defaults.BaseDataPath(), "web/assets/themes"),
|
||||
},
|
||||
GatewayAddress: "qsfera.api.gateway",
|
||||
Web: config.Web{
|
||||
ThemeServer: "https://localhost:9200",
|
||||
ThemePath: "/themes/qsfera/theme.json",
|
||||
Config: config.WebConfig{
|
||||
Server: "https://localhost:9200",
|
||||
Theme: "",
|
||||
OpenIDConnect: config.OIDC{
|
||||
MetadataURL: "",
|
||||
Authority: "https://localhost:9200",
|
||||
ClientID: "web",
|
||||
ResponseType: "code",
|
||||
Scope: "openid profile email",
|
||||
},
|
||||
Apps: []string{"files", "search", "text-editor", "pdf-viewer", "external", "admin-settings", "epub-reader", "preview", "app-store"},
|
||||
Options: config.Options{
|
||||
ContextHelpersReadMore: true,
|
||||
AccountEditLink: &config.AccountEditLink{},
|
||||
Editor: &config.Editor{},
|
||||
FeedbackLink: &config.FeedbackLink{},
|
||||
Embed: &config.Embed{},
|
||||
ConcurrentRequests: &config.ConcurrentRequests{
|
||||
Shares: &config.ConcurrentRequestsShares{},
|
||||
},
|
||||
Upload: &config.Upload{},
|
||||
TokenStorageLocal: true,
|
||||
UserListRequiresFilter: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults adds default values to the configuration if they are not set yet
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
if cfg.Commons != nil {
|
||||
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
|
||||
}
|
||||
|
||||
if (cfg.Commons != nil && cfg.Commons.QsferaURL != "") &&
|
||||
(cfg.HTTP.CORS.AllowedOrigins == nil ||
|
||||
len(cfg.HTTP.CORS.AllowedOrigins) == 1 &&
|
||||
cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") {
|
||||
cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.QsferaURL}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitized the configuration
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
if cfg.HTTP.Root != "/" {
|
||||
cfg.HTTP.Root = strings.TrimRight(cfg.HTTP.Root, "/")
|
||||
}
|
||||
// build well known openid-configuration endpoint if it is not set
|
||||
if cfg.Web.Config.OpenIDConnect.MetadataURL == "" {
|
||||
cfg.Web.Config.OpenIDConnect.MetadataURL = strings.TrimRight(cfg.Web.Config.OpenIDConnect.Authority, "/") + "/.well-known/openid-configuration"
|
||||
}
|
||||
// remove AccountEdit parent if no value is set
|
||||
if cfg.Web.Config.Options.AccountEditLink != nil &&
|
||||
cfg.Web.Config.Options.AccountEditLink.Href == "" {
|
||||
cfg.Web.Config.Options.AccountEditLink = nil
|
||||
}
|
||||
// remove Editor parent if no value is set
|
||||
if cfg.Web.Config.Options.Editor != nil &&
|
||||
!cfg.Web.Config.Options.Editor.AutosaveEnabled {
|
||||
cfg.Web.Config.Options.Editor = nil
|
||||
}
|
||||
// remove FeedbackLink parent if no value is set
|
||||
if cfg.Web.Config.Options.FeedbackLink != nil &&
|
||||
cfg.Web.Config.Options.FeedbackLink.Href == "" &&
|
||||
cfg.Web.Config.Options.FeedbackLink.AriaLabel == "" &&
|
||||
cfg.Web.Config.Options.FeedbackLink.Description == "" {
|
||||
cfg.Web.Config.Options.FeedbackLink = nil
|
||||
}
|
||||
// remove Upload parent if no value is set
|
||||
if cfg.Web.Config.Options.Upload != nil &&
|
||||
cfg.Web.Config.Options.Upload.CompanionURL == "" {
|
||||
cfg.Web.Config.Options.Upload = nil
|
||||
}
|
||||
// remove Embed parent if no value is set
|
||||
if cfg.Web.Config.Options.Embed != nil &&
|
||||
cfg.Web.Config.Options.Embed.Enabled == "" &&
|
||||
cfg.Web.Config.Options.Embed.Target == "" &&
|
||||
cfg.Web.Config.Options.Embed.MessagesOrigin == "" &&
|
||||
cfg.Web.Config.Options.Embed.DelegateAuthentication &&
|
||||
cfg.Web.Config.Options.Embed.DelegateAuthenticationOrigin == "" {
|
||||
cfg.Web.Config.Options.Embed = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import "github.com/qsfera/server/pkg/shared"
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"WEB_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
TLS shared.HTTPServiceTLS `yaml:"tls"`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"WEB_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
|
||||
CacheTTL int `yaml:"cache_ttl" env:"WEB_CACHE_TTL" desc:"Cache policy in seconds for КуСфера Web assets." introductionVersion:"1.0.0"`
|
||||
CORS CORS `yaml:"cors"`
|
||||
}
|
||||
|
||||
// CORS defines the available cors configuration.
|
||||
type CORS struct {
|
||||
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;WEB_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;WEB_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;WEB_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;WEB_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS. See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
// Options are the option for the web
|
||||
type Options struct {
|
||||
AccountEditLink *AccountEditLink `json:"accountEditLink,omitempty" yaml:"accountEditLink"`
|
||||
DisableFeedbackLink bool `json:"disableFeedbackLink,omitempty" yaml:"disableFeedbackLink" env:"WEB_OPTION_DISABLE_FEEDBACK_LINK" desc:"Set this option to 'true' to disable the feedback link in the top bar. Keeping it enabled by setting the value to 'false' or with the absence of the option, allows КуСфера to get feedback from your user base through a dedicated survey website." introductionVersion:"1.0.0"`
|
||||
FeedbackLink *FeedbackLink `json:"feedbackLink,omitempty" yaml:"feedbackLink"`
|
||||
RunningOnEOS bool `json:"runningOnEos,omitempty" yaml:"runningOnEos" env:"WEB_OPTION_RUNNING_ON_EOS" desc:"Set this option to 'true' if running on an EOS storage backend (https://eos-web.web.cern.ch/eos-web/) to enable its specific features. Defaults to 'false'." introductionVersion:"1.0.0" deprecationVersion:"6.2.0" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"WEB_OPTION_RUNNING_ON_EOS is deprecated and will be removed in a future release."`
|
||||
CernFeatures bool `json:"cernFeatures,omitempty" yaml:"cernFeatures" deprecationVersion:"6.2.0" removalVersion:"%%NEXT_PRODUCTION_VERSION%%"`
|
||||
OpenFilesInNewTab bool `json:"openFilesInNewTab,omitempty" yaml:"openFilesInNewTab" env:"WEB_OPTION_OPEN_FILES_IN_NEW_TAB" desc:"Set this option to 'true' to open files in a new browser tab instead of navigating in the same tab. Defaults to 'false'." introductionVersion:"5.3.0"`
|
||||
Upload *Upload `json:"upload,omitempty" yaml:"upload"`
|
||||
Editor *Editor `json:"editor,omitempty" yaml:"editor"`
|
||||
ContextHelpersReadMore bool `json:"contextHelpersReadMore,omitempty" yaml:"contextHelpersReadMore" env:"WEB_OPTION_CONTEXTHELPERS_READ_MORE" desc:"Specifies whether the 'Read more' link should be displayed or not." introductionVersion:"1.0.0"`
|
||||
LogoutURL string `json:"logoutUrl,omitempty" yaml:"logoutUrl" env:"WEB_OPTION_LOGOUT_URL" desc:"Adds a link to the user's profile page to point him to an external page, where he can manage his session and devices. This is helpful when an external IdP is used. This option is disabled by default." introductionVersion:"1.0.0"`
|
||||
LoginURL string `json:"loginUrl,omitempty" yaml:"loginUrl" env:"WEB_OPTION_LOGIN_URL" desc:"Specifies the target URL to the login page. This is helpful when an external IdP is used. This option is disabled by default. Example URL like: https://www.myidp.com/login." introductionVersion:"1.0.0"`
|
||||
TokenStorageLocal bool `json:"tokenStorageLocal" yaml:"tokenStorageLocal" env:"WEB_OPTION_TOKEN_STORAGE_LOCAL" desc:"Specifies whether the access token will be stored in the local storage when set to 'true' or in the session storage when set to 'false'. If stored in the local storage, login state will be persisted across multiple browser tabs, means no additional logins are required." introductionVersion:"1.0.0"`
|
||||
DisabledExtensions []string `json:"disabledExtensions,omitempty" yaml:"disabledExtensions" env:"WEB_OPTION_DISABLED_EXTENSIONS" desc:"A list to disable specific Web extensions identified by their ID. The ID can e.g. be taken from the 'index.ts' file of the web extension. Example: 'com.github.qsfera-eu.web.files.search,com.github.qsfera-eu.web.files.print'. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
Embed *Embed `json:"embed,omitempty" yaml:"embed"`
|
||||
UserListRequiresFilter bool `json:"userListRequiresFilter,omitempty" yaml:"userListRequiresFilter" env:"WEB_OPTION_USER_LIST_REQUIRES_FILTER" desc:"Defines whether one or more filters must be set in order to list users in the Web admin settings. Set this option to 'true' if running in an environment with a lot of users and listing all users could slow down performance. Defaults to 'false'." introductionVersion:"1.0.0"`
|
||||
ConcurrentRequests *ConcurrentRequests `json:"concurrentRequests,omitempty" yaml:"concurrentRequests"`
|
||||
DefaultAppID string `json:"defaultAppId,omitempty" yaml:"defaultAppId" env:"WEB_OPTION_DEFAULT_APP_ID" desc:"Defines the entrypoint for the web ui." introductionVersion:"4.0.0"`
|
||||
}
|
||||
|
||||
// AccountEditLink are the AccountEditLink options
|
||||
type AccountEditLink struct {
|
||||
Href string `json:"href,omitempty" yaml:"href" env:"WEB_OPTION_ACCOUNT_EDIT_LINK_HREF" desc:"Set a different target URL for the edit link. Make sure to prepend it with 'http(s)://'." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// FeedbackLink are the feedback link options
|
||||
type FeedbackLink struct {
|
||||
Href string `json:"href,omitempty" yaml:"href" env:"WEB_OPTION_FEEDBACKLINK_HREF" desc:"Set a target URL for the feedback link. Make sure to prepend it with 'http(s)://'." introductionVersion:"1.0.0"`
|
||||
AriaLabel string `json:"ariaLabel,omitempty" yaml:"ariaLabel" env:"WEB_OPTION_FEEDBACKLINK_ARIALABEL" desc:"Since the feedback link only has an icon, a screen reader accessible label can be set. The text defaults to 'КуСфера feedback survey'." introductionVersion:"1.0.0"`
|
||||
Description string `json:"description,omitempty" yaml:"description" env:"WEB_OPTION_FEEDBACKLINK_DESCRIPTION" desc:"For feedbacks, provide any description you want to see as tooltip and as accessible description. Defaults to 'Provide your feedback: We'd like to improve the web design and would be happy to hear your feedback. Thank you! Your КуСфера team'." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Upload are the upload options
|
||||
type Upload struct {
|
||||
CompanionURL string `json:"companionUrl,omitempty" yaml:"companionUrl" env:"WEB_OPTION_UPLOAD_COMPANION_URL" desc:"Sets the URL of Companion which is a service provided by Uppy to import files from external cloud providers. See https://uppy.io/docs/companion/ for instructions on how to set up Companion. This feature is disabled as long as no URL is given." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Editor are the web editor options
|
||||
type Editor struct {
|
||||
AutosaveEnabled bool `json:"autosaveEnabled,omitempty" yaml:"autosaveEnabled" env:"WEB_OPTION_EDITOR_AUTOSAVE_ENABLED" desc:"Specifies if the autosave for the editor apps is enabled." introductionVersion:"1.0.0"`
|
||||
AutosaveInterval int `json:"autosaveInterval,omitempty" yaml:"autosaveInterval" env:"WEB_OPTION_EDITOR_AUTOSAVE_INTERVAL" desc:"Specifies the time interval for the autosave of editor apps in seconds. Has no effect when WEB_OPTION_EDITOR_AUTOSAVE_ENABLED is set to 'false'." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// Embed are the Embed options
|
||||
type Embed struct {
|
||||
Enabled string `json:"enabled,omitempty" yaml:"enabled" env:"WEB_OPTION_EMBED_ENABLED" desc:"Defines whether Web should be running in 'embed' mode. Setting this to 'true' will enable a stripped down version of Web with reduced functionality used to integrate Web into other applications like via iFrame. Setting it to 'false' or not setting it (default) will run Web as usual with all functionality enabled. See the text description for more details." introductionVersion:"1.0.0"`
|
||||
Target string `json:"target,omitempty" yaml:"target" env:"WEB_OPTION_EMBED_TARGET" desc:"Defines how Web is being integrated when running in 'embed' mode. Currently, the only supported options are '' (empty) and 'location'. With '' which is the default, Web will run regular as defined via the 'embed.enabled' config option. With 'location', Web will run embedded as location picker. Resource selection will be disabled and the selected resources array always includes the current folder as the only item. See the text description for more details." introductionVersion:"1.0.0"`
|
||||
MessagesOrigin string `json:"messagesOrigin,omitempty" yaml:"messagesOrigin" env:"WEB_OPTION_EMBED_MESSAGES_ORIGIN" desc:"Defines a URL under which Web can be integrated via iFrame in 'embed' mode. Note that setting this is mandatory when running Web in 'embed' mode. Use '*' as value to allow running the iFrame under any URL, although this is not recommended for security reasons. See the text description for more details." introductionVersion:"1.0.0"`
|
||||
DelegateAuthentication bool `json:"delegateAuthentication,omitempty" yaml:"delegateAuthentication" env:"WEB_OPTION_EMBED_DELEGATE_AUTHENTICATION" desc:"Defines whether Web should require authentication to be done by the parent application when running in 'embed' mode. If set to 'true' Web will not try to authenticate the user on its own but will require an access token coming from the parent application. Defaults to being unset." introductionVersion:"1.0.0"`
|
||||
DelegateAuthenticationOrigin string `json:"delegateAuthenticationOrigin,omitempty" yaml:"delegateAuthenticationOrigin" env:"WEB_OPTION_EMBED_DELEGATE_AUTHENTICATION_ORIGIN" desc:"Defines the host to validate the message event origin against when running Web in 'embed' mode with delegated authentication. Defaults to event message origin validation being omitted, which is only recommended for development setups." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// ConcurrentRequests are the ConcurrentRequests options
|
||||
type ConcurrentRequests struct {
|
||||
ResourceBatchActions int `json:"resourceBatchActions,omitempty" yaml:"resourceBatchActions" env:"WEB_OPTION_CONCURRENT_REQUESTS_RESOURCE_BATCH_ACTIONS" desc:"Defines the maximum number of concurrent requests per file/folder/space batch action. Defaults to 4." introductionVersion:"1.0.0"`
|
||||
SSE int `json:"sse,omitempty" yaml:"sse" env:"WEB_OPTION_CONCURRENT_REQUESTS_SSE" desc:"Defines the maximum number of concurrent requests in SSE event handlers. Defaults to 4." introductionVersion:"1.0.0"`
|
||||
Shares *ConcurrentRequestsShares `json:"shares,omitempty" yaml:"shares"`
|
||||
}
|
||||
|
||||
// ConcurrentRequestsShares are the Shares options inside the ConcurrentRequests options
|
||||
type ConcurrentRequestsShares struct {
|
||||
Create int `json:"create,omitempty" yaml:"create" env:"WEB_OPTION_CONCURRENT_REQUESTS_SHARES_CREATE" desc:"Defines the maximum number of concurrent requests per sharing invite batch. Defaults to 4." introductionVersion:"1.0.0"`
|
||||
List int `json:"list,omitempty" yaml:"list" env:"WEB_OPTION_CONCURRENT_REQUESTS_SHARES_LIST" desc:"Defines the maximum number of concurrent requests when loading individual share information inside listings. Defaults to 2." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
"github.com/qsfera/server/services/web/pkg/config/defaults"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// apps are a special case, as they are not part of the main config, but are loaded from a separate config file
|
||||
err = occfg.BindSourcesToStructs("apps", &cfg.Apps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
// Validate validates the configuration
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package metrics
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
// Counter *prometheus.CounterVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{}
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
checkHandler := handlers.NewCheckHandler(
|
||||
handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)),
|
||||
)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(checkHandler),
|
||||
debug.Ready(checkHandler),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
"github.com/qsfera/server/services/web/pkg/metrics"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.Metrics
|
||||
Flags []pflag.Flag
|
||||
Namespace string
|
||||
TraceProvider trace.TracerProvider
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides a function to set the Namespace option.
|
||||
func Namespace(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.Namespace = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the TraceProvider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// Flags provides a function to set the flags option.
|
||||
func Flags(flags ...pflag.Flag) Option {
|
||||
return func(o *Options) {
|
||||
o.Flags = append(o.Flags, flags...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go-micro.dev/v4"
|
||||
|
||||
"github.com/qsfera/server/pkg/cors"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/service/http"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/services/web"
|
||||
"github.com/qsfera/server/services/web/pkg/apps"
|
||||
svc "github.com/qsfera/server/services/web/pkg/service/v0"
|
||||
)
|
||||
|
||||
var (
|
||||
// _customAppsEndpoint path is used to make app artifacts available by the web service.
|
||||
_customAppsEndpoint = "/assets/apps"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
service, err := http.NewService(
|
||||
http.TLSConfig(options.Config.HTTP.TLS),
|
||||
http.Logger(options.Logger),
|
||||
http.Namespace(options.Namespace),
|
||||
http.Name("web"),
|
||||
http.Version(version.GetString()),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.Flags(options.Flags...),
|
||||
http.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing http service")
|
||||
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
|
||||
}
|
||||
|
||||
gatewaySelector, err := pool.GatewaySelector(
|
||||
options.Config.GatewayAddress,
|
||||
pool.WithRegistry(registry.GetRegistry()),
|
||||
pool.WithTracerProvider(options.TraceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
appsFS := fsx.NewFallbackFS(
|
||||
fsx.NewReadOnlyFs(fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.AppsPath)),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/apps"),
|
||||
)
|
||||
// build and inject the list of applications into the config
|
||||
for _, application := range apps.List(options.Logger, options.Config.Apps, appsFS.Secondary().IOFS(), appsFS.Primary().IOFS()) {
|
||||
options.Config.Web.Config.ExternalApps = append(
|
||||
options.Config.Web.Config.ExternalApps,
|
||||
application.ToExternal(path.Join(options.Config.HTTP.Root, _customAppsEndpoint)),
|
||||
)
|
||||
}
|
||||
|
||||
coreFS := fsx.NewFallbackFS(
|
||||
fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.CorePath),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/core"),
|
||||
)
|
||||
themeFS := fsx.NewFallbackFS(
|
||||
fsx.NewBasePathFs(fsx.NewOsFs(), options.Config.Asset.ThemesPath),
|
||||
fsx.NewBasePathFs(fsx.FromIOFS(web.Assets), "assets/themes"),
|
||||
)
|
||||
|
||||
handle, err := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.CoreFS(coreFS.IOFS()),
|
||||
svc.AppFS(appsFS.IOFS()),
|
||||
svc.ThemeFS(themeFS),
|
||||
svc.AppsHTTPEndpoint(_customAppsEndpoint),
|
||||
svc.Config(options.Config),
|
||||
svc.GatewaySelector(gatewaySelector),
|
||||
svc.Middleware(
|
||||
chimiddleware.RealIP,
|
||||
chimiddleware.RequestID,
|
||||
chimiddleware.Compress(5),
|
||||
middleware.NoCache,
|
||||
middleware.Version(
|
||||
options.Config.Service.Name,
|
||||
version.GetString(),
|
||||
),
|
||||
middleware.Logger(
|
||||
options.Logger,
|
||||
),
|
||||
middleware.Cors(
|
||||
cors.Logger(options.Logger),
|
||||
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
|
||||
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
|
||||
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
|
||||
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
|
||||
),
|
||||
),
|
||||
svc.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
{
|
||||
handle = svc.NewInstrument(handle, options.Metrics)
|
||||
handle = svc.NewLogging(handle, options.Logger)
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/services/web/pkg/metrics"
|
||||
)
|
||||
|
||||
// NewInstrument returns a service that instruments metrics.
|
||||
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
|
||||
return instrument{
|
||||
next: next,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type instrument struct {
|
||||
next Service
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Config implements the Service interface.
|
||||
func (i instrument) Config(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.Config(w, r)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
// NewLogging returns a service that logs messages.
|
||||
func NewLogging(next Service, logger log.Logger) Service {
|
||||
return logging{
|
||||
next: next,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type logging struct {
|
||||
next Service
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Config implements the Service interface.
|
||||
func (l logging) Config(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.Config(w, r)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options define the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
TraceProvider trace.TracerProvider
|
||||
AppsHTTPEndpoint string
|
||||
CoreFS fs.FS
|
||||
AppFS fs.FS
|
||||
ThemeFS *fsx.FallbackFS
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware provides a function to set the middleware option.
|
||||
func Middleware(val ...func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Middleware = val
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector provides a function to set the gatewaySelector option.
|
||||
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = gatewaySelector
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the traceProvider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppFS provides a function to set the appFS option.
|
||||
func AppFS(val fs.FS) Option {
|
||||
return func(o *Options) {
|
||||
o.AppFS = val
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeFS provides a function to set the themeFS option.
|
||||
func ThemeFS(val *fsx.FallbackFS) Option {
|
||||
return func(o *Options) {
|
||||
o.ThemeFS = val
|
||||
}
|
||||
}
|
||||
|
||||
// AppsHTTPEndpoint provides a function to set the appsHTTPEndpoint option.
|
||||
func AppsHTTPEndpoint(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.AppsHTTPEndpoint = val
|
||||
}
|
||||
}
|
||||
|
||||
// CoreFS provides a function to set the coreFS option.
|
||||
func CoreFS(val fs.FS) Option {
|
||||
return func(o *Options) {
|
||||
o.CoreFS = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/riandyrn/otelchi"
|
||||
|
||||
"github.com/qsfera/server/pkg/account"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/middleware"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/services/web/pkg/assets"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
"github.com/qsfera/server/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
// ErrConfigInvalid is returned when the config parse is invalid.
|
||||
var ErrConfigInvalid = `Invalid or missing config`
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
Config(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) (Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
m := chi.NewMux()
|
||||
m.Use(options.Middleware...)
|
||||
|
||||
m.Use(
|
||||
otelchi.Middleware(
|
||||
"web",
|
||||
otelchi.WithChiRoutes(m),
|
||||
otelchi.WithTracerProvider(options.TraceProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
)
|
||||
|
||||
svc := Web{
|
||||
logger: options.Logger,
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
coreFS: options.CoreFS,
|
||||
themeFS: options.ThemeFS,
|
||||
gatewaySelector: options.GatewaySelector,
|
||||
}
|
||||
|
||||
themeService, err := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(options.ThemeFS).
|
||||
WithGatewaySelector(options.GatewaySelector),
|
||||
)
|
||||
if err != nil {
|
||||
return svc, err
|
||||
}
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Get("/config.json", svc.Config)
|
||||
r.Get("/web/config.json", svc.Config)
|
||||
r.Route("/branding/logo", func(r chi.Router) {
|
||||
r.Use(middleware.ExtractAccountUUID(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
))
|
||||
r.Post("/", themeService.LogoUpload)
|
||||
r.Delete("/", themeService.LogoReset)
|
||||
})
|
||||
r.Route("/themes", func(r chi.Router) {
|
||||
r.Get("/{id}/theme.json", themeService.Get)
|
||||
r.Mount("/", svc.Static(
|
||||
options.ThemeFS.IOFS(),
|
||||
path.Join(svc.config.HTTP.Root, "/themes"),
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
})
|
||||
r.Mount(options.AppsHTTPEndpoint, svc.Static(
|
||||
options.AppFS,
|
||||
path.Join(svc.config.HTTP.Root, options.AppsHTTPEndpoint),
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
r.Mount("/", svc.Static(
|
||||
svc.coreFS,
|
||||
svc.config.HTTP.Root,
|
||||
options.Config.HTTP.CacheTTL,
|
||||
))
|
||||
})
|
||||
_ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint")
|
||||
return nil
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Web defines the handlers for the web service.
|
||||
type Web struct {
|
||||
logger log.Logger
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
coreFS fs.FS
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (p Web) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
p.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (p Web) getPayload() (payload []byte, err error) {
|
||||
// render dynamically using config
|
||||
|
||||
// build theme url
|
||||
if themeServer, err := url.Parse(p.config.Web.ThemeServer); err == nil {
|
||||
p.config.Web.Config.Theme = themeServer.String() + p.config.Web.ThemePath
|
||||
} else {
|
||||
p.config.Web.Config.Theme = p.config.Web.ThemePath
|
||||
}
|
||||
|
||||
// make apps render as empty array if it is empty
|
||||
// TODO remove once https://github.com/golang/go/issues/27589 is fixed
|
||||
if len(p.config.Web.Config.Apps) == 0 {
|
||||
p.config.Web.Config.Apps = make([]string, 0)
|
||||
}
|
||||
|
||||
// ensure that the server url has a trailing slash
|
||||
p.config.Web.Config.Server = strings.TrimRight(p.config.Web.Config.Server, "/") + "/"
|
||||
|
||||
return json.Marshal(p.config.Web.Config)
|
||||
}
|
||||
|
||||
// Config implements the Service interface.
|
||||
func (p Web) Config(w http.ResponseWriter, _ *http.Request) {
|
||||
payload, err := p.getPayload()
|
||||
if err != nil {
|
||||
http.Error(w, ErrConfigInvalid, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
p.logger.Error().Err(err).Msg("could not write config response")
|
||||
}
|
||||
}
|
||||
|
||||
// Static simply serves all static files.
|
||||
func (p Web) Static(f fs.FS, root string, ttl int) http.HandlerFunc {
|
||||
rootWithSlash := root
|
||||
|
||||
if !strings.HasSuffix(rootWithSlash, "/") {
|
||||
rootWithSlash = rootWithSlash + "/"
|
||||
}
|
||||
|
||||
static := http.StripPrefix(
|
||||
rootWithSlash,
|
||||
assets.FileServer(f),
|
||||
)
|
||||
|
||||
lastModified := time.Now().UTC().Format(http.TimeFormat)
|
||||
expires := time.Now().Add(time.Second * time.Duration(ttl)).UTC().Format(http.TimeFormat)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if rootWithSlash != "/" && r.URL.Path == p.config.HTTP.Root {
|
||||
http.Redirect(
|
||||
w,
|
||||
r,
|
||||
rootWithSlash,
|
||||
http.StatusMovedPermanently,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "max-age="+strconv.Itoa(ttl))
|
||||
w.Header().Set("Expires", expires)
|
||||
w.Header().Set("Last-Modified", lastModified)
|
||||
w.Header().Set("SameSite", "Strict")
|
||||
|
||||
static.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package theme
|
||||
|
||||
var IsFiletypePermitted = isFiletypePermitted
|
||||
@@ -0,0 +1,98 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// KV is a generic key-value map.
|
||||
type KV map[string]any
|
||||
|
||||
// MergeKV merges the given key-value maps.
|
||||
func MergeKV(values ...KV) (KV, error) {
|
||||
var kv KV
|
||||
|
||||
for _, v := range values {
|
||||
err := mergo.Merge(&kv, v, mergo.WithOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// PatchKV injects the given values into to v.
|
||||
func PatchKV(v map[string]any, values KV) KV {
|
||||
if v == nil {
|
||||
v = KV{}
|
||||
}
|
||||
for k, val := range values {
|
||||
t := v
|
||||
path := strings.Split(k, ".")
|
||||
for i, p := range path {
|
||||
if i == len(path)-1 {
|
||||
switch val {
|
||||
// if the value is nil, we delete the key
|
||||
case nil:
|
||||
delete(t, p)
|
||||
default:
|
||||
t[p] = val
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if _, ok := t[p]; !ok {
|
||||
t[p] = map[string]any{}
|
||||
}
|
||||
|
||||
t = t[p].(map[string]any)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// LoadKV loads a key-value map from the given file system.
|
||||
func LoadKV(fsys afero.Fs, p string) (KV, error) {
|
||||
f, err := fsys.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var kv KV
|
||||
err = json.NewDecoder(f).Decode(&kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// WriteKV writes the given key-value map to the file system.
|
||||
func WriteKV(fsys afero.Fs, p string, kv KV) error {
|
||||
data, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return afero.WriteReader(fsys, p, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// UpdateKV updates the key-value map at the given path with the given values.
|
||||
func UpdateKV(fsys afero.Fs, p string, values KV) error {
|
||||
var kv KV
|
||||
|
||||
existing, err := LoadKV(fsys, p)
|
||||
if err == nil {
|
||||
kv = existing
|
||||
}
|
||||
|
||||
kv = PatchKV(kv, values)
|
||||
|
||||
return WriteKV(fsys, p, kv)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
func TestMergeKV(t *testing.T) {
|
||||
left := theme.KV{
|
||||
"left": "left",
|
||||
"both": "left",
|
||||
}
|
||||
right := theme.KV{
|
||||
"right": "right",
|
||||
"both": "right",
|
||||
}
|
||||
|
||||
result, err := theme.MergeKV(left, right)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, result, theme.KV{
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"both": "right",
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
out := theme.PatchKV(in, theme.KV{
|
||||
"b.value": "b-new",
|
||||
"c.value": "c-new",
|
||||
"d": "d-new",
|
||||
"e.value.subvalue": "e-new",
|
||||
})
|
||||
assert.Equal(t, theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b-new",
|
||||
},
|
||||
"c": map[string]any{
|
||||
"value": "c-new",
|
||||
},
|
||||
"d": "d-new",
|
||||
"e": map[string]any{
|
||||
"value": map[string]any{
|
||||
"subvalue": "e-new",
|
||||
},
|
||||
},
|
||||
}, out)
|
||||
}
|
||||
|
||||
func TestPatchKVUnset(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
out := theme.PatchKV(in, theme.KV{
|
||||
"a.value": nil,
|
||||
"b": nil,
|
||||
})
|
||||
assert.Equal(t, theme.KV{
|
||||
"a": map[string]any{},
|
||||
}, out)
|
||||
}
|
||||
|
||||
func TestPatchKVwithNil(t *testing.T) {
|
||||
var in theme.KV
|
||||
out := theme.PatchKV(in, theme.KV{
|
||||
"b.value": "b-new",
|
||||
"c.value": "c-new",
|
||||
"d": "d-new",
|
||||
"e.value.subvalue": "e-new",
|
||||
})
|
||||
assert.Equal(t, theme.KV{
|
||||
"b": map[string]any{
|
||||
"value": "b-new",
|
||||
},
|
||||
"c": map[string]any{
|
||||
"value": "c-new",
|
||||
},
|
||||
"d": "d-new",
|
||||
"e": map[string]any{
|
||||
"value": map[string]any{
|
||||
"subvalue": "e-new",
|
||||
},
|
||||
},
|
||||
}, out)
|
||||
}
|
||||
|
||||
func TestLoadKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
assert.Nil(t, err)
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, afero.WriteFile(fsys, "some.json", b, 0644))
|
||||
|
||||
out, err := theme.LoadKV(fsys, "some.json")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestWriteKV(t *testing.T) {
|
||||
in := theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, theme.WriteKV(fsys, "some.json", in))
|
||||
|
||||
f, err := fsys.Open("some.json")
|
||||
assert.Nil(t, err)
|
||||
|
||||
var out theme.KV
|
||||
assert.Nil(t, json.NewDecoder(f).Decode(&out))
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
|
||||
func TestUpdateKV(t *testing.T) {
|
||||
fileKV := theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b",
|
||||
},
|
||||
}
|
||||
|
||||
wb, err := json.Marshal(fileKV)
|
||||
assert.Nil(t, err)
|
||||
|
||||
fsys := fsx.NewMemMapFs()
|
||||
assert.Nil(t, afero.WriteFile(fsys, "some.json", wb, 0644))
|
||||
_ = theme.UpdateKV(fsys, "some.json", theme.KV{
|
||||
"b.value": "b-new",
|
||||
"c.value": "c-new",
|
||||
})
|
||||
|
||||
f, err := fsys.Open("some.json")
|
||||
assert.Nil(t, err)
|
||||
|
||||
var out theme.KV
|
||||
assert.Nil(t, json.NewDecoder(f).Decode(&out))
|
||||
assert.Equal(t, out, theme.KV{
|
||||
"a": map[string]any{
|
||||
"value": "a",
|
||||
},
|
||||
"b": map[string]any{
|
||||
"value": "b-new",
|
||||
},
|
||||
"c": map[string]any{
|
||||
"value": "c-new",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/pkg/x/path/filepathx"
|
||||
)
|
||||
|
||||
// ServiceOptions defines the options to configure the Service.
|
||||
type ServiceOptions struct {
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// WithThemeFS sets the theme filesystem.
|
||||
func (o ServiceOptions) WithThemeFS(fSys *fsx.FallbackFS) ServiceOptions {
|
||||
o.themeFS = fSys
|
||||
return o
|
||||
}
|
||||
|
||||
// WithGatewaySelector sets the gateway selector.
|
||||
func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions {
|
||||
o.gatewaySelector = gws
|
||||
return o
|
||||
}
|
||||
|
||||
// validate validates the input parameters.
|
||||
func (o ServiceOptions) validate() error {
|
||||
if o.themeFS == nil {
|
||||
return errors.New("themeFS is required")
|
||||
}
|
||||
|
||||
if o.gatewaySelector == nil {
|
||||
return errors.New("gatewaySelector is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service defines the http service.
|
||||
type Service struct {
|
||||
themeFS *fsx.FallbackFS
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
}
|
||||
|
||||
// NewService initializes a new Service.
|
||||
func NewService(options ServiceOptions) (Service, error) {
|
||||
if err := options.validate(); err != nil {
|
||||
return Service{}, err
|
||||
}
|
||||
|
||||
return Service(options), nil
|
||||
}
|
||||
|
||||
// Get renders the theme, the theme is a merge of the default theme, the base theme, and the branding theme.
|
||||
func (s Service) Get(w http.ResponseWriter, r *http.Request) {
|
||||
// there is no guarantee that the theme exists, its optional; therefore, we ignore the error
|
||||
baseTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(r.PathValue("id"), _themeFileName))
|
||||
|
||||
// there is no guarantee that the theme exists, its optional; therefore, we ignore the error here too
|
||||
brandingTheme, _ := LoadKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName))
|
||||
|
||||
// merge the themes, the order is important, the last one wins and overrides the previous ones
|
||||
// themeDefaults: contains all the default values, this is guaranteed to exist
|
||||
// baseTheme: contains the base theme from the theme fs, there is no guarantee that it exists
|
||||
// brandingTheme: contains the branding theme from the theme fs, there is no guarantee that it exists
|
||||
// mergedTheme = themeDefaults < baseTheme < brandingTheme
|
||||
mergedTheme, err := MergeKV(themeDefaults, baseTheme, brandingTheme)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(mergedTheme)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// LogoUpload implements the endpoint to upload a custom logo for the КуСфера instance.
|
||||
func (s Service) LogoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
SubjectRef: &permissionsapi.SubjectReference{
|
||||
Spec: &permissionsapi.SubjectReference_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("logo")
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if !isFiletypePermitted(fileHeader.Filename, fileHeader.Header.Get("Content-Type")) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fp := filepathx.JailJoin(_brandingRoot, fileHeader.Filename)
|
||||
err = afero.WriteReader(s.themeFS, fp, file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{
|
||||
"common.logo": filepathx.JailJoin("themes", fp),
|
||||
"clients.web.defaults.logo": filepathx.JailJoin("themes", fp),
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// LogoReset implements the endpoint to reset the instance logo.
|
||||
// The config will be changed back to use the embedded logo asset.
|
||||
func (s Service) LogoReset(w http.ResponseWriter, r *http.Request) {
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := revactx.ContextMustGetUser(r.Context())
|
||||
rsp, err := gatewayClient.CheckPermission(r.Context(), &permissionsapi.CheckPermissionRequest{
|
||||
Permission: "Logo.Write",
|
||||
SubjectRef: &permissionsapi.SubjectReference{
|
||||
Spec: &permissionsapi.SubjectReference_UserId{
|
||||
UserId: user.GetId(),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rsp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
err = UpdateKV(s.themeFS, filepathx.JailJoin(_brandingRoot, _themeFileName), KV{
|
||||
"common.logo": nil,
|
||||
"clients.web.defaults.logo": nil,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/qsfera/server/pkg/x/io/fsx"
|
||||
"github.com/qsfera/server/services/graph/mocks"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
"github.com/qsfera/server/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
t.Run("fails if the options are invalid", func(t *testing.T) {
|
||||
_, err := theme.NewService(theme.ServiceOptions{})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("success if the options are valid", func(t *testing.T) {
|
||||
_, err := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(fsx.NewFallbackFS(fsx.NewMemMapFs(), fsx.NewMemMapFs())).
|
||||
WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Get(t *testing.T) {
|
||||
primaryFS := fsx.NewMemMapFs()
|
||||
fallbackFS := fsx.NewFallbackFS(primaryFS, fsx.NewMemMapFs())
|
||||
|
||||
add := func(filename string, content any) {
|
||||
b, err := json.Marshal(content)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Nil(t, afero.WriteFile(primaryFS, filename, b, 0644))
|
||||
}
|
||||
|
||||
// baseTheme
|
||||
add("base/theme.json", map[string]any{
|
||||
"base": "base",
|
||||
})
|
||||
// brandingTheme
|
||||
add("_branding/theme.json", map[string]any{
|
||||
"_branding": "_branding",
|
||||
})
|
||||
|
||||
service, _ := theme.NewService(
|
||||
theme.ServiceOptions{}.
|
||||
WithThemeFS(fallbackFS).
|
||||
WithGatewaySelector(mocks.NewSelectable[gateway.GatewayAPIClient](t)),
|
||||
)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.SetPathValue("id", "base")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
service.Get(w, r)
|
||||
|
||||
jsonData := gjson.Parse(w.Body.String())
|
||||
// baseTheme
|
||||
assert.Equal(t, jsonData.Get("base").String(), "base")
|
||||
// brandingTheme
|
||||
assert.Equal(t, jsonData.Get("_branding").String(), "_branding")
|
||||
// themeDefaults
|
||||
assert.Equal(t, jsonData.Get("common.shareRoles."+unifiedrole.UnifiedRoleViewerID+".name").String(), "UnifiedRoleViewer")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/qsfera/server/pkg/capabilities"
|
||||
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
|
||||
)
|
||||
|
||||
var (
|
||||
_brandingRoot = "_branding"
|
||||
_themeFileName = "theme.json"
|
||||
)
|
||||
|
||||
// themeDefaults contains the default values for the theme.
|
||||
// all rendered themes get the default values from here.
|
||||
var themeDefaults = KV{
|
||||
"common": KV{
|
||||
"shareRoles": KV{
|
||||
unifiedrole.UnifiedRoleViewerID: KV{
|
||||
"name": "UnifiedRoleViewer",
|
||||
"iconName": "eye",
|
||||
},
|
||||
unifiedrole.UnifiedRoleViewerListGrantsID: KV{
|
||||
"name": "UnifiedRoleViewerListGrants",
|
||||
"iconName": "eye",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSpaceViewerID: KV{
|
||||
"label": "UnifiedRoleSpaceViewer",
|
||||
"iconName": "eye",
|
||||
},
|
||||
unifiedrole.UnifiedRoleFileEditorID: KV{
|
||||
"label": "UnifiedRoleFileEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleFileEditorListGrantsID: KV{
|
||||
"label": "UnifiedRoleFileEditorListGrants",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleEditorID: KV{
|
||||
"label": "UnifiedRoleEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleEditorListGrantsID: KV{
|
||||
"label": "UnifiedRoleEditorListGrants",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSpaceEditorID: KV{
|
||||
"label": "UnifiedRoleSpaceEditor",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSpaceEditorWithoutVersionsID: KV{
|
||||
"label": "UnifiedRoleSpaceEditorWithoutVersions",
|
||||
"iconName": "pencil",
|
||||
},
|
||||
unifiedrole.UnifiedRoleManagerID: KV{
|
||||
"label": "UnifiedRoleManager",
|
||||
"iconName": "user-star",
|
||||
},
|
||||
unifiedrole.UnifiedRoleEditorLiteID: KV{
|
||||
"label": "UnifiedRoleEditorLite",
|
||||
"iconName": "upload",
|
||||
},
|
||||
unifiedrole.UnifiedRoleSecureViewerID: KV{
|
||||
"label": "UnifiedRoleSecureView",
|
||||
"iconName": "shield",
|
||||
},
|
||||
unifiedrole.UnifiedRoleDeniedID: KV{
|
||||
"label": "UnifiedRoleFullDenial",
|
||||
"iconName": "stop-circle",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// isFiletypePermitted checks if the given file extension is allowed.
|
||||
func isFiletypePermitted(filename string, givenMime string) bool {
|
||||
// Check if we allow that extension and if the mediatype matches the extension
|
||||
extensionMime, ok := capabilities.Default().Theme.Logo.PermittedFileTypes[path.Ext(filename)]
|
||||
return ok && extensionMime == givenMime
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package theme_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/web/pkg/theme"
|
||||
)
|
||||
|
||||
// TestAllowedLogoFileTypes is here to ensure that a certain set of bare minimum file types are allowed for logos.
|
||||
func TestAllowedLogoFileTypes(t *testing.T) {
|
||||
type test struct {
|
||||
filename string
|
||||
mimetype string
|
||||
allowed bool
|
||||
}
|
||||
|
||||
tests := []test{
|
||||
{filename: "foo.jpg", mimetype: "image/jpeg", allowed: true},
|
||||
{filename: "foo.jpeg", mimetype: "image/jpeg", allowed: true},
|
||||
{filename: "foo.png", mimetype: "image/png", allowed: true},
|
||||
{filename: "foo.gif", mimetype: "image/gif", allowed: true},
|
||||
{filename: "foo.tiff", mimetype: "image/tiff", allowed: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
assert.Equal(t, theme.IsFiletypePermitted(tc.filename, tc.mimetype), tc.allowed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user