mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 06:42:23 -05:00
The `service.allowiconchanges` config option was ignored. On the web ui the value injected into index.html by the api was immediately overwritten by a hardcoded `window.ALLOW_ICON_CHANGES = true` in a later inline script, so the configured value never took effect. The desktop app never received the injected value at all, since it serves the bundled frontend from its own local server and only talks to the api for data. Expose the option via the /info endpoint and read it from the config store, which is the only channel that reaches both the web ui and the desktop app. The brittle window injection and its hardcoded default are removed in favor of this single source of truth. https://claude.ai/code/session_01HAXTJNsDcfsB4hwDNKTECb
295 lines
7.7 KiB
Go
295 lines
7.7 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package routes
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
|
|
"code.vikunja.io/api/frontend"
|
|
"code.vikunja.io/api/pkg/config"
|
|
|
|
etaggenerator "github.com/hhsnopek/etag"
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/labstack/echo/v5/middleware"
|
|
)
|
|
|
|
const (
|
|
indexFile = `index.html`
|
|
rootPath = `dist/`
|
|
cacheControlMax = `max-age=315360000, public, max-age=31536000, s-maxage=31536000, immutable`
|
|
cacheControlNone = `public, max-age=0, s-maxage=0, must-revalidate`
|
|
configScriptTagTemplate = `
|
|
<script>
|
|
window.SENTRY_ENABLED = {{ .SENTRY_ENABLED }}
|
|
window.SENTRY_DSN = '{{ .SENTRY_DSN }}'
|
|
window.CUSTOM_LOGO_URL = '{{ .CUSTOM_LOGO_URL }}'
|
|
window.CUSTOM_LOGO_URL_DARK = '{{ .CUSTOM_LOGO_URL_DARK }}'
|
|
</script>`
|
|
)
|
|
|
|
// Because the files are embedded into the final binary, we can be absolutely sure the etag will never change
|
|
// and we can cache its generation pretty heavily.
|
|
var etagCache map[string]string
|
|
var etagLock sync.Mutex
|
|
var scriptConfigString string
|
|
var scriptConfigStringLock sync.Mutex
|
|
|
|
func init() {
|
|
etagCache = make(map[string]string)
|
|
etagLock = sync.Mutex{}
|
|
scriptConfigStringLock = sync.Mutex{}
|
|
}
|
|
|
|
func serveIndexFile(c *echo.Context, assetFs http.FileSystem) (err error) {
|
|
index, err := assetFs.Open(path.Join(rootPath, indexFile))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer index.Close()
|
|
|
|
if scriptConfigString == "" {
|
|
|
|
scriptConfigStringLock.Lock()
|
|
defer scriptConfigStringLock.Unlock()
|
|
|
|
// replace config variables
|
|
tmpl, err := template.New("config").Parse(configScriptTagTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var tplOutput bytes.Buffer
|
|
data := make(map[string]string)
|
|
|
|
data["SENTRY_ENABLED"] = "false"
|
|
if config.SentryFrontendEnabled.GetBool() {
|
|
data["SENTRY_ENABLED"] = "true"
|
|
}
|
|
data["SENTRY_DSN"] = config.SentryFrontendDsn.GetString()
|
|
data["CUSTOM_LOGO_URL"] = config.ServiceCustomLogoURL.GetString()
|
|
data["CUSTOM_LOGO_URL_DARK"] = config.ServiceCustomLogoURLDark.GetString()
|
|
|
|
err = tmpl.Execute(&tplOutput, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scriptConfig := tplOutput.String()
|
|
|
|
buf := bytes.Buffer{}
|
|
_, err = buf.ReadFrom(index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
scriptConfigString = strings.ReplaceAll(buf.String(), `<div id="app"></div>`, `<div id="app"></div>`+scriptConfig)
|
|
|
|
publicURL := config.ServicePublicURL.GetString()
|
|
if publicURL == "" {
|
|
publicURL = "/"
|
|
}
|
|
|
|
scriptConfigString = strings.ReplaceAll(scriptConfigString, "'/api/v1'", "'"+publicURL+"api/v1'")
|
|
}
|
|
|
|
reader := strings.NewReader(scriptConfigString)
|
|
|
|
info, err := index.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
//etag, err := generateEtag(index, info.Name())
|
|
//if err != nil {
|
|
// return err
|
|
//}
|
|
return serveFile(c, reader, info, "")
|
|
}
|
|
|
|
// Copied from echo's middleware.StaticWithConfig simplified and adjusted for caching
|
|
func static() echo.MiddlewareFunc {
|
|
assetFs := http.FS(frontend.Files)
|
|
|
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c *echo.Context) (err error) {
|
|
p := c.Request().URL.Path
|
|
if strings.HasPrefix(p, "/api/") {
|
|
return next(c)
|
|
}
|
|
if strings.HasSuffix(c.Path(), "*") { // When serving from a group, e.g. `/static*`.
|
|
p = c.Param("*")
|
|
}
|
|
p, err = url.PathUnescape(p)
|
|
if err != nil {
|
|
return
|
|
}
|
|
name := path.Join(rootPath, path.Clean("/"+p)) // "/"+ for security
|
|
|
|
file, err := assetFs.Open(name)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
// file with that path did not exist, so we continue down in middleware/handler chain, hoping that we end up in
|
|
// handler that is meant to handle this request
|
|
if err = next(c); err == nil {
|
|
return err
|
|
}
|
|
|
|
var he echo.HTTPStatusCoder
|
|
if !errors.As(err, &he) || he.StatusCode() != http.StatusNotFound {
|
|
return err
|
|
}
|
|
|
|
// Handle all other requests with the index file
|
|
return serveIndexFile(c, assetFs)
|
|
}
|
|
|
|
defer file.Close()
|
|
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return serveIndexFile(c, assetFs)
|
|
}
|
|
|
|
etag, err := generateEtag(file, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return serveFile(c, file, info, etag)
|
|
}
|
|
}
|
|
}
|
|
|
|
func generateEtag(file http.File, name string) (etag string, err error) {
|
|
etagLock.Lock()
|
|
defer etagLock.Unlock()
|
|
etag, has := etagCache[name]
|
|
if !has {
|
|
buf := bytes.Buffer{}
|
|
_, err = buf.ReadFrom(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
etag = etaggenerator.Generate(buf.Bytes(), true)
|
|
etagCache[name] = etag
|
|
}
|
|
|
|
return etag, nil
|
|
}
|
|
|
|
// copied from http.serveContent
|
|
func getMimeType(name string, file io.ReadSeeker) (mineType string, err error) {
|
|
mineType = mime.TypeByExtension(filepath.Ext(name))
|
|
if mineType == "" {
|
|
// read a chunk to decide between utf-8 text and binary
|
|
var buf [512]byte
|
|
n, _ := io.ReadFull(file, buf[:])
|
|
mineType = http.DetectContentType(buf[:n])
|
|
_, err := file.Seek(0, io.SeekStart) // rewind to output whole file
|
|
if err != nil {
|
|
return "", fmt.Errorf("seeker can't seek")
|
|
}
|
|
}
|
|
|
|
return mineType, nil
|
|
}
|
|
|
|
func getCacheControlHeader(info os.FileInfo, file io.ReadSeeker) (header string, err error) {
|
|
// Don't cache service worker and related files
|
|
if info.Name() == "robots.txt" ||
|
|
info.Name() == "sw.js" ||
|
|
info.Name() == "manifest.webmanifest" {
|
|
return cacheControlNone, nil
|
|
}
|
|
|
|
if strings.HasPrefix(info.Name(), "workbox-") {
|
|
return cacheControlMax, nil
|
|
}
|
|
|
|
contentType, err := getMimeType(info.Name(), file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Cache everything looking like an asset
|
|
if strings.HasPrefix(contentType, "image/") ||
|
|
strings.HasPrefix(contentType, "font/") ||
|
|
strings.HasPrefix(contentType, "~images/") ||
|
|
strings.HasPrefix(contentType, "~font/") ||
|
|
contentType == "text/css" ||
|
|
contentType == "application/javascript" ||
|
|
contentType == "text/javascript" ||
|
|
contentType == "application/vnd.ms-fontobject" ||
|
|
contentType == "application/x-font-ttf" ||
|
|
contentType == "font/opentype" ||
|
|
contentType == "font/woff2" ||
|
|
contentType == "image/svg+xml" ||
|
|
contentType == "image/x-icon" ||
|
|
contentType == "audio/wav" {
|
|
return cacheControlMax, nil
|
|
}
|
|
|
|
return cacheControlNone, nil
|
|
}
|
|
|
|
func serveFile(c *echo.Context, file io.ReadSeeker, info os.FileInfo, etag string) error {
|
|
|
|
c.Response().Header().Set("Server", "Vikunja")
|
|
c.Response().Header().Set("Vary", "Accept-Encoding")
|
|
if etag != "" {
|
|
c.Response().Header().Set("Etag", etag)
|
|
}
|
|
|
|
cacheControl, err := getCacheControlHeader(info, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Response().Header().Set("Cache-Control", cacheControl)
|
|
|
|
http.ServeContent(c.Response(), c.Request(), info.Name(), info.ModTime(), file)
|
|
return nil
|
|
}
|
|
|
|
func setupStaticFrontendFilesHandler(e *echo.Echo) {
|
|
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
|
Level: 6,
|
|
MinLength: 256,
|
|
Skipper: func(c *echo.Context) bool {
|
|
return strings.HasPrefix(c.Path(), "/api/")
|
|
},
|
|
}))
|
|
|
|
e.Use(static())
|
|
}
|