Skip to content

Commit

Permalink
allow for user-defined translations
Browse files Browse the repository at this point in the history
with simple key-value JSON files read from a configurable directory
  • Loading branch information
jantari committed Feb 10, 2024
1 parent b0acf98 commit 09a6216
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 9 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

# Other
/store.db
/translations/*
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/google/uuid v1.5.0
github.com/peterbourgon/ff/v3 v3.4.0
go.etcd.io/bbolt v1.3.8
golang.org/x/text v0.14.0
)

require golang.org/x/sys v0.8.0 // indirect
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
169 changes: 163 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package main
import (
"fmt"
"os"
"io/fs"
"flag"
"time"
"embed"
"errors"
"strings"
"log/slog"
"path/filepath"

"html/template"
"net/http"
Expand All @@ -26,6 +29,11 @@ import (

"secretlinks/logging"
"secretlinks/cryptopasta"

// Localization of the little UI we have
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
)

// API payload of incoming request (create new secret)
Expand Down Expand Up @@ -63,13 +71,21 @@ var db *bolt.DB
//go:embed templates/*
var embeddedTemplates embed.FS

var viewPage *template.Template
var clickthroughPage *template.Template
var localizedViewPage map[language.Tag]*template.Template
var localizedClickPage map[language.Tag]*template.Template

// Make sure the undefined ('und') language is the first in the list,
// so that NewMatcher uses it as its fallback option.
var serverLangs = []language.Tag{
language.Und,
}
var translationCatalog catalog.Catalog

func main() {
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
var versionFlagPtr = fs.Bool("version", false, "Print the version information and exit")
var listenAddrPtr = fs.String("listen", "localhost:8080", "The address and port for the webserver to listen on")
var translationPtr = fs.String("translationPath", "./translations", "Directory with optional JSON translation files")
var logLevelPtr = fs.String("logLevel", "info", "Set log verbosity: error, warn, info or debug")
var dbfilePtr = fs.String("dbfile", "./store.db", "Path to the Bolt database file to store secrets")

Expand Down Expand Up @@ -102,8 +118,24 @@ func main() {
go backgroundScan(expiredSecrets)
go backgroundPrune(expiredSecrets)

viewPage = template.Must(template.ParseFS(embeddedTemplates, "templates/view.html"))
clickthroughPage = template.Must(template.ParseFS(embeddedTemplates, "templates/reveal.html"))
var additionalServerLangs []language.Tag
additionalServerLangs, translationCatalog = loadTranslations(*translationPtr)
serverLangs = append(serverLangs, additionalServerLangs...)

// Prepare localized page templates for every supported language
localizedViewPage = map[language.Tag]*template.Template{}
for _, lang := range serverLangs {
localizedViewPage[lang] = template.Must(template.New("view.html").Funcs(template.FuncMap{
"translate": translatePageSnippetFL(lang),
}).ParseFS(embeddedTemplates, "templates/view.html"))
}

localizedClickPage = map[language.Tag]*template.Template{}
for _, lang := range serverLangs {
localizedClickPage[lang] = template.Must(template.New("reveal.html").Funcs(template.FuncMap{
"translate": translatePageSnippetFL(lang),
}).ParseFS(embeddedTemplates, "templates/reveal.html"))
}

// DB setup
db, err = bolt.Open(*dbfilePtr, 0600, &bolt.Options{Timeout: 10 * time.Second})
Expand Down Expand Up @@ -147,6 +179,14 @@ func viewPageHandler(w http.ResponseWriter, r *http.Request) {
return
}

accept := r.Header.Get("Accept-Language")
// NewMatcher returns the first element in the list in case no match is found,
// so we want to make sure the first element is the undefiend language as that's
// set up to handle all fallback scenarios.
matcher := language.NewMatcher(serverLangs)
tag, _ := language.MatchStrings(matcher, accept)
logging.Logger.Debug("request language preference", slog.String("header", accept), slog.Any("decision", tag))

id := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
keyBytes, err := base64.URLEncoding.DecodeString(key)
Expand All @@ -173,6 +213,9 @@ func viewPageHandler(w http.ResponseWriter, r *http.Request) {
return
}

// Fallback in case nothing matches
var localizedTemplateToRender *template.Template

// When clickthrough is not enabled or request is POST, show secret immediately.
// A POST request is triggered by the "reveal" button on the clickthrough page.
if !retrievedSecret.ClickThrough || r.Method == http.MethodPost {
Expand All @@ -187,16 +230,43 @@ func viewPageHandler(w http.ResponseWriter, r *http.Request) {
return
}

viewPage.Execute(w, struct{
// Finding a best-match betweeen two language.Tag. This is how go itself does it:
// https://cs.opensource.google/go/x/text/+/refs/tags/v0.14.0:message/catalog/catalog.go;l=231
for ; ; tag = tag.Parent() {
var ok bool
localizedTemplateToRender, ok = localizedViewPage[tag]
if ok || tag == language.Und {
break
}
}

err = localizedTemplateToRender.Execute(w, struct{
Secret string
Views int
} {
Secret: decryptedSecret,
Views: retrievedSecret.Views,
})
if err != nil {
logging.Logger.Error("could not template response", slog.Any("error", err))
}
} else {
// Clickthrough enabled, return page with button to retrieve (additional request)
clickthroughPage.Execute(w, nil)

// Finding a best-match betweeen two language.Tag. This is how go itself does it:
// https://cs.opensource.google/go/x/text/+/refs/tags/v0.14.0:message/catalog/catalog.go;l=231
for ; ; tag = tag.Parent() {
var ok bool
localizedTemplateToRender, ok = localizedClickPage[tag]
if ok || tag == language.Und {
break
}
}

err = localizedTemplateToRender.Execute(w, nil)
if err != nil {
logging.Logger.Error("could not template response", slog.Any("error", err))
}
}
} else {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
Expand Down Expand Up @@ -337,3 +407,90 @@ func backgroundPrune(channel chan []byte) {
}
}
}

// Implements catalog.Dictionary{} interface
type myOwnDictionary struct {
Data map[string]string
}
func (d *myOwnDictionary) Lookup(key string) (data string, ok bool) {
if value, ok := d.Data[key]; ok {
return "\x02" + value, true
}
return "", false
}

// translatePageSnippet - fixed Language
func translatePageSnippetFL(tag language.Tag) func(msg string, a ...interface{}) string {
return func(msg string, a ...interface{}) string {
return message.NewPrinter(tag, message.Catalog(translationCatalog)).Sprintf(msg, a...)
}
}

func loadTranslations(translationsPath string) ([]language.Tag, catalog.Catalog) {
languages := []language.Tag{}
translations := map[string]catalog.Dictionary{}
// These hardcoded translations are assigned to the und / undefined language
// because the 'und' language is the parent of all language Tags, at the root of the tree.
// When a Dictionary has a language but cannot find a specific key-value translation, it will
// look up the key in all the parent languages (aka if a key is missing/not found in it).
// this means by defining translations for the 'und' language these failing lookups will
// all eventually fallback to this.
// For languages that aren't in the dictionary to begin with, we can explicitly specify
// a fallback language, but to cover both cases we just set that to the 'und' language as well.
translations["und"] = &myOwnDictionary{
Data: map[string]string{
"msg_reveal": "Reveal",
"msg_copy": "COPY",
"msg_views_remaining": "%d more views left.",
},
}

files, err := os.ReadDir(translationsPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
logging.Logger.Warn("could not read (all) translation files", slog.Any("error", err))
}

for _, file := range files {
if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
fileNameWithoutExtension := strings.TrimSuffix(file.Name(), ".json")
translationLanguage, err := language.Parse(fileNameWithoutExtension)
if err != nil {
logging.Logger.Error("filename is not a recognized language tag", slog.String("file", file.Name()), slog.Any("error", err))
continue
}

fileFullPath := filepath.Join(translationsPath, file.Name())
translationFile, err := os.ReadFile(fileFullPath)
if err != nil {
logging.Logger.Error("file could not be read", slog.String("file", file.Name()), slog.Any("error", err))
continue
}

translation := map[string]string{}
err = json.Unmarshal(translationFile, &translation)
if err != nil {
logging.Logger.Error("file content could not be parsed", slog.String("file", file.Name()), slog.Any("error", err))
continue
}

languages = append(languages, translationLanguage)
translations[fileNameWithoutExtension] = &myOwnDictionary{
Data: translation,
}

logging.Logger.Debug("translation loaded", slog.String("file", file.Name()), slog.Any("language", translationLanguage))
}
}

c, err := catalog.NewFromMap(
translations,
catalog.Fallback(language.Und), // Und/Undefined is the default fallback but we'll be explicit
)
if err != nil {
logging.Logger.Error("error creating translation catalog", slog.Any("error", err))
os.Exit(1)
}

return languages, c
}

2 changes: 1 addition & 1 deletion templates/reveal.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
<body>
<div style="font-size: 32px; padding: 3rem;" class="center">
<div class="button" onclick="clickThrough()">
Reveal
{{ translate "msg_reveal" }}
</div>
</div>
</body>
Expand Down
4 changes: 2 additions & 2 deletions templates/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@
{{- .Secret -}}
</div>
<div style="margin-top: 3rem; user-select: none;" class="center button" onclick="copySecretToClipboard()">
COPY📋
{{- translate "msg_copy" -}}📋
</div>
</div>
<div style="font-size: 16px;" class="center footer">
{{ .Views }} more views left.
{{ translate "msg_views_remaining" .Views }}
</div>
</body>
</html>

0 comments on commit 09a6216

Please sign in to comment.