feat: images

This commit is contained in:
2026-03-02 22:38:01 +01:00
parent e689ab08c9
commit 3cc632e358
19 changed files with 517 additions and 28 deletions
+5
View File
@@ -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
View File
@@ -1,5 +1,6 @@
.DS_Store .DS_Store
node_modules node_modules
/tmp
/build /build
/.svelte-kit /.svelte-kit
/package /package
+12
View File
@@ -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
+7 -5
View File
@@ -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 -8
View File
@@ -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=
+49
View File
@@ -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
}
+18
View File
@@ -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)
}
+12 -6
View File
@@ -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 -3
View File
@@ -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)
+162
View File
@@ -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())
}
+58
View File
@@ -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{}
}
+106
View 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(),
}
}
}
+20
View File
@@ -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)
}
+24
View File
@@ -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)
}
+21 -2
View File
@@ -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())))
+1
View File
@@ -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)
}) })
} }
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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