diff --git a/.gitignore b/.gitignore index 602705d..794aad4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ +customize.yaml +frontend/api.html frontend/app.css frontend/app.js frontend/app.js.LICENSE.txt frontend/css frontend/js -frontend/api.html frontend/locale/*.untranslated.json frontend/webfonts frontend/*.woff2 diff --git a/README.md b/README.md index 5ffe694..51413f5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,45 @@ For a better setup you can choose the backend which is used to store the secrets - Common options - `SECRET_EXPIRY` - Expiry of the keys in seconds (Default `0` = no expiry) +### Customization + +In order to be adjustable to your needs there are some ways to customize your OTS setup. All of those require you to create a YAML file containing the definitions of your customizations and to load this file through the `--customize=path/to/customize.yaml`: + +```yaml +# Override the app-icon, present a path to the image to use, if unset +# or empty the default FontAwesome icon will be displayed. Recommended +# is a height of 30px. +appIcon: '' + +# Override the app-title, if unset or empty the default app-title +# "OTS - One Time Secret" will be used +appTitle: '' + +# Disable display of the app-title (for example if you included the +# title within the appIcon) +disableAppTitle: false + +# Disable the footer linking back to the project. If you disable it +# please consider a donation to support the project. +disablePoweredBy: false + +# Disable the switcher for dark / light theme in the top right corner +# for example if your custom theme does not support two themes. +disableThemeSwitcher: false + +# Custom path to override embedded resources. You can override any +# file present in the `frontend` directory (which is baked into the +# binary during compile-time). You also can add new files (for +# example the appIcon given above). Those files are available at the +# root of the application (i.e. an app.png would be served at +# https://ots.example.com/app.png). +overlayFSPath: /path/to/ots-customization +``` + +To override the styling of the application have a look at the [`src/style.scss`](./src/style.scss) file how the theme of the application is built and present the compiled `app.css` in the `overlayFSPath`. + +After modifying files in the `overlayFSPath` make sure to restart the application as otherwise the file integrity hashes are no longer matching and your resources will be blocked by the browsers. + ## Creating secrets through CLI / scripts As `ots` is designed to never let the server know the secret you are sharing you should not just send the plain secret to it though it is possible. diff --git a/ci/build.mjs b/ci/build.mjs index b2ce146..2d1c83d 100644 --- a/ci/build.mjs +++ b/ci/build.mjs @@ -9,6 +9,7 @@ esbuild.build({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'), }, entryPoints: ['src/main.js'], + legalComments: 'none', loader: { '.woff2': 'file', }, diff --git a/customize.go b/customize.go new file mode 100644 index 0000000..1697082 --- /dev/null +++ b/customize.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "io/fs" + "os" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +type ( + customize struct { + AppIcon string `json:"appIcon,omitempty" yaml:"appIcon"` + AppTitle string `json:"appTitle,omitempty" yaml:"appTitle"` + DisableAppTitle bool `json:"disableAppTitle,omitempty" yaml:"disableAppTitle"` + DisablePoweredBy bool `json:"disablePoweredBy,omitempty" yaml:"disablePoweredBy"` + DisableThemeSwitcher bool `json:"disableThemeSwitcher,omitempty" yaml:"disableThemeSwitcher"` + OverlayFSPath string `json:"-" yaml:"overlayFSPath"` + } +) + +func loadCustomize(filename string) (customize, error) { + if filename == "" { + // None given, take a shortcut + return customize{}, nil + } + + var cust customize + + cf, err := os.Open(filename) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + logrus.Warn("customize file given but not found") + return cust, nil + } + return cust, errors.Wrap(err, "opening customize file") + } + defer cf.Close() + + return cust, errors.Wrap( + yaml.NewDecoder(cf).Decode(&cust), + "decoding customize file", + ) +} + +func (c customize) ToJSON() (string, error) { + j, err := json.Marshal(c) + return string(j), errors.Wrap(err, "marshalling JSON") +} diff --git a/frontend/index.html b/frontend/index.html index 4e39d8f..6ccb22a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -49,6 +49,7 @@ // Template variable from Golang process const version = "{{ .Version }}" + window.OTSCustomize = JSON.parse('{{ .Customize.ToJSON }}') diff --git a/go.mod b/go.mod index 03323ab..6ae964c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Luzifer/ots go 1.20 require ( - github.com/Luzifer/go_helpers/v2 v2.17.1 + github.com/Luzifer/go_helpers/v2 v2.18.0 github.com/Luzifer/rconfig/v2 v2.4.0 github.com/gofrs/uuid/v3 v3.1.2 github.com/gorilla/mux v1.8.0 diff --git a/go.sum b/go.sum index 8fc3444..2210742 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Luzifer/go_helpers/v2 v2.17.1 h1:SJjfkkJ14d1i8zpKzZ3619YSGijxp2jfc6Qk6iT5YsY= -github.com/Luzifer/go_helpers/v2 v2.17.1/go.mod h1:C5EkTBawA4sJt0CHoAoblgGPwTjW9blXZ/Et6RiEu6Q= +github.com/Luzifer/go_helpers/v2 v2.18.0 h1:zDLNPKAxyFLMcwCN2Z/0SVpU3hTTqdYWXnCyviI8IBM= +github.com/Luzifer/go_helpers/v2 v2.18.0/go.mod h1:C5EkTBawA4sJt0CHoAoblgGPwTjW9blXZ/Et6RiEu6Q= github.com/Luzifer/rconfig/v2 v2.4.0 h1:MAdymTlExAZ8mx5VG8xOFAtFQSpWBipKYQHPOmYTn9o= github.com/Luzifer/rconfig/v2 v2.4.0/go.mod h1:hWF3ZVSusbYlg5bEvCwalEyUSY+0JPJWUiIu7rBmav8= github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= diff --git a/main.go b/main.go index 1d1063b..55ebd2b 100644 --- a/main.go +++ b/main.go @@ -2,24 +2,26 @@ package main import ( "embed" - "fmt" + "io/fs" "mime" "net/http" "os" - "path" "strings" "text/template" + "time" "github.com/gorilla/mux" "github.com/pkg/errors" - log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus" + file_helpers "github.com/Luzifer/go_helpers/v2/file" http_helpers "github.com/Luzifer/go_helpers/v2/http" "github.com/Luzifer/rconfig/v2" ) var ( cfg struct { + Customize string `flag:"customize" default:"" description:"Customize-File to load"` Listen string `flag:"listen" default:":3000" description:"IP/Port to listen on"` LogLevel string `flag:"log-level" default:"info" description:"Set log level (debug, info, warning, error)"` SecretExpiry int64 `flag:"secret-expiry" default:"0" description:"Maximum expiry of the stored secrets in seconds"` @@ -27,34 +29,77 @@ var ( VersionAndExit bool `flag:"version" default:"false" description:"Print version information and exit"` } - product = "ots" + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP + cspHeader = strings.Join([]string{ + "default-src 'none'", + "connect-src 'self'", + "font-src 'self'", + "img-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + }, ";") + + assets file_helpers.FSStack + cust customize + indexTpl *template.Template + version = "dev" ) //go:embed frontend/* -var assets embed.FS +var embeddedAssets embed.FS -func init() { +func initApp() error { + rconfig.AutoEnv(true) if err := rconfig.ParseAndValidate(&cfg); err != nil { - log.Fatalf("Error parsing CLI arguments: %s", err) + return errors.Wrap(err, "parsing cli options") } - if l, err := log.ParseLevel(cfg.LogLevel); err == nil { - log.SetLevel(l) - } else { - log.Fatalf("Invalid log level: %s", err) + l, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + return errors.Wrap(err, "parsing log-level") + } + logrus.SetLevel(l) + + if cust, err = loadCustomize(cfg.Customize); err != nil { + return errors.Wrap(err, "loading customizations") } - if cfg.VersionAndExit { - fmt.Printf("%s %s\n", product, version) - os.Exit(0) + frontendFS, err := fs.Sub(embeddedAssets, "frontend") + if err != nil { + return errors.Wrap(err, "creating sub-fs for assets") } + assets = append(assets, frontendFS) + + if cust.OverlayFSPath != "" { + assets = append(file_helpers.FSStack{os.DirFS(cust.OverlayFSPath)}, assets...) + } + + return nil } func main() { + var err error + if err = initApp(); err != nil { + logrus.WithError(err).Fatal("initializing app") + } + + if cfg.VersionAndExit { + logrus.WithField("version", version).Info("ots") + os.Exit(0) + } + + // Initialize index template in order not to parse it multiple times + source, err := assets.ReadFile("index.html") + if err != nil { + logrus.WithError(err).Fatal("frontend folder should contain index.html Go template") + } + indexTpl = template.Must(template.New("index.html").Funcs(tplFuncs).Parse(string(source))) + + // Initialize storage store, err := getStorageByType(cfg.StorageType) if err != nil { - log.Fatalf("Could not initialize storage: %s", err) + logrus.WithError(err).Fatal("initializing storage") } api := newAPI(store) @@ -66,11 +111,21 @@ func main() { r.HandleFunc("/", handleIndex) r.PathPrefix("/").HandlerFunc(assetDelivery) - log.Fatalf("HTTP server quit: %s", http.ListenAndServe(cfg.Listen, http_helpers.NewHTTPLogHandler(r))) + logrus.WithField("version", version).Info("ots started") + + server := &http.Server{ + Addr: cfg.Listen, + Handler: http_helpers.NewHTTPLogHandlerWithLogger(r, logrus.StandardLogger()), + ReadHeaderTimeout: time.Second, + } + + if err = server.ListenAndServe(); err != nil { + logrus.WithError(err).Fatal("HTTP server quit unexpectedly") + } } func assetDelivery(w http.ResponseWriter, r *http.Request) { - assetName := r.URL.Path + assetName := strings.TrimLeft(r.URL.Path, "/") dot := strings.LastIndex(assetName, ".") if dot < 0 { @@ -80,7 +135,7 @@ func assetDelivery(w http.ResponseWriter, r *http.Request) { } ext := assetName[dot:] - assetData, err := assets.ReadFile(path.Join("frontend", assetName)) + assetData, err := assets.ReadFile(assetName) if err != nil { http.Error(w, "404 not found", http.StatusNotFound) return @@ -91,28 +146,6 @@ func assetDelivery(w http.ResponseWriter, r *http.Request) { w.Write(assetData) } -var ( - // https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP - cspHeader = strings.Join([]string{ - "default-src 'none'", - "connect-src 'self'", - "font-src 'self'", - "img-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - }, ";") - - indexTpl *template.Template -) - -func init() { - source, err := assets.ReadFile("frontend/index.html") - if err != nil { - log.WithError(err).Fatal("frontend folder should contain index.html Go template") - } - indexTpl = template.Must(template.New("index.html").Funcs(tplFuncs).Parse(string(source))) -} - func handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Referrer-Policy", "no-referrer") @@ -122,9 +155,11 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") if err := indexTpl.Execute(w, struct { - Version string + Customize customize + Version string }{ - Version: version, + Customize: cust, + Version: version, }); err != nil { http.Error(w, errors.Wrap(err, "executing template").Error(), http.StatusInternalServerError) return diff --git a/src/app.vue b/src/app.vue index 2a7ce19..b0d3262 100644 --- a/src/app.vue +++ b/src/app.vue @@ -9,7 +9,16 @@ href="#" @click="newSecret" > - OTS - One Time Secrets + + + {{ customize.appTitle || 'OTS - One Time Secrets' }} @@ -25,7 +34,10 @@ {{ $t('btn-new-secret') }} - + - + {{ $t('text-powered-by') }} OTS {{ $root.version }} @@ -176,6 +191,7 @@ export default { data() { return { + customize: {}, error: '', explanationShown: false, mode: 'create', @@ -207,6 +223,7 @@ export default { // Trigger initialization functions mounted() { + this.customize = window.OTSCustomize this.darkTheme = window.getTheme() === 'dark' window.onhashchange = this.hashLoad this.hashLoad() diff --git a/src/main.js b/src/main.js index 7668299..bbc998e 100644 --- a/src/main.js +++ b/src/main.js @@ -3,8 +3,6 @@ import VueI18n from 'vue-i18n' import BootstrapVue from 'bootstrap-vue' -import 'bootstrap/dist/css/bootstrap.css' -import 'bootstrap-vue/dist/bootstrap-vue.css' import './style.scss' import app from './app.vue' diff --git a/src/style.scss b/src/style.scss index 150ce1f..e7238a0 100644 --- a/src/style.scss +++ b/src/style.scss @@ -1,6 +1,9 @@ // Force local fonts $web-font-path: ''; +@import "../node_modules/bootstrap/dist/css/bootstrap.css"; +@import "../node_modules/bootstrap-vue/dist/bootstrap-vue.css"; + @import "lato"; :root { diff --git a/tplFuncs.go b/tplFuncs.go index 62df749..3e90b4d 100644 --- a/tplFuncs.go +++ b/tplFuncs.go @@ -3,7 +3,6 @@ package main import ( "crypto/sha512" "encoding/base64" - "path" "sync" "text/template" ) @@ -21,7 +20,7 @@ func assetSRIHash(assetName string) string { return sri } - data, err := assets.ReadFile(path.Join("frontend", assetName)) + data, err := assets.ReadFile(assetName) if err != nil { panic(err) }