Implement frontend customizations

refs #71

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2023-06-10 18:21:38 +02:00
parent d3ca12fa35
commit 128ce071cb
Signed by: luzifer
GPG key ID: D91C3E91E4CAD6F5
12 changed files with 198 additions and 53 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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.

View file

@ -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',
},

51
customize.go Normal file
View file

@ -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")
}

View file

@ -49,6 +49,7 @@
// Template variable from Golang process
const version = "{{ .Version }}"
window.OTSCustomize = JSON.parse('{{ .Customize.ToJSON }}')
</script>
</head>
<body>

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

115
main.go
View file

@ -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,8 +155,10 @@ func handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if err := indexTpl.Execute(w, struct {
Customize customize
Version string
}{
Customize: cust,
Version: version,
}); err != nil {
http.Error(w, errors.Wrap(err, "executing template").Error(), http.StatusInternalServerError)

View file

@ -9,7 +9,16 @@
href="#"
@click="newSecret"
>
<i class="fas fa-user-secret" /> OTS - One Time Secrets
<i
v-if="!customize.appIcon"
class="fas fa-user-secret mr-1"
/>
<img
v-else
class="mr-1"
:src="customize.appIcon"
>
<span v-if="!customize.disableAppTitle">{{ customize.appTitle || 'OTS - One Time Secrets' }}</span>
</b-navbar-brand>
<b-navbar-toggle target="nav-collapse" />
@ -25,7 +34,10 @@
<b-nav-item @click="newSecret">
<i class="fas fa-plus" /> {{ $t('btn-new-secret') }}
</b-nav-item>
<b-nav-form class="ml-2">
<b-nav-form
v-if="!customize.disableThemeSwitcher"
class="ml-2"
>
<b-form-checkbox
v-model="darkTheme"
switch
@ -156,7 +168,10 @@
</b-col>
</b-row>
<b-row class="mt-5">
<b-row
v-if="!customize.disablePoweredBy"
class="mt-5"
>
<b-col class="footer">
{{ $t('text-powered-by') }} <a href="https://github.com/Luzifer/ots"><i class="fab fa-github" /> OTS</a> {{ $root.version }}
</b-col>
@ -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()

View file

@ -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'

View file

@ -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 {

View file

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