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
+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)
}
+13 -7
View File
@@ -13,6 +13,7 @@ import (
"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/filesystem"
"github.com/studio-b12/gowebdav"
)
@@ -34,25 +35,28 @@ func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHead
lang := translate.Language(ctx)
var coverImage *gowebdav.File
var contentMD *gowebdav.File
var coverImage filesystem.FileInfo
var contentMD filesystem.FileInfo
for _, f := range info {
file := f.(gowebdav.File)
file, ok := f.(filesystem.FileInfo)
if !ok {
continue
}
if strings.HasPrefix(file.ContentType(), "image/") {
coverImage = &file
coverImage = file
}
parts := strings.Split(file.Name(), ".")
// fallback content
if contentMD == nil && len(parts) == 2 && parts[1] == "md" {
contentMD = &file
contentMD = file
}
// language content
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.Title = title[1]
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
if coverImage != nil {
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
}
return
}
+3 -3
View File
@@ -3,14 +3,14 @@ package page
import (
"log/slog"
"github.com/studio-b12/gowebdav"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
)
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("/")
if err != nil {
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"
"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/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/handleimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
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"))
}
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())))
+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 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("Vary", "HX-Request,Accept-Language,Accept-Encoding")
next.ServeHTTP(w, r)
})
}