rewrite/go-templ #1
@@ -43,6 +43,11 @@ tmp_dir = "tmp"
|
|||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = false
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[env]
|
||||||
|
TEST_PW = "test"
|
||||||
|
VALKEY_ADDR = "127.0.0.1:6379"
|
||||||
|
VALKEY_PASSWORD = "valkey"
|
||||||
|
|
||||||
[screen]
|
[screen]
|
||||||
clear_on_rebuild = false
|
clear_on_rebuild = false
|
||||||
keep_scroll = true
|
keep_scroll = true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
/tmp
|
||||||
/build
|
/build
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/package
|
/package
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: valkey-server --requirepass "valkey"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "valkey-cli", "-a", "valkey", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
@@ -4,7 +4,11 @@ go 1.25.7
|
|||||||
|
|
||||||
tool github.com/a-h/templ/cmd/templ
|
tool github.com/a-h/templ/cmd/templ
|
||||||
|
|
||||||
require github.com/a-h/templ v0.3.977
|
require (
|
||||||
|
github.com/a-h/templ v0.3.977
|
||||||
|
github.com/studio-b12/gowebdav v0.12.0
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
@@ -13,14 +17,12 @@ require (
|
|||||||
github.com/cli/browser v1.3.0 // indirect
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/natefinch/atomic v1.0.1 // indirect
|
github.com/natefinch/atomic v1.0.1 // indirect
|
||||||
github.com/studio-b12/gowebdav v0.12.0 // indirect
|
|
||||||
golang.org/x/mod v0.26.0 // indirect
|
golang.org/x/mod v0.26.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/tools v0.35.0 // indirect
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
|||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
|
||||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
@@ -25,22 +23,30 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||||
|
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||||
|
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
||||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE=
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotAnImage = errors.New("not an image")
|
||||||
|
|
||||||
|
func (s *Service) getMime(path string) (mimeType string, err error) {
|
||||||
|
info, err := s.fs.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("image file info could not be fetched")
|
||||||
|
}
|
||||||
|
|
||||||
|
if mimer, ok := info.(interface{ ContentType() string }); ok {
|
||||||
|
mimeType = mimer.ContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetImage(ctx context.Context, uid string) (img io.Reader, mimeType string, err error) {
|
||||||
|
path, err := PathFromUID(uid)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("could not get path: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeType, err = s.getMime(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(mimeType, "image") {
|
||||||
|
err = ErrNotAnImage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := s.fs.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("image file could not be fetched")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewBuffer(file), mimeType, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
fs fileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fs fileSystem) *Service {
|
||||||
|
return &Service{
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSystem interface {
|
||||||
|
Read(path string) ([]byte, error)
|
||||||
|
Stat(path string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
"github.com/studio-b12/gowebdav"
|
"github.com/studio-b12/gowebdav"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,25 +35,28 @@ func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHead
|
|||||||
|
|
||||||
lang := translate.Language(ctx)
|
lang := translate.Language(ctx)
|
||||||
|
|
||||||
var coverImage *gowebdav.File
|
var coverImage filesystem.FileInfo
|
||||||
var contentMD *gowebdav.File
|
var contentMD filesystem.FileInfo
|
||||||
|
|
||||||
for _, f := range info {
|
for _, f := range info {
|
||||||
file := f.(gowebdav.File)
|
file, ok := f.(filesystem.FileInfo)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(file.ContentType(), "image/") {
|
if strings.HasPrefix(file.ContentType(), "image/") {
|
||||||
coverImage = &file
|
coverImage = file
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(file.Name(), ".")
|
parts := strings.Split(file.Name(), ".")
|
||||||
|
|
||||||
// fallback content
|
// fallback content
|
||||||
if contentMD == nil && len(parts) == 2 && parts[1] == "md" {
|
if contentMD == nil && len(parts) == 2 && parts[1] == "md" {
|
||||||
contentMD = &file
|
contentMD = file
|
||||||
}
|
}
|
||||||
// language content
|
// language content
|
||||||
if len(parts) == 3 && parts[1] == lang && parts[2] == "md" {
|
if len(parts) == 3 && parts[1] == lang && parts[2] == "md" {
|
||||||
contentMD = &file
|
contentMD = file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +75,9 @@ func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHead
|
|||||||
|
|
||||||
page.UID = uid
|
page.UID = uid
|
||||||
page.Title = title[1]
|
page.Title = title[1]
|
||||||
|
if coverImage != nil {
|
||||||
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
|
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package page
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/studio-b12/gowebdav"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
fs *gowebdav.Client
|
fs filesystem.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(fs *gowebdav.Client) *Service {
|
func New(fs filesystem.FS) *Service {
|
||||||
info, err := fs.ReadDir("/")
|
info, err := fs.ReadDir("/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("could not read dir", "err", err)
|
slog.Error("could not read dir", "err", err)
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CachedClient wraps a FileSystem implementation with Valkey caching.
|
||||||
|
type CachedClient struct {
|
||||||
|
impl FS
|
||||||
|
client valkey.Client
|
||||||
|
refreshTime time.Duration
|
||||||
|
hits atomic.Int64
|
||||||
|
misses atomic.Int64
|
||||||
|
readDirHits atomic.Int64
|
||||||
|
readHits atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCachedClient creates a new CachedClient.
|
||||||
|
func NewCachedClient(impl FS, client valkey.Client, ttl time.Duration) *CachedClient {
|
||||||
|
return &CachedClient{
|
||||||
|
impl: impl,
|
||||||
|
client: client,
|
||||||
|
refreshTime: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
|
||||||
|
key := fmt.Sprintf("webdav:readdir:%s", path)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).ToString()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
var cached []File
|
||||||
|
if err := json.Unmarshal([]byte(val), &cached); err == nil {
|
||||||
|
c.readDirHits.Add(1)
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
infos := make([]os.FileInfo, len(cached))
|
||||||
|
for i, f := range cached {
|
||||||
|
infos[i] = f
|
||||||
|
}
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
infos, err := c.impl.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
cached := make([]File, 0, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
// Type assert to ensure we capture ContentType and Path
|
||||||
|
if fi, ok := info.(FileInfo); ok {
|
||||||
|
cached = append(cached, File{
|
||||||
|
name: fi.Name(),
|
||||||
|
size: fi.Size(),
|
||||||
|
mode: fi.Mode(),
|
||||||
|
modTime: fi.ModTime(),
|
||||||
|
isDir: fi.IsDir(),
|
||||||
|
contentType: fi.ContentType(),
|
||||||
|
path: fi.Path(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback if underlying impl returns generic os.FileInfo
|
||||||
|
cached = append(cached, File{
|
||||||
|
name: info.Name(),
|
||||||
|
size: info.Size(),
|
||||||
|
mode: info.Mode(),
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
isDir: info.IsDir(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(cached)
|
||||||
|
if err == nil {
|
||||||
|
c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.refreshTime).Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) Read(path string) ([]byte, error) {
|
||||||
|
key := fmt.Sprintf("webdav:read:%s", path)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).AsBytes()
|
||||||
|
if err == nil && len(val) > 0 {
|
||||||
|
c.readHits.Add(1)
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
data, err := c.impl.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
c.client.Do(ctx, c.client.B().Set().Key(key).Value(valkey.BinaryString(data)).Ex(c.refreshTime).Build())
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
|
||||||
|
key := fmt.Sprintf("webdav:stat:%s", path)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).ToString()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
if err := json.Unmarshal([]byte(val), &info); err == nil {
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
fileInfo, err := c.impl.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
// Type assert to ensure we capture ContentType and Path
|
||||||
|
info = ParseToFile(fileInfo)
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(info)
|
||||||
|
if err == nil {
|
||||||
|
c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.refreshTime).Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) Invalidate(path string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
c.client.Do(ctx, c.client.B().Del().Key(fmt.Sprintf("webdav:readdir:%s", path)).Key(fmt.Sprintf("webdav:read:%s", path)).Build())
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCachedFile_Serialization(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second) // JSON marshal of time might lose precision
|
||||||
|
cf := File{
|
||||||
|
name: "test.md",
|
||||||
|
size: 123,
|
||||||
|
mode: 0o644,
|
||||||
|
modTime: now,
|
||||||
|
isDir: false,
|
||||||
|
contentType: "text/markdown",
|
||||||
|
path: "/test.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cf2 File
|
||||||
|
if err := json.Unmarshal(data, &cf2); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.name != cf2.name {
|
||||||
|
t.Errorf("Name mismatch: %s != %s", cf.name, cf2.name)
|
||||||
|
}
|
||||||
|
if cf.size != cf2.size {
|
||||||
|
t.Errorf("Size mismatch: %d != %d", cf.size, cf2.size)
|
||||||
|
}
|
||||||
|
if cf.mode != cf2.mode {
|
||||||
|
t.Errorf("Mode mismatch: %v != %v", cf.mode, cf2.mode)
|
||||||
|
}
|
||||||
|
if !cf.modTime.Equal(cf2.modTime) {
|
||||||
|
t.Errorf("ModTime mismatch: %v != %v", cf.modTime, cf2.modTime)
|
||||||
|
}
|
||||||
|
if cf.isDir != cf2.isDir {
|
||||||
|
t.Errorf("IsDir mismatch: %v != %v", cf.isDir, cf2.isDir)
|
||||||
|
}
|
||||||
|
if cf.contentType != cf2.contentType {
|
||||||
|
t.Errorf("ContentType mismatch: %s != %s", cf.contentType, cf2.contentType)
|
||||||
|
}
|
||||||
|
if cf.path != cf2.path {
|
||||||
|
t.Errorf("Path mismatch: %s != %s", cf.path, cf2.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedFile_Interface(t *testing.T) {
|
||||||
|
// Verify CachedFile implements os.FileInfo
|
||||||
|
var _ os.FileInfo = File{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
ReadDir(path string) ([]os.FileInfo, error)
|
||||||
|
Read(path string) ([]byte, error)
|
||||||
|
Stat(path string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileInfo interface {
|
||||||
|
os.FileInfo
|
||||||
|
ContentType() string
|
||||||
|
Path() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// File implements FileInfo
|
||||||
|
type File struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
contentType string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f File) Name() string { return f.name }
|
||||||
|
func (f File) Size() int64 { return f.size }
|
||||||
|
func (f File) Mode() os.FileMode { return f.mode }
|
||||||
|
func (f File) ModTime() time.Time { return f.modTime }
|
||||||
|
func (f File) IsDir() bool { return f.isDir }
|
||||||
|
func (f File) Sys() any { return nil }
|
||||||
|
func (f File) ContentType() string { return f.contentType }
|
||||||
|
func (f File) Path() string { return f.path }
|
||||||
|
|
||||||
|
type serializableFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mode os.FileMode `json:"mode"`
|
||||||
|
ModTime time.Time `json:"modTime"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler
|
||||||
|
func (f File) MarshalJSON() ([]byte, error) {
|
||||||
|
// Create a local shadow struct with exported fields for the JSON package
|
||||||
|
return json.Marshal(serializableFile{
|
||||||
|
Name: f.name,
|
||||||
|
Size: f.size,
|
||||||
|
Mode: f.mode,
|
||||||
|
ModTime: f.modTime,
|
||||||
|
IsDir: f.isDir,
|
||||||
|
ContentType: f.contentType,
|
||||||
|
Path: f.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler
|
||||||
|
func (f *File) UnmarshalJSON(data []byte) error {
|
||||||
|
// Define a shadow struct to capture the incoming JSON
|
||||||
|
shadow := serializableFile{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &shadow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer values to the actual struct
|
||||||
|
f.name = shadow.Name
|
||||||
|
f.size = shadow.Size
|
||||||
|
f.mode = shadow.Mode
|
||||||
|
f.modTime = shadow.ModTime
|
||||||
|
f.isDir = shadow.IsDir
|
||||||
|
f.contentType = shadow.ContentType
|
||||||
|
f.path = shadow.Path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToFile(file os.FileInfo) File {
|
||||||
|
if fi, ok := file.(FileInfo); ok {
|
||||||
|
return File{
|
||||||
|
name: fi.Name(),
|
||||||
|
size: fi.Size(),
|
||||||
|
mode: fi.Mode(),
|
||||||
|
modTime: fi.ModTime(),
|
||||||
|
isDir: fi.IsDir(),
|
||||||
|
contentType: fi.ContentType(),
|
||||||
|
path: fi.Path(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if underlying impl returns generic os.FileInfo
|
||||||
|
return File{
|
||||||
|
name: file.Name(),
|
||||||
|
size: file.Size(),
|
||||||
|
mode: file.Mode(),
|
||||||
|
modTime: file.ModTime(),
|
||||||
|
isDir: file.IsDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package handleimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := r.PathValue("uid")
|
||||||
|
|
||||||
|
img, mime, err := h.img.GetImage(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error wile serving image", "err", err, "uid", uid)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Add("Content-Type", mime)
|
||||||
|
io.Copy(w, img)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package handleimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
img imageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(mux *http.ServeMux, srv imageService) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
img: srv,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /images/{uid}", h.getImage)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageService interface {
|
||||||
|
GetImage(ctx context.Context, uid string) (img io.Reader, mimeType string, err error)
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/handlehome"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/handlehome"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/handleimage"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
|
||||||
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
|
||||||
"github.com/studio-b12/gowebdav"
|
"github.com/studio-b12/gowebdav"
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) RegisterRoutes() (h http.Handler) {
|
func (s *Server) RegisterRoutes() (h http.Handler) {
|
||||||
@@ -34,9 +38,24 @@ func registerOther() (h http.Handler) {
|
|||||||
slog.Error("could not connect webdavClient", "err", err, "pw", os.Getenv("TEST_PW"))
|
slog.Error("could not connect webdavClient", "err", err, "pw", os.Getenv("TEST_PW"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pager := page.New(webdavClient)
|
var fs filesystem.FS = webdavClient
|
||||||
|
|
||||||
_ = handlehome.New(mux, pager)
|
valkeyAddr := os.Getenv("VALKEY_ADDR")
|
||||||
|
if valkeyAddr == "" {
|
||||||
|
valkeyAddr = "127.0.0.1:6379"
|
||||||
|
}
|
||||||
|
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{valkeyAddr},
|
||||||
|
Password: os.Getenv("VALKEY_PASSWORD"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create valkey client", "err", err)
|
||||||
|
} else {
|
||||||
|
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = handlehome.New(mux, page.New(fs))
|
||||||
|
_ = handleimage.New(mux, images.New(fs))
|
||||||
|
|
||||||
mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS())))
|
mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS())))
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func cacheMiddleware(maxAge time.Duration) func(next http.Handler) http.Handler
|
|||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
|
||||||
|
w.Header().Set("Vary", "HX-Request,Accept-Language,Accept-Encoding")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
templ Home(pages []page.PageHeader) {
|
templ Home(pages []page.PageHeader) {
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
@components.ImageTile("https://picsum.photos/" + page.CoverImageUID) {
|
@components.ImageTile("/images/" + page.CoverImageUID) {
|
||||||
<a class="flex items-center justify-center h-full " href={ "/" + page.UID } preload="mouseover">
|
<a class="flex items-center justify-center h-full " href={ "/" + page.UID } preload="mouseover">
|
||||||
<h2
|
<h2
|
||||||
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
|
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func Home(pages []page.PageHeader) templ.Component {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
templ_7745c5c3_Err = components.ImageTile("https://picsum.photos/"+page.CoverImageUID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = components.ImageTile("/images/"+page.CoverImageUID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user