Implement frontend customizations
refs #71 Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
d3ca12fa35
commit
128ce071cb
12 changed files with 198 additions and 53 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -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
|
||||
|
|
39
README.md
39
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.
|
||||
|
|
|
@ -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
51
customize.go
Normal 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")
|
||||
}
|
|
@ -49,6 +49,7 @@
|
|||
|
||||
// Template variable from Golang process
|
||||
const version = "{{ .Version }}"
|
||||
window.OTSCustomize = JSON.parse('{{ .Customize.ToJSON }}')
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
2
go.mod
2
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
|
||||
|
|
4
go.sum
4
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=
|
||||
|
|
119
main.go
119
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
|
||||
|
|
23
src/app.vue
23
src/app.vue
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue