32 Commits
Author SHA1 Message Date
schreifuchs c678c7aa45 fix(gallery): use correct aspect ratios
/ publish (push) Successful in 3m48s
2026-06-08 11:18:44 +02:00
schreifuchs 763163c24e feat: image gallery (#2)
/ publish (push) Successful in 4m4s
Reviewed-on: #2
2026-06-01 14:07:50 +02:00
schreifuchs ee3eba3cd9 feat(images): cache lifetime in config
/ publish (push) Successful in 3m43s
2026-03-19 22:10:20 +01:00
schreifuchs b384c70c39 feat: preload images 2026-03-19 22:07:02 +01:00
schreifuchs 1c67eb0cff chore: update readme 2026-03-19 18:30:42 +01:00
schreifuchs 16e5107185 feat: title on pages 2026-03-19 18:27:23 +01:00
schreifuchs 2b07701762 feat: better caching headers for images 2026-03-09 13:35:23 +01:00
schreifuchs a27b4a66e6 chore: use json logger 2026-03-09 09:23:31 +01:00
schreifuchs bc900e1450 chore: use better scaling kernel 2026-03-09 08:41:52 +01:00
schreifuchs 1310b7b243 feat: add umami
/ publish (push) Failing after 2m14s
2026-03-08 16:11:57 +01:00
schreifuchs 01b24f830f chore: better resource loading
/ publish (push) Successful in 9m23s
2026-03-08 01:05:57 +01:00
schreifuchs eab7eb3879 chore: better config
/ publish (push) Successful in 3m1s
2026-03-07 20:55:04 +01:00
schreifuchs fca1030e1a feat: limit image request
/ publish (push) Successful in 3m9s
2026-03-07 20:26:53 +01:00
schreifuchs 2f7ff28c9a feat: better loging
/ publish (push) Successful in 3m7s
2026-03-07 19:31:19 +01:00
schreifuchs 82e601fd7f chore: enable webp support
/ publish (push) Successful in 3m22s
2026-03-07 18:23:18 +01:00
schreifuchs f5bcc7c381 feat: configuration
/ publish (push) Successful in 3m27s
2026-03-07 16:58:19 +01:00
schreifuchs 4efd3ee396 Merge pull request 'rewrite/go-templ' (#1) from rewrite/go-templ into main
/ publish (push) Successful in 3m40s
Reviewed-on: #1
2026-03-03 06:47:07 +01:00
schreifuchs 9ad26910a9 feat: dockerization 2026-03-02 22:41:15 +01:00
schreifuchs e575f34b34 feat: page content 2026-03-02 22:38:29 +01:00
schreifuchs 2491d21963 chore: custom error 2026-03-02 22:38:28 +01:00
schreifuchs 6c1387c1cd feat: cache revalidation 2026-03-02 22:38:14 +01:00
schreifuchs 3cc632e358 feat: images 2026-03-02 22:38:01 +01:00
schreifuchs e689ab08c9 feat: caching headers 2026-03-02 22:37:48 +01:00
schreifuchs 19fb5b9292 feat: dummy pages 2026-03-02 22:37:30 +01:00
schreifuchs 9a1f7e0d99 feat: translation middleware 2026-03-02 22:37:11 +01:00
schreifuchs dd5e32cc4d feat: initialize template 2026-03-02 22:36:47 +01:00
u80864958 b4e8baabdc fix: language change
/ publish (push) Successful in 1m19s
2026-01-23 14:41:52 +01:00
u80864958 ab3c8982e2 feat: lru cache 2026-01-23 14:32:51 +01:00
u80864958 df9e6e95cb feat: cache images 2026-01-23 13:50:40 +01:00
u80864958 2154d6cfa8 feat: umami tracking
/ publish (push) Successful in 1m9s
2026-01-23 09:49:56 +01:00
schreifuchs 9002f13f80 style: align markdown text left and center images 2026-01-21 19:52:14 +01:00
schreifuchs c937be1274 feat: hidden pages
/ publish (push) Failing after 2m18s
2026-01-21 18:54:29 +01:00
120 changed files with 4350 additions and 4093 deletions
+54
View File
@@ -0,0 +1,54 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
env_files = [".env"]
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go generate ./... && go build -o ./tmp/main ./cmd/schreifuchs-ch/main.go"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", ".git", ".gitea"]
exclude_file = []
exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = "dlv exec ./tmp/main --listen=127.0.0.1:2345 --headless=true --api-version=2 --accept-multiclient --continue --log --"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html", "css"]
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
send_interrupt = false
stop_on_error = true
[proxy]
enabled = true
proxy_port = 8888
app_port = 8080
app_start_timeout = 50000000
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[env]
TEST_PW = "test"
VALKEY_ADDR = "127.0.0.1:6379"
VALKEY_PASSWORD = "valkey"
[screen]
clear_on_rebuild = false
keep_scroll = true
+1 -2
View File
@@ -8,6 +8,5 @@ build
docker-compose.yml docker-compose.yml
Dockerfile Dockerfile
README.md README.md
TODO_NEXTCLOUD.md
GEMINI.md
pnpm-workspace.yaml pnpm-workspace.yaml
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json",
"mcpServers": {
"svelte": {
"command": "npx",
"args": [
"-y",
"@sveltejs/mcp"
]
}
}
}
+5 -4
View File
@@ -1,10 +1,11 @@
.DS_Store .DS_Store
node_modules node_modules
/tmp
/tmp/*
/build /build
/.svelte-kit
/package
.env .env
.env.* .env.*
!.env.example !.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-* /web/**/*_templ.go
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+22
View File
@@ -0,0 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/cmd/schreifuchs-ch/main.go",
"cwd": "${workspaceFolder}",
"showLog": true
},
{
"name": "Attach to Air",
"type": "go",
"request": "attach",
"mode": "remote",
"port": 2345,
"host": "127.0.0.1"
}
]
}
+35 -33
View File
@@ -1,46 +1,48 @@
# Stage 1: Build the application # Stage 1: Build frontend assets
FROM node:24-slim AS builder FROM node:24-alpine AS frontend-builder
# Enable corepack to use pnpm
RUN corepack enable
WORKDIR /app WORKDIR /app
# Copy configuration files # Enable pnpm via corepack
COPY package.json pnpm-lock.yaml .npmrc ./ RUN corepack enable pnpm
# Install all dependencies (including devDependencies) # Install dependencies
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy the rest of the application code # Copy source and build assets
COPY . . COPY . .
RUN pnpm run build:css && pnpm run build:js
# Build the SvelteKit application # Stage 2: Build Go binary
RUN pnpm run build FROM golang:1.26-alpine AS go-builder
# Stage 2: Run the application
FROM node:24-slim AS runner
# Enable corepack to use pnpm
RUN corepack enable
WORKDIR /app WORKDIR /app
# Copy the build output and necessary files for production # Download Go modules
COPY --from=builder /app/build ./build COPY go.mod go.sum ./
COPY --from=builder /app/package.json ./package.json RUN go mod download
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/.npmrc ./.npmrc
# Install only production dependencies # Copy source code
RUN pnpm install --prod --frozen-lockfile COPY . .
# Expose the port the app runs on (SvelteKit defaults to 3000) # Copy built frontend assets from the previous stage
EXPOSE 3000 COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/
COPY --from=frontend-builder /app/web/static/js/htmx.min.js ./web/static/js/
# Set environment variables # Generate templ files using the tool defined in go.mod
ENV NODE_ENV=production RUN go tool templ generate
ENV PORT=3000
# Run the application # Build the optimized binary
CMD ["node", "build"] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/schreifuchs-ch
# Stage 3: Final minimal image
FROM alpine:latest
WORKDIR /app
# Copy the compiled binary from the builder stage
COPY --from=go-builder /app/server .
# Expose the application port
EXPOSE 8080
# Set the entrypoint
ENTRYPOINT ["/app/server"]
-62
View File
@@ -1,62 +0,0 @@
# Project Context: Schreifuchs.ch
## Overview
This project is a personal portfolio website for "Schreifuchs", featuring sections for Photography (Fotos), Informatics (Informatik), Music (Musig), and Video. It is currently being transitioned from a static site to a dynamic Node.js site powered by Nextcloud via WebDAV.
- [Nextcloud Integration Plan](./TODO_NEXTCLOUD.md)
## Tech Stack
- **Framework:** Svelte 5 (using Runes) & SvelteKit
- **Language:** TypeScript
- **Styling:** Tailwind CSS (with custom text-shadow plugin)
- **Bundler:** Vite
- **Adapter:** `@sveltejs/adapter-static` (SSG)
- **Markdown:** `@ts-stack/markdown` for content rendering
## Directory Structure
- `src/routes/`: File-based routing.
- `src/routes/fotos`, `informatik`, `musig`, `video`: Feature-specific pages.
- `src/lib/`: Shared utilities and components.
- `src/lib/components/`: Reusable UI components (`ImageTile`, `ImageLinkTile`, `Markdown`).
- `src/lib/images/`: Local image assets imported in components.
- `static/`: Static assets served as-is.
- Contains Markdown content files (`Fotos.md`, etc.) and their associated attachments.
## Key Conventions
- **Svelte 5:** Use Runes (`$state`, `$derived`, `$props`, etc.) for reactivity.
- **Styling:** Use Tailwind utility classes. The font family is configured to 'Outfit'.
- **Content:** Content is largely driven by Markdown files located in `static/` which are fetched and parsed.
- **Static Generation:** The site is built as a static site outputting to the `build/` directory.
## Development Scripts
- `npm run dev`: Start development server.
- `npm run build`: Build for production.
- `npm run check`: Run Svelte and TypeScript checks.
---
# Agent Instructions (Svelte MCP)
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
+7
View File
@@ -0,0 +1,7 @@
.PHONY: run
run: docker
air
.PHONY: docker
docker:
docker compose up -d
+40 -20
View File
@@ -2,33 +2,40 @@
Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video. Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video.
The site is built with **Svelte 5** and **SvelteKit**, and it is powered by a dynamic **Nextcloud** backend via WebDAV. The site is built with **Go**, **templ**, and **HTMX**, and it is powered by a dynamic **Nextcloud** backend via WebDAV.
## Features ## Features
- **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud. - **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud.
- **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file. - **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links.
- **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file and rendered via `gomarkdown`.
- **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support.
- **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy. - **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy.
- **Performance:** In-memory caching for Nextcloud requests (15-minute TTL). - **Performance:** Multi-layer caching using **Valkey** (Redis-compatible) for WebDAV requests and image scaling.
- **Responsive Design:** Styled with Tailwind CSS, optimized for all screen sizes. - **Responsive Design:** Styled with **Tailwind CSS**, optimized for all screen sizes.
## Tech Stack ## Tech Stack
- **Framework:** Svelte 5 (Runes) & SvelteKit - **Backend:** Go (Golang)
- **UI Components:** [templ](https://templ.guide/)
- **Frontend Interactivity:** [HTMX](https://htmx.org/)
- **Styling:** Tailwind CSS - **Styling:** Tailwind CSS
- **Backend:** Node.js (via `@sveltejs/adapter-node`) - **Caching:** Valkey
- **Integration:** `webdav` library for Nextcloud communication - **Integration:** `gowebdav` for Nextcloud communication
- **Content:** `@ts-stack/markdown` for rendering - **Content:** `github.com/gomarkdown/markdown` for rendering
## Configuration ## Configuration
The application requires the following environment variables in a `.env` file: The application requires the following environment variables in a `.env` file:
```env ```env
NEXTCLOUD_URL=https://your-nextcloud-instance.com FILESYSTEM_URL=https://your-nextcloud-instance.com/remote.php/dav/files/user/
NEXTCLOUD_USER=your_username FILESYSTEM_USERNAME=your_username
NEXTCLOUD_PASSWORD=your_app_password FILESYSTEM_PASSWORD=your_app_password
NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content CACHE_ADDRESS=localhost:6379
CACHE_PASSWORD=your_valkey_password
PORT=8080
LOG_LEVEL=DEBUG
``` ```
## Content Management ## Content Management
@@ -36,34 +43,47 @@ NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content
To add or update content, manage files in your configured Nextcloud directory: To add or update content, manage files in your configured Nextcloud directory:
1. **Create a Page:** Create a folder (e.g., `Travel`). 1. **Create a Page:** Create a folder (e.g., `Travel`).
2. **Add Content:** Inside that folder, create a Markdown file named after the folder (e.g., `Travel.md`). - **Hidden Sites:** To hide a page from the navigation menu, prefix the folder name with an underscore (e.g., `_Secret`). It will still be accessible at `/secret`.
2. **Add Content:** Inside that folder, create a Markdown file named after the folder (e.g., `Travel.md` or `Secret.md` for `_Secret`).
3. **Add Header Image:** Upload a file named `cover.webp`, `cover.jpg`, or `cover.png` to the folder. This will be used as the header on the page and the tile on the homepage. 3. **Add Header Image:** Upload a file named `cover.webp`, `cover.jpg`, or `cover.png` to the folder. This will be used as the header on the page and the tile on the homepage.
4. **Add Assets:** Any other images or files uploaded to the folder can be linked in the Markdown. 4. **Add Assets:** Any other images or files uploaded to the folder can be linked in the Markdown.
## Development ## Development
Install dependencies: Install dependencies (Go, pnpm, and the `templ` binary):
```bash ```bash
# Install Go dependencies
go mod tidy
# Install JS/CSS dependencies
pnpm install pnpm install
``` ```
Start the development server: Start the development server with hot reloading:
```bash ```bash
pnpm run dev air
```
For CSS changes, run in a separate terminal:
```bash
pnpm run watch:css
``` ```
## Building ## Building
Build for production (Node.js): Build the project for production:
```bash ```bash
pnpm run build # Generate Go code from Templ files
go generate ./...
# Build CSS
pnpm run build:css
# Build Go binary
go build -o ./tmp/main ./cmd/schreifuchs-ch/main.go
``` ```
The output will be in the `build/` directory, ready to be served by Node.
## Image Processing ## Image Processing
To optimize images before uploading to Nextcloud: To optimize images before uploading to Nextcloud:
-37
View File
@@ -1,37 +0,0 @@
# Plan: Nextcloud WebDAV Integration (COMPLETED)
Transition from static file hosting to a dynamic SvelteKit (Node.js) application that fetches content directly from Nextcloud.
## 1. Infrastructure Shift
- [x] Replace `@sveltejs/adapter-static` with `@sveltejs/adapter-node`.
- [x] Update `svelte.config.js` to use the Node adapter.
- [x] Remove `fallback: undefined` and other static-specific configurations.
## 2. Dependencies
- [x] Install `webdav` client library.
- [x] Install `dotenv` (handled by SvelteKit's built-in env support).
## 3. Secure Configuration
- [x] Set up `.env` with:
- `NEXTCLOUD_URL`
- `NEXTCLOUD_USER`
- `NEXTCLOUD_PASSWORD`
- [x] Use `$env/dynamic/private` to access these in server-side code only.
## 4. Server-Side Data Fetching
- [x] Create `src/lib/server/nextcloud.ts` to initialize and export the WebDAV client.
- [x] Implement `+page.server.ts` for each content route:
- Fetch Markdown content from Nextcloud.
- Return the raw string to the frontend.
- [x] Update `Markdown.svelte` to receive and render raw strings instead of fetching from a URL on the client.
## 5. Asset Proxying
- [x] Create a catch-all server route (e.g., `src/routes/assets/[...path]/+server.ts`).
- [x] This route will:
- Authenticate with WebDAV.
- Stream images/files from Nextcloud directly to the browser.
- [x] Update Markdown rendering logic to rewrite image URLs to point to this proxy route.
## 6. Optimization
- [x] Implement server-side caching to avoid hitting Nextcloud on every single request.
- [x] Implement dynamic route discovery to automatically create pages from Nextcloud folders.
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server"
)
func main() {
ctx := context.Background()
ctx, cancle := context.WithCancel(ctx)
defer cancle()
go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
<-sigint
cancle()
}()
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))
cfg, err := config.Read(ctx)
if err != nil {
slog.Error("could not read configuaration", "err", err)
}
setupLogger(&cfg)
err = server.Start(ctx, cfg)
if err != nil {
slog.Error("error while serving", "err", err)
return
}
}
func setupLogger(cfg *config.Cfg) {
opts := slog.HandlerOptions{
Level: cfg.LogLevel,
}
var handler slog.Handler
if cfg.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &opts)
} else {
handler = slog.NewTextHandler(os.Stdout, &opts)
}
slog.SetDefault(
slog.New(
handler,
),
)
}
+11
View File
@@ -0,0 +1,11 @@
services:
valkey:
image: valkey/valkey:8
ports:
- "6379:6379"
command: valkey-server --requirepass "valkey"
healthcheck:
test: ["CMD", "valkey-cli", "-a", "valkey", "ping"]
interval: 5s
timeout: 5s
retries: 5
+36
View File
@@ -0,0 +1,36 @@
module git.schreifuchs.ch/schreifuchs/schreifuchs.ch
go 1.26
tool github.com/a-h/templ/cmd/templ
require (
github.com/a-h/templ v0.3.977
github.com/alicebob/miniredis/v2 v2.36.1
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/sethvargo/go-envconfig v1.3.0
github.com/studio-b12/gowebdav v0.12.0
github.com/valkey-io/valkey-go v1.0.72
github.com/valkey-io/valkey-go/mock v1.0.72
go.uber.org/mock v0.6.0
golang.org/x/image v0.36.0
golang.org/x/sync v0.16.0
)
require (
github.com/InfinityTools/go-binpack2d v1.0.0 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.36.0 // indirect
)
+69
View File
@@ -0,0 +1,69 @@
github.com/InfinityTools/go-binpack2d v1.0.0 h1:l15wC3zDlR0FoffIcPze0qbszshmGCpMncDiAxpWnE8=
github.com/InfinityTools/go-binpack2d v1.0.0/go.mod h1:3hwEmVO6ffChGwW55BVr9k4rzSYGow0OH2B8bjEdO6o=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI=
github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
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/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
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=
github.com/valkey-io/valkey-go/mock v1.0.72 h1:rE8K/sjlX0SRldI70Rt4/MCrYl224XD4A4vkYegP1Iw=
github.com/valkey-io/valkey-go/mock v1.0.72/go.mod h1:A4B8L3Wg85yAOl/GwNgkO/6aeGNXydwBl+86e20NQQY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+125
View File
@@ -0,0 +1,125 @@
package gallery
import (
"context"
"log/slog"
"math"
"github.com/InfinityTools/go-binpack2d"
)
type Gallery struct {
GridWith int
GridHeight int
Images []Image
}
type Set struct {
SM Gallery
LG Gallery
XLG Gallery
}
func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) {
gallerySet.SM, err = s.GetGallery(ctx, 50)
if err != nil {
return
}
gallerySet.LG, err = s.GetGallery(ctx, 80)
if err != nil {
return
}
gallerySet.XLG, err = s.GetGallery(ctx, 120)
if err != nil {
return
}
return
}
func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return
}
packer := binpack2d.Create(width, math.MaxInt)
for len(imgs) > 0 {
unused := make([]Image, 0, len(imgs))
for _, img := range imgs {
adjustImageSizeStrictRatio(&img, 200)
if img.Height == 0 || img.Width == 0 {
slog.Warn("image has invalid size", "img", img)
continue
}
rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok {
unused = append(unused, img)
continue
}
img.X = rect.X
img.Y = rect.Y
gallery.Images = append(gallery.Images, img)
}
imgs = unused
}
packer.ShrinkBin(false)
gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth()
return
}
// getCleanRatio returns the simplified aspect ratio as (numerator, denominator).
// It searches for the best fit within a denominator limit to account for rounding errors.
func getCleanRatio(width, height int) (int, int) {
targetRatio := float64(width) / float64(height)
const maxDenominator = 100
bestN, bestD := 0, 1
minDiff := math.MaxFloat64
for d := 1; d <= maxDenominator; d++ {
// Calculate the closest numerator for this denominator
n := int(math.Round(float64(d) * targetRatio))
// Calculate the difference
currentRatio := float64(n) / float64(d)
diff := math.Abs(currentRatio - targetRatio)
// Keep track of the one with the smallest error
if diff < minDiff {
minDiff = diff
bestN, bestD = n, d
}
}
return bestN, bestD
}
func adjustImageSizeStrictRatio(img *Image, targetArea int) {
baseW, baseH := getCleanRatio(img.Width, img.Height)
baseArea := baseW * baseH
idealK := math.Sqrt(float64(targetArea) / float64(baseArea))
k := int(math.Floor(idealK))
if math.Abs(float64(k*k*baseArea-targetArea)) >
math.Abs(float64(int(math.Ceil(idealK))*int(math.Ceil(idealK))*baseArea-targetArea)) {
k = int(math.Ceil(idealK))
}
slog.Debug("adslkfj", "k", k, "idealK", idealK, "baseH", baseH, "baseW", baseW, "imgW", img.Width, "imgH", img.Height)
// 5. Apply the scaled dimensions (guarantees perfect aspect ratio)
img.Width = baseW * k
img.Height = baseH * k
}
+40
View File
@@ -0,0 +1,40 @@
package gallery
import (
"os"
"path"
)
type fileWithPath struct {
os.FileInfo
path string
}
func (f fileWithPath) Name() string {
return path.Join(f.path, f.FileInfo.Name())
}
func (s *Service) readDirRecursive(path string) (files []os.FileInfo, err error) {
return s.readDirRecursiveTo([]os.FileInfo{}, path)
}
func (s *Service) readDirRecursiveTo(files []os.FileInfo, p ...string) ([]os.FileInfo, error) {
localFiles, err := s.fs.ReadDir(path.Join(p...))
if err != nil {
return files, err
}
for _, file := range localFiles {
if file.IsDir() {
newP := append(p, file.Name())
files, err = s.readDirRecursiveTo(files, newP...)
if err != nil {
return files, err
}
} else {
files = append(files, fileWithPath{file, path.Join(p[1:]...)})
}
}
return files, nil
}
+121
View File
@@ -0,0 +1,121 @@
package gallery
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path"
"strings"
"time"
"github.com/valkey-io/valkey-go"
)
type Image struct {
UID string
X int
Y int
Width int
Height int
}
const (
imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash"
)
func (s *Service) autoRefresh() {
t := time.NewTicker(time.Minute)
for {
ctx, cancle := context.WithTimeout(context.Background(), time.Minute)
if err := s.assembleImageList(ctx); err != nil {
slog.Error("error while refreshing gallery", "err", err)
}
cancle()
<-t.C
}
}
func (s *Service) assembleImageList(ctx context.Context) error {
files, err := s.readDirRecursive(s.cfg.Path)
if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err)
}
savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
newHash := hashImageList(files)
if bytes.Equal(savedHash, newHash) {
slog.Debug("images up to date no refreshing of gallery", "savedHash", savedHash, "newHash", newHash)
return nil
}
slog.Info("starting gallery refresh")
defer slog.Info("gallery refresh completed")
imgs := make([]Image, 0, len(files))
for _, file := range files {
if strings.HasSuffix(file.Name(), ".md") {
continue
}
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
slog.Warn("could not get image for gallery", "err", err, "img", img)
continue
}
uid, err := img.UID()
if err != nil {
continue
}
w := img.Bounds().Dx()
h := img.Bounds().Dy()
// FIX 2: Defensive check to guard against unreadable/corrupted image boundaries
if w <= 0 || h <= 0 {
slog.Warn("skipping image with zero or negative dimensions", "path", imagePath, "w", w, "h", h)
continue
}
imgs = append(imgs, Image{
UID: uid,
Width: w,
Height: h,
})
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(hashKey).Value(valkey.BinaryString(newHash)).Build()).Error(); err != nil {
return err
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(imagesKey).Value(valkey.JSON(imgs)).Build()).Error(); err != nil {
return err
}
return nil
}
func hashImageList(files []os.FileInfo) []byte {
h := fnv.New128()
for _, file := range files {
fmt.Fprint(h, file.ModTime())
fmt.Fprint(h, file.Size())
fmt.Fprint(h, file.Name())
}
return h.Sum([]byte{})
}
func (s *Service) GetImages(ctx context.Context) (images []Image, err error) {
err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return
}
+35
View File
@@ -0,0 +1,35 @@
package gallery
import (
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/valkey-io/valkey-go"
)
type getImager interface {
GetImage(path string) (img images.Image, err error)
}
type Service struct {
fs filesystem.FS
cfg *config.Gallery
img getImager
cache valkey.Client
refreshIntervall time.Duration
}
func New(fs filesystem.FS, cfg *config.Gallery, img getImager, cache valkey.Client) *Service {
s := &Service{
fs: fs,
cfg: cfg,
img: img,
cache: cache,
}
go s.autoRefresh()
return s
}
+142
View File
@@ -0,0 +1,142 @@
package images
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"hash/fnv"
"image"
"image/jpeg"
_ "image/png"
"io"
"strings"
_ "golang.org/x/image/webp"
"github.com/valkey-io/valkey-go"
)
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: %w", err)
}
if mimer, ok := info.(interface{ ContentType() string }); ok {
mimeType = mimer.ContentType()
return
}
return
}
func (s *Service) getImage(path string) (file []byte, mimeType string, err error) {
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
}
func (s *Service) GetImage(path string) (img Image, err error) {
rawImage, _, err := s.getImage(path)
if err != nil {
return
}
img.Path = path
img.Image, _, err = image.Decode(bytes.NewBuffer(rawImage))
return
}
func (s *Service) GetScaledImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) {
if err = s.seph.Acquire(ctx, 1); err != nil {
err = fmt.Errorf("could not Acquire semaphore: %w", err)
return
}
defer s.seph.Release(1)
path, err := PathFromUID(uid)
if err != nil {
return
}
rawImage, mimeType, err := s.getImage(path)
if err != nil {
return
}
hash, err := hash(rawImage, options)
if err != nil {
return
}
if imgBytes, err := s.cache.Do(ctx, s.cache.B().Get().Key("img:"+hash).Build()).AsBytes(); err == nil {
return bytes.NewBuffer(imgBytes), mimeType, err
}
decImage, _, err := image.Decode(bytes.NewBuffer(rawImage))
if err != nil {
return
}
decImage = scale(decImage, options)
outBuff := bytes.NewBuffer(make([]byte, 0, 1024))
if options.Quality == 0 {
options.Quality = s.cfg.Quality
}
err = jpeg.Encode(outBuff, decImage, &jpeg.Options{Quality: options.Quality})
if err != nil {
return
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(s.cfg.CacheLifetime).Build()).Error(); err == nil {
return outBuff, "image/jpeg", err
}
return outBuff, "image/jpeg", err
}
func hash(bytes []byte, options Options) (hash string, err error) {
h := fnv.New128()
if _, err = h.Write(bytes); err != nil {
return
}
if err = binary.Write(h, binary.LittleEndian, int64(options.Height)); err != nil {
return
}
if err = binary.Write(h, binary.LittleEndian, int64(options.Width)); err != nil {
return
}
if err = binary.Write(h, binary.LittleEndian, int64(options.Quality)); err != nil {
return
}
res := h.Sum(make([]byte, 0, 128/8))
return base64.RawStdEncoding.EncodeToString(res), nil
}
@@ -0,0 +1,37 @@
package images_test
import (
"context"
"errors"
"testing"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/test/testdata"
"github.com/valkey-io/valkey-go"
"github.com/valkey-io/valkey-go/mock"
"go.uber.org/mock/gomock"
)
func Setup(ctx context.Context, ctrl *gomock.Controller) (*images.Service, *mock.Client) {
valkey := mock.NewClient(ctrl)
return images.New(testdata.FS(), &config.Image{Concurency: 1, Quality: 80}, valkey), valkey
}
func BenchmarkService_GetImage(b *testing.B) {
srv, valkeyClient := Setup(b.Context(), gomock.NewController(b))
uid, err := images.UIDFromPath("cover.jpg")
if err != nil {
b.Error("could not get uid", err)
}
b.ReportAllocs()
for b.Loop() {
valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.ErrorResult(errors.New("adf")))
valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.Result(valkey.ValkeyMessage{}))
_, _, err = srv.GetScaledImage(b.Context(), uid, images.Options{Width: 500, Quality: 80})
if err != nil {
b.Error(err)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package images
import (
"bytes"
"compress/flate"
"encoding/base64"
"fmt"
"io"
"strings"
)
const (
sourceSetSteps = 300
sourceSetMax = 1500
)
func UIDFromPath(path string) (uid string, err error) {
var b bytes.Buffer
// NewWriter with NoDict (nil) creates a raw DEFLATE compressor
zw, _ := flate.NewWriter(&b, flate.BestCompression)
_, err = zw.Write([]byte(path))
if err != nil {
return
}
err = zw.Close() // Essential to flush the final bits
if err != nil {
return
}
return base64.RawURLEncoding.EncodeToString(b.Bytes()), nil
}
func URLFromPath(path string) (url string, err error) {
uid, err := UIDFromPath(path)
url = "/images/" + uid
return
}
func PathFromUID(uid string) (string, error) {
// 1. Decode the Base64 string back to compressed bytes
compressedBytes, err := base64.RawURLEncoding.DecodeString(uid)
if err != nil {
return "", err
}
// 2. Create a flate reader (NoDict/nil since you used nil in the writer)
// flate.NewReader returns an io.ReadCloser
reader := flate.NewReader(bytes.NewReader(compressedBytes))
defer reader.Close()
// 3. Read the decompressed data into a buffer
var out bytes.Buffer
_, err = io.Copy(&out, reader)
if err != nil {
return "", err
}
return out.String(), nil
}
func SourceSet(src string) string {
sb := strings.Builder{}
i := 1
for {
width := i * sourceSetSteps
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, width, width))
if width >= sourceSetMax {
return sb.String()
}
sb.WriteRune(',')
i++
}
}
+12
View File
@@ -0,0 +1,12 @@
package images
import "image"
type Image struct {
image.Image
Path string `json:"path"`
}
func (i Image) UID() (string, error) {
return UIDFromPath(i.Path)
}
+36
View File
@@ -0,0 +1,36 @@
package images
import (
"os"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/valkey-io/valkey-go"
"golang.org/x/sync/semaphore"
)
type Options struct {
Width int // max width, 0 is unlimited
Height int // max Height, 0 is unlimited
Quality int // quality of the output jpeg
}
type Service struct {
fs fileSystem
cache valkey.Client
seph *semaphore.Weighted
cfg *config.Image
}
func New(fs fileSystem, cfg *config.Image, cache valkey.Client) *Service {
return &Service{
fs: fs,
cache: cache,
seph: semaphore.NewWeighted(cfg.Concurency),
cfg: cfg,
}
}
type fileSystem interface {
Read(path string) ([]byte, error)
Stat(path string) (os.FileInfo, error)
}
+55
View File
@@ -0,0 +1,55 @@
package images
import (
"image"
"math"
"golang.org/x/image/draw"
)
func scale(src image.Image, options Options) image.Image {
// Set the expected size that you want:
dst := image.NewRGBA(
calculateFit(src.Bounds(), options),
)
// Resize:
draw.ApproxBiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
// Encode to `output`:
return dst
}
// calculateFit returns the new dimensions (width, height) that fit within the
// provided Options without upscaling and while maintaining the aspect ratio.
func calculateFit(bounds image.Rectangle, opt Options) image.Rectangle {
// If no limits are set, or the image is already smaller than the limits,
// return original dimensions (No Upscaling).
if (opt.Width == 0 || bounds.Max.X <= opt.Width) && (opt.Height == 0 || bounds.Max.Y <= opt.Height) {
return bounds
}
// Calculate ratios for width and height
ratioW := float64(opt.Width) / float64(bounds.Max.X)
ratioH := float64(opt.Height) / float64(bounds.Max.Y)
// Determine the scale factor
var scale float64
if opt.Width > 0 && opt.Height > 0 {
// Use the smaller ratio to ensure it fits in both bounds
scale = math.Min(ratioW, ratioH)
} else if opt.Width > 0 {
scale = ratioW
} else if opt.Height > 0 {
scale = ratioH
} else {
return bounds
}
return image.Rect(
0,
0,
int(math.Round(float64(bounds.Max.X)*scale)),
int(math.Round(float64(bounds.Max.Y)*scale)),
)
}
+61
View File
@@ -0,0 +1,61 @@
package page
import (
"context"
"log/slog"
"strings"
)
type PageHeader struct {
UID string
Title string
CoverImageUID string
md []byte // markdown of the page //markdown of the page
}
type Page struct {
PageHeader
Content string
Images []Image
}
func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) {
paths, err := s.fs.ReadDir("/")
if err != nil {
return
}
for _, path := range paths {
if strings.HasPrefix(path.Name(), ".") || // hidden files
strings.HasPrefix(path.Name(), "_") { // hidden pages
continue
}
page, err := s.getPageHeaders(ctx, path.Name())
if err != nil { // just ignore missing pages
slog.Error("could not open path", "path", path.Name(), "err", err)
continue
}
pages = append(pages, page)
}
return
}
func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error) {
p, err := s.getPageHeaders(ctx, uid)
if err != nil {
return
}
html, imgs, err := s.getHTML(ctx, p)
if err != nil {
return
}
page = Page{
PageHeader: p,
Content: string(html),
Images: imgs,
}
return
}
+84
View File
@@ -0,0 +1,84 @@
package page
import (
"context"
"io"
"log/slog"
"net/url"
"strings"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
// image can be used to preload
type Image struct {
SrcSet string
Src string
}
func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, imgs []Image, err error) {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(page.md)
// create HTML renderer with extensions
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{
Flags: htmlFlags,
RenderNodeHook: imageMiddleware(page, &imgs),
}
renderer := html.NewRenderer(opts)
out = markdown.Render(doc, renderer)
return
}
func imageMiddleware(page PageHeader, imgs *[]Image) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if !entering {
return ast.GoToNext, false
}
img, ok := node.(*ast.Image)
if !ok {
return ast.GoToNext, false
}
src := string(img.Destination)
if strings.HasPrefix(src, ".attachments") {
imgPath, err := url.PathUnescape(src)
if err != nil {
slog.Error("could not unescape path", "err", err)
return ast.GoToNext, false
}
src, err = images.URLFromPath(page.UID + "/" + imgPath)
if err != nil {
slog.Error("could not get image url", "err", err)
return ast.GoToNext, false
}
image := Image{
SrcSet: images.SourceSet(src),
Src: src + "?w=1024",
}
img.Attribute = &ast.Attribute{Attrs: map[string][]byte{
// "srcset": []byte(image.SrcSet),
"fetchpriority": []byte("low"),
"loading": []byte("lazy"),
"class": []byte("min-h-48"),
"src": []byte(image.Src),
}}
*imgs = append(*imgs, image)
// // img.Attrs["srcset"] =
}
img.Destination = []byte(src)
return ast.GoToNext, false
}
}
+82
View File
@@ -0,0 +1,82 @@
package page
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"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/pkg/filesystem"
"github.com/studio-b12/gowebdav"
)
var titleRGX = regexp.MustCompile(`(?m)^#\s+(.+)$`)
var (
ErrMalFormedInput = errors.New("mal formed input")
ErrNotFound = errors.New("not found")
)
func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHeader, err error) {
info, err := s.fs.ReadDir(uid)
if gowebdav.IsErrNotFound(err) { // try private
err = ErrNotFound
}
if err != nil {
return
}
lang := translate.Language(ctx)
var coverImage filesystem.FileInfo
var contentMD filesystem.FileInfo
for _, f := range info {
file, ok := f.(filesystem.FileInfo)
if !ok {
continue
}
if strings.HasPrefix(file.ContentType(), "image/") {
coverImage = file
}
parts := strings.Split(file.Name(), ".")
// fallback content
if contentMD == nil && len(parts) == 2 && parts[1] == "md" {
contentMD = file
}
// language content
if len(parts) == 3 && parts[1] == lang && parts[2] == "md" {
contentMD = file
}
}
if contentMD != nil {
page.md, err = s.fs.Read(contentMD.Path())
if err != nil {
return
}
} else {
page.md = []byte("# ...")
}
title := titleRGX.FindStringSubmatch(string(page.md))
if len(title) < 2 {
err = fmt.Errorf("%w: no matches for title", ErrMalFormedInput)
return
}
page.UID = uid
page.Title = title[1]
if coverImage != nil {
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
}
return
}
+15
View File
@@ -0,0 +1,15 @@
package page
import (
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
)
type Service struct {
fs filesystem.FS
}
func New(fs filesystem.FS) *Service {
return &Service{
fs: fs,
}
}
+81
View File
@@ -0,0 +1,81 @@
package translate
import (
"context"
"log/slog"
"path"
)
type contextKey string
const (
serviceKey contextKey = "translationService"
langKey contextKey = "language"
)
func Language(ctx context.Context) string {
lang, ok := ctx.Value(langKey).(string)
if !ok {
return ""
}
return lang
}
func SupportedLanguages(ctx context.Context) []string {
s, ok := ctx.Value(serviceKey).(*Service)
if !ok {
slog.Error("could not extract translation service from context for getting supported languages")
return []string{}
}
return s.SupportedLanguages()
}
func T(ctx context.Context, key string) string {
s, ok := ctx.Value(serviceKey).(*Service)
if !ok {
slog.Error("could not extract translation service from context", "ctx", ctx, "key", key)
return key
}
lang := Language(ctx)
translation, ok := s.translations[lang][key]
if !ok {
slog.Warn("could not translate", "key", key, "language", lang)
return key
}
return translation
}
func Path(ctx context.Context, pp ...string) string {
return PathForLang(ctx, Language(ctx), pp...)
}
func PathForLang(ctx context.Context, lang string, pp ...string) string {
s, ok := ctx.Value(serviceKey).(*Service)
if !ok {
slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp)
return path.Join(pp...)
}
if len(pp) > 0 {
if _, ok := s.translations[pp[0]]; ok {
pp = pp[1:]
}
}
return "/" + path.Join(append([]string{lang}, pp...)...)
}
func (s *Service) SupportedLanguages() (languages []string) {
for lang := range s.translations {
languages = append(languages, lang)
}
return
}
func (s *Service) DefaultLanguage() string {
return s.defaultLanguage
}
@@ -0,0 +1,87 @@
package translate
import (
"context"
"log/slog"
"net/http"
"strings"
)
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
segments := []string{}
for segment := range strings.SplitSeq(path, "/") {
if segment != "" {
segments = append(segments, segment)
}
}
// 1. Check if the first segment is a supported language.
var currentLang string
if len(segments) >= 1 {
potentialLang := segments[0]
if _, ok := s.translations[potentialLang]; ok {
currentLang = potentialLang
}
}
if currentLang != "" {
// CASE A: Language is present in the URL.
// Remove the language from the path so the router sees the clean path.
newPath := "/" + strings.Join(segments[1:], "/")
if newPath == "" {
newPath = "/"
}
// Update the request URL path.
r.URL.Path = newPath
// Inject translator into context.
ctx := context.WithValue(r.Context(), serviceKey, s)
ctx = context.WithValue(ctx, langKey, currentLang)
// Pass control to the next handler with the modified request.
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// CASE B: Language is NOT in the URL.
// Detect best language from Accept-Language header.
detectedLang := s.detectLanguage(r.Header.Get("Accept-Language"))
// Redirect to the URL with the language prefix.
targetURL := "/" + detectedLang + path
if r.URL.RawQuery != "" {
targetURL += "?" + r.URL.RawQuery
}
slog.Debug("redirecting for translated url", "old", path, "new", targetURL)
http.Redirect(w, r, targetURL, http.StatusFound)
})
}
// detectLanguage parses the Accept-Language header and returns the best matching supported language.
func (s *Service) detectLanguage(header string) string {
if header == "" {
return s.defaultLanguage
}
// Simple parser for Accept-Language: en-US,en;q=0.9,de;q=0.8
// We only care about the first match for simplicity here, or we could rank them.
parts := strings.Split(header, ",")
for _, part := range parts {
tag := strings.Split(strings.TrimSpace(part), ";")[0]
// Check for exact match (e.g., "en-US")
if _, ok := s.translations[tag]; ok {
return tag
}
// Check for base match (e.g., "en" from "en-US")
base := strings.Split(tag, "-")[0]
if _, ok := s.translations[base]; ok {
return base
}
}
return s.defaultLanguage
}
+22
View File
@@ -0,0 +1,22 @@
package translate
type Translations map[string]string // key -> translation
type Service struct {
translations map[string]Translations // language -> translations
defaultLanguage string
}
func New() *Service {
return &Service{
defaultLanguage: "de",
translations: map[string]Translations{
"de": {
"home": "Startseite",
},
"en": {
"home": "Home",
},
},
}
}
+68
View File
@@ -0,0 +1,68 @@
package rest
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
)
type ErrorType string
const (
ErrorTypeUndefinded = ""
ErrorTypeValidation = "VALIDATION"
ErrorTypeNotFound = "NOT_FOUND"
ErrorTypeServerError = "SERVER_ERROR"
)
type Error struct {
Type ErrorType `json:"type"`
Message string `json:"message"`
err error
}
func WrappErr(err error, errorType ErrorType, message string) *Error {
return &Error{
Type: errorType,
Message: message,
err: err,
}
}
func (e Error) Unwrap() error {
return e.err
}
func (e Error) Error() string {
return fmt.Sprintf("%s: %s", e.Message, e.err.Error())
}
func (e Error) StatusCode() int {
switch e.Type {
case ErrorTypeValidation:
return http.StatusBadRequest
case ErrorTypeNotFound:
return http.StatusNotFound
default:
return http.StatusInternalServerError
}
}
func SendErr(w http.ResponseWriter, err error) {
restErr := &Error{}
if !errors.As(err, &restErr) {
slog.Error("internal server error", "err", err)
restErr = &Error{
Type: ErrorTypeServerError,
}
}
w.WriteHeader(restErr.StatusCode())
w.Header().Add("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(restErr)
slog.Error("could not send error", "err", err)
}
+24
View File
@@ -0,0 +1,24 @@
package rest
import (
"fmt"
"net/http"
"strconv"
)
func IntParam(r *http.Request, key string) (val int, err error) {
str := r.URL.Query().Get(key)
if str == "" {
return
}
val, err = strconv.Atoi(str)
if err != nil {
err = WrappErr(
err,
ErrorTypeValidation,
fmt.Sprintf("failed to parse query parameter '%s': not an int", key),
)
}
return
}
+13
View File
@@ -0,0 +1,13 @@
package rest
import "net/http"
type Middeware = func(next http.Handler) http.Handler
func Use(handler http.Handler, middlewares ...Middeware) http.Handler {
for _, middleware := range middlewares {
handler = middleware(handler)
}
return handler
}
@@ -0,0 +1,18 @@
package restgallery
import (
"log/slog"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
)
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetGallerySet(r.Context())
if err != nil {
slog.Error("could not get images", "err", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
h.r.Render(r.Context(), w, r, pages.GallerySet(imgs))
}
+40
View File
@@ -0,0 +1,40 @@
package restgallery
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/a-h/templ"
)
type Handler struct {
http.Handler
srv galleryService
r renderer
cfg *config.Gallery
}
func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
mux := http.NewServeMux()
h := &Handler{
Handler: mux,
srv: srv,
r: renderer,
cfg: cfg,
}
mux.HandleFunc(fmt.Sprintf("GET /%s", url.PathEscape(cfg.Path)), h.gallery)
return h
}
type galleryService interface {
GetGallerySet(ctx context.Context) (gallerySet gallery.Set, err error)
}
type renderer interface {
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
}
+47
View File
@@ -0,0 +1,47 @@
package resthome
import (
"context"
"errors"
"fmt"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
)
func (h *Handler) home(w http.ResponseWriter, r *http.Request) {
pageHeaders, err := h.src.GetPages(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
h.r.Render(r.Context(), w, r, pages.Home(pageHeaders))
}
func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := r.PathValue("uid")
page, err := h.src.GetPage(r.Context(), uid)
if err != nil {
if errors.Is(err, context.Canceled) {
w.WriteHeader(http.StatusRequestTimeout)
}
w.WriteHeader(http.StatusInternalServerError)
return
}
for i, image := range page.Images {
if i > 3 {
break
}
ctx = templer.AppendHeader(ctx, fmt.Sprintf(`<link rel="preload" href="%s" as="image" />`, image.Src))
}
ctx = templer.PageTitle(ctx, page.Title)
h.r.Render(ctx, w, r, pages.ContentPage(page))
}
+37
View File
@@ -0,0 +1,37 @@
package resthome
import (
"context"
"io"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"github.com/a-h/templ"
)
type Handler struct {
http.Handler
src pageService
r renderer
}
func New(renderer renderer, srv pageService) *Handler {
mux := http.NewServeMux()
h := &Handler{
Handler: mux,
src: srv,
r: renderer,
}
mux.HandleFunc("GET /{$}", h.home)
mux.HandleFunc("GET /{uid}", h.page)
return h
}
type pageService interface {
GetPages(ctx context.Context) ([]page.PageHeader, error)
GetPage(ctx context.Context, uid string) (page.Page, error)
}
type renderer interface {
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
}
+43
View File
@@ -0,0 +1,43 @@
package restimage
import (
"io"
"log/slog"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
)
func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("uid")
var err error
options := images.Options{}
options.Height, err = rest.IntParam(r, "h")
if err != nil {
rest.SendErr(w, err)
return
}
options.Width, err = rest.IntParam(r, "w")
if err != nil {
rest.SendErr(w, err)
return
}
options.Quality, err = rest.IntParam(r, "q")
if err != nil {
rest.SendErr(w, err)
return
}
img, mime, err := h.img.GetScaledImage(r.Context(), uid, options)
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)
}
+29
View File
@@ -0,0 +1,29 @@
package restimage
import (
"context"
"io"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
)
type Handler struct {
http.Handler
img imageService
}
func New(srv imageService) *Handler {
mux := http.NewServeMux()
h := &Handler{
Handler: mux,
img: srv,
}
mux.HandleFunc("GET /images/{uid}", h.getImage)
return h
}
type imageService interface {
GetScaledImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error)
}
+25
View File
@@ -0,0 +1,25 @@
package config
import "time"
func Default() Cfg {
return Cfg{
Image: &Image{
Concurency: 10,
Quality: 80,
CacheLifetime: time.Hour * 24 * 31,
},
Tracking: &Umami{
ScriptSrc: "https://umami.schreifuchs.ch/script.js",
WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd",
},
Gallery: &Gallery{
Path: "photos",
},
Cache: &Valkey{
RefreshTime: time.Minute * 10,
LifeTime: time.Hour * 48,
},
LogJSON: true,
}
}
+79
View File
@@ -0,0 +1,79 @@
package config
import (
"context"
"log/slog"
"time"
"github.com/sethvargo/go-envconfig"
)
type Cfg struct {
FileSystem *WebdavConfig `env:", prefix=FILESYSTEM_"`
Cache *Valkey `env:", prefix=CACHE_"`
Image *Image `env:", prefix=IMAGE_"`
Tracking *Umami `env:", prefix=TRACKING_"`
Gallery *Gallery `env:", prefix=GALLERY_"`
LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"`
LogJSON bool `env:"LOG_JSON"`
}
type WebdavConfig struct {
URL string `env:"URL"`
User string `env:"USERNAME"`
Password string `env:"PASSWORD"`
}
type Valkey struct {
ClientName string `env:"CLIENTNAME"`
Username string `env:"USERNAME"`
Password string `env:"PASSWORD"`
RefreshTime time.Duration `env:"REFRESH_TIME"`
LifeTime time.Duration `env:"LIFE_TIME"`
// InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
// If len(InitAddress) == 1 and the address is not running in cluster mode, valkey will fall back to the single client mode.
// If ClientOption.Sentinel.MasterSet is set, then InitAddress will be used to connect sentinels
// You can bypass this behavior by using ClientOption.ForceSingleClient.
InitAddress []string `env:"ADDRESS"`
}
type Image struct {
Concurency int64 `env:"CONCURENCY"`
Quality int `env:"QUALITY"`
CacheLifetime time.Duration `env:"CACHE_LIFETIME"`
}
type Gallery struct {
Path string `env:"PATH"`
}
type Umami struct {
ScriptSrc string `env:"SCRIPT_SRC"`
WebsiteID string `env:"WEBSITE_ID"`
}
func Read(ctx context.Context) (cfg Cfg, err error) {
cfg = Default()
err = envconfig.ProcessWith(ctx, &envconfig.Config{
Target: &cfg,
// All fields will use a ";" delimiter and "@" separator, unless locally
// overridden.
DefaultDelimiter: ";",
DefaultSeparator: "@",
DefaultOverwrite: true,
Lookuper: envconfig.MultiLookuper(secretLookuper(), envconfig.OsLookuper()),
})
if err != nil {
return
}
slog.Info("rft", "rft", cfg.Cache.RefreshTime)
return
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"io"
"log/slog"
"os"
"strings"
"github.com/sethvargo/go-envconfig"
)
func secretLookuper() envconfig.LookuperFunc {
return func(key string) (string, bool) {
fileName := os.Getenv(key + "_FILE")
if fileName == "" {
return "", false
}
file, err := os.Open(fileName)
if err != nil {
slog.Error("could not read secret file", "path", fileName)
return "", false
}
defer file.Close()
sb := &strings.Builder{}
_, err = io.Copy(sb, file)
if err != nil {
return "", false
}
return sb.String(), true
}
}
+71
View File
@@ -0,0 +1,71 @@
package filesystem
import (
"context"
"log/slog"
"sync/atomic"
"time"
"github.com/valkey-io/valkey-go"
)
const keyStat = "webdav:stat:"
// CachedClient wraps a FileSystem implementation with Valkey caching.
type CachedClient struct {
impl FS
cache valkey.Client
ttl 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, refreshtime time.Duration, lifetime time.Duration) *CachedClient {
c := &CachedClient{
impl: impl,
cache: client,
ttl: lifetime,
}
go func() {
t := time.NewTicker(refreshtime)
for {
<-t.C
c.revalidate(context.Background())
}
}()
return c
}
func (c *CachedClient) revalidate(ctx context.Context) {
slog.Info("starting revalidation")
go c.revalidateReadDir(ctx)
go c.revalidateRead(ctx)
go c.revalidateStat(ctx)
}
func (c *CachedClient) scanAndProcess(ctx context.Context, match string, process func(key string) error) {
cursor := uint64(0)
for {
res, err := c.cache.Do(ctx, c.cache.B().Scan().Cursor(cursor).Match(match).Build()).AsScanEntry()
if err != nil {
slog.Error("scan failed", "match", match, "err", err)
return
}
for _, key := range res.Elements {
if err := process(key); err != nil {
slog.Debug("process failed", "key", key, "err", err)
}
}
cursor = res.Cursor
if cursor == 0 {
break
}
}
}
+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{}
}
@@ -0,0 +1,464 @@
package filesystem
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/valkey-io/valkey-go"
)
// MockFS implements filesystem.FS interface for testing.
type MockFS struct {
files map[string]*MockFile
}
// MockFile implements FileInfo and holds content.
type MockFile struct {
name string
content []byte
mode os.FileMode
modTime time.Time
isDir bool
contentType string
path string
}
func (f *MockFile) Name() string { return f.name }
func (f *MockFile) Size() int64 { return int64(len(f.content)) }
func (f *MockFile) Mode() os.FileMode { return f.mode }
func (f *MockFile) ModTime() time.Time { return f.modTime }
func (f *MockFile) IsDir() bool { return f.isDir }
func (f *MockFile) Sys() any { return nil }
func (f *MockFile) ContentType() string { return f.contentType }
func (f *MockFile) Path() string { return f.path }
func NewMockFS() *MockFS {
return &MockFS{
files: make(map[string]*MockFile),
}
}
func (m *MockFS) AddFile(path string, content []byte) {
m.files[path] = &MockFile{
name: path,
content: content,
mode: 0o644,
modTime: time.Now().Truncate(time.Second),
isDir: false,
contentType: "text/plain",
path: path,
}
}
func (m *MockFS) AddDir(path string) {
m.files[path] = &MockFile{
name: path,
mode: 0o755,
modTime: time.Now().Truncate(time.Second),
isDir: true,
path: path,
}
}
func (m *MockFS) Read(path string) ([]byte, error) {
f, ok := m.files[path]
if !ok {
return nil, os.ErrNotExist
}
if f.isDir {
return nil, errors.New("is a directory")
}
return f.content, nil
}
func (m *MockFS) ReadDir(path string) ([]os.FileInfo, error) {
// Simple implementation: return files that start with path + "/"
// For deeper nesting, simulate direct children only.
// Assume flat structure for simplicity or specific test setup.
var infos []os.FileInfo
for p, f := range m.files {
// Just check if it's logically "inside" the directory
// e.g. path="/foo", file="/foo/bar" -> yes
// file="/foo/bar/baz" -> no (if strict direct children)
// file="/other" -> no
if p == path {
continue // self
}
// Check prefix
// if path is root "/", check if p has no other slashes?
// if path is "/foo", check if p starts with "/foo/" and has no further slashes
// This logic is simple but sufficient for tests.
// Let's assume absolute paths.
// If path does not end with /, append it for prefix check
prefix := path
if len(prefix) > 0 && prefix[len(prefix)-1] != '/' {
prefix += "/"
}
if len(p) > len(prefix) && p[:len(prefix)] == prefix {
// Ensure direct child (no more slashes)
rel := p[len(prefix):]
// Count slashes in rel. If 0, it's a direct child.
slashCount := 0
for _, c := range rel {
if c == '/' {
slashCount++
}
}
if slashCount == 0 {
infos = append(infos, f)
}
}
}
if len(infos) == 0 {
// Check if directory exists at all
if _, ok := m.files[path]; !ok {
return nil, os.ErrNotExist
}
}
return infos, nil
}
func (m *MockFS) Stat(path string) (os.FileInfo, error) {
f, ok := m.files[path]
if !ok {
return nil, os.ErrNotExist
}
return f, nil
}
func TestCachedClient_Stat(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/test.txt", []byte("hello"))
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Cache miss
info, err := c.Stat("/test.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Name() != "/test.txt" {
t.Errorf("expected name /test.txt, got %s", info.Name())
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
if c.hits.Load() != 0 {
t.Errorf("expected 0 hits, got %d", c.hits.Load())
}
// Second call: Cache hit
info2, err := c.Stat("/test.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info2.Size() != info.Size() {
t.Errorf("size mismatch")
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
// Verify valkey content
keys := mr.Keys()
if len(keys) != 1 {
t.Errorf("expected 1 key in redis, got %d", len(keys))
}
}
func TestCachedClient_Read(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
content := []byte("hello world")
mockFS.AddFile("/read.txt", content)
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Miss
data, err := c.Read("/read.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data) != string(content) {
t.Errorf("content mismatch")
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
// Second call: Hit
data2, err := c.Read("/read.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data2) != string(content) {
t.Errorf("content mismatch")
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_ReadDir(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddDir("/dir")
mockFS.AddFile("/dir/f1.txt", []byte("1"))
mockFS.AddFile("/dir/f2.txt", []byte("2"))
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Miss
infos, err := c.ReadDir("/dir")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos) != 2 {
t.Errorf("expected 2 files, got %d", len(infos))
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
// Second call: Hit
infos2, err := c.ReadDir("/dir")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos2) != 2 {
t.Errorf("expected 2 files, got %d", len(infos2))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateStat(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, _ = c.Stat("/reval.txt")
// Update mock file (simulate external change)
// For simplicity, just update size
f := mockFS.files["/reval.txt"]
f.content = []byte("updated content")
// Important: update modTime to be newer than cached
f.modTime = time.Now().Add(time.Minute)
// Run revalidation manually
// Note: revalidateStat is a private method, so we can test it directly here
// because we are in package filesystem (if using package filesystem_test we couldn't)
// But `revalidateStat` only iterates over keys in redis. So we need to ensure the key is there.
// `Stat` put it there.
// Wait, Stat puts it there. `revalidateStat` iterates keys matching `keyStat*`.
// For each key, it checks `Stat` again.
// Wait, looking at `revalidateStat` implementation:
// It calls `c.impl.Stat(path)`. If successful, it updates cache.
// It doesn't check ModTime explicitly for `Stat` revalidation, it just blindly updates?
// Let's check `revalidateStat` implementation in `cache.go`.
// func (c *CachedClient) revalidateStat(ctx context.Context) { ...
// info, err := c.impl.Stat(path)
// ...
// bytes, err := json.Marshal(f)
// c.client.Do(...Set...)
// }
// Yes, it blindly updates. So modification time doesn't matter for `Stat`, it just fetches fresh info.
c.revalidateStat(context.Background())
// Verify cache is updated by fetching from cache again
// We can inspect redis directly or trust `Stat` returns cached value (which should be updated).
// Let's inspect redis to be sure.
// Or just call Stat again. It should be a cache HIT, but return NEW values.
// Reset stats to check hit
c.hits.Store(0)
c.misses.Store(0)
info, err := c.Stat("/reval.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Size() != int64(len("updated content")) {
t.Errorf("expected size %d, got %d", len("updated content"), info.Size())
}
// Should be a hit because revalidate updated the cache entry, so it exists and is valid.
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateRead(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/read_reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, _ = c.Read("/read_reval.txt")
// Update mock file (simulate external change)
f := mockFS.files["/read_reval.txt"]
f.content = []byte("updated content")
f.modTime = time.Now().Add(time.Minute) // Newer ModTime triggers update
// Run revalidation manually
c.revalidateRead(context.Background())
// Verify cache is updated
// Reset stats
c.hits.Store(0)
c.misses.Store(0)
data, err := c.Read("/read_reval.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data) != "updated content" {
t.Errorf("expected updated content, got %s", string(data))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateReadDir(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddDir("/dir_reval")
mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, err = c.ReadDir("/dir_reval")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
// Update mock file (simulate external change)
mockFS.AddFile("/dir_reval/f2.txt", []byte("2"))
// Run revalidation manually
c.revalidateReadDir(context.Background())
// Verify cache is updated
// Reset stats
c.hits.Store(0)
c.misses.Store(0)
infos2, err := c.ReadDir("/dir_reval")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos2) != 2 {
t.Errorf("expected 2 files after revalidation, got %d", len(infos2))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
+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(),
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/valkey-io/valkey-go"
)
const keyRead = "webdav:read:"
type readFile struct {
Age time.Time `json:"age"`
Content []byte `json:"content"`
}
func (c *CachedClient) Read(path string) ([]byte, error) {
key := keyRead + path
ctx := context.Background()
// Try cache
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
if err == nil && len(cached) > 0 {
c.readHits.Add(1)
c.hits.Add(1)
var file readFile
err := json.Unmarshal(cached, &file)
if err != nil {
slog.Error("could not Unmarshal cache", "key", key, "err", err)
} else {
return file.Content, 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
}
file := readFile{
Age: time.Now(),
Content: data,
}
cached, err = json.Marshal(file)
if err != nil {
slog.Error("could not marshal file for cache", "key", key, "err", err)
return data, nil
}
// Cache
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(cached)).Ex(c.ttl).Build())
return data, nil
}
func (c *CachedClient) revalidateRead(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyRead+"*", func(key string) error {
path := strings.TrimPrefix(key, keyRead)
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
if err != nil {
// Key might be gone or error fetching
return nil
}
var cachedFile readFile
err = json.Unmarshal(cached, &cachedFile)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
info, err := c.impl.Stat(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return nil
}
if info.ModTime().After(cachedFile.Age) {
data, err := c.impl.Read(path)
if err != nil {
return err
}
cachedFile.Age = time.Now()
cachedFile.Content = data
newCached, err := json.Marshal(cachedFile)
if err != nil {
return err
}
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build())
} else {
c.cache.Do(ctx, c.cache.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build())
}
return nil
})
slog.Debug("revalidation of read complete", "duration", time.Since(startTime))
}
+78
View File
@@ -0,0 +1,78 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"time"
)
const keyReadDir = "webdav:readdir:"
func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
key := keyReadDir + path
ctx := context.Background()
// Try cache
val, err := c.cache.Do(ctx, c.cache.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)
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 {
cached = append(cached, ParseToFile(info))
}
bytes, err := json.Marshal(cached)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return infos, nil
}
func (c *CachedClient) revalidateReadDir(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyReadDir+"*", func(key string) error {
path := strings.TrimPrefix(key, keyReadDir)
infos, err := c.impl.ReadDir(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
// Serialize and cache
cached := make([]File, 0, len(infos))
for _, info := range infos {
cached = append(cached, ParseToFile(info))
}
bytes, err := json.Marshal(cached)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return nil
})
slog.Debug("revalidation of readDir complete", "duration", time.Since(startTime))
}
+66
View File
@@ -0,0 +1,66 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"time"
)
func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
key := keyStat + path
ctx := context.Background()
// Try cache
val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString()
if err == nil && val != "" {
var f File
if err := json.Unmarshal([]byte(val), &f); err == nil {
c.hits.Add(1)
return f, 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.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return
}
func (c *CachedClient) revalidateStat(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyStat+"*", func(key string) error {
path := strings.TrimPrefix(key, keyStat)
info, err := c.impl.Stat(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
// Serialize and cache
f := ParseToFile(info)
bytes, err := json.Marshal(f)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return nil
})
slog.Debug("revalidation of Stat complete", "duration", time.Since(startTime))
}
+46
View File
@@ -0,0 +1,46 @@
package middleware
import (
"fmt"
"log/slog"
"net/http"
"time"
)
type captureWriter struct {
http.ResponseWriter
code int
}
func (c *captureWriter) WriteHeader(statusCode int) {
c.code = statusCode
c.ResponseWriter.WriteHeader(statusCode)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
cw := &captureWriter{
ResponseWriter: w,
code: 200,
}
next.ServeHTTP(cw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
"status", cw.code,
)
})
}
// cacheMiddleware adds Cache-Control headers to the response.
func Cache(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)
})
}
}
+48
View File
@@ -0,0 +1,48 @@
package templer
import (
"context"
)
// GetHeader returns a slice of strings that should be added to the html head.
// Because strings are immutable a slice of strings is used.
func GetHeader(ctx context.Context) []string {
v := ctx.Value(headerKey)
if v == nil {
return []string{}
}
header, ok := v.([]string)
if !ok {
return []string{}
}
return header
}
// AppendHeader lets you add lines to the header.
func AppendHeader(ctx context.Context, v ...string) context.Context {
header := GetHeader(ctx)
header = append(header, v...)
return context.WithValue(ctx, headerKey, header)
}
// AppendHeader lets you add lines to the header.
func PageTitle(ctx context.Context, title string) context.Context {
return context.WithValue(ctx, titleKey, title)
}
// AppendHeader lets you add lines to the header.
func GetTitle(ctx context.Context, main string) string {
v := ctx.Value(titleKey)
if v == nil {
return main
}
title, ok := v.(string)
if !ok {
return main
}
return title + " | " + main
}
+36
View File
@@ -0,0 +1,36 @@
package templer
import (
"context"
"log/slog"
"net/http"
"strings"
)
type ctxKey int
const (
pathKey ctxKey = iota
headerKey
titleKey
)
func Middleware(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, pathKey, r.URL.Path)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
// Path returns the current path without a leading slash
func Path(ctx context.Context) string {
path, ok := ctx.Value(pathKey).(string)
if !ok {
slog.Error("could not extract request path from context")
return ""
}
return strings.TrimPrefix(path, "/")
}
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"fmt"
"net/http"
"path"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"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/handlers/rest"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restgallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
func (s *Server) allRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
registerStatic(mux)
other, err := s.dynamicRoutes()
if err != nil {
return
}
mux.Handle("/", other)
h = mux
h = templer.Middleware(h)
h = middleware.Logging(h)
return h, nil
}
func (s *Server) dynamicRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
webdavClient := gowebdav.NewClient(s.cfg.FileSystem.URL, s.cfg.FileSystem.User, s.cfg.FileSystem.Password)
err = webdavClient.Connect()
if err != nil {
err = fmt.Errorf("could not connect webdavClient: %w", err)
return
}
var fs filesystem.FS = webdavClient
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
InitAddress: s.cfg.Cache.InitAddress,
Password: s.cfg.Cache.Password,
})
if err != nil {
err = fmt.Errorf("failed to create valkey client: %w", err)
return
}
if err != nil {
} else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, s.cfg.Cache.RefreshTime, s.cfg.Cache.LifeTime)
}
imageSrv := images.New(fs, s.cfg.Image, valkeyClient)
mux.Handle("/images/", rest.Use(
restimage.New(imageSrv),
middleware.Cache(time.Hour*24*5),
))
translator := translate.New()
renderer := layouts.New(s.cfg)
mux.Handle("/", rest.Use(
func() http.Handler {
mux := http.NewServeMux()
mux.Handle(path.Join("/"+s.cfg.Gallery.Path), restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery))
mux.Handle("/", resthome.New(renderer, page.New(fs)))
return mux
}(),
translator.Middleware,
middleware.Cache(time.Minute),
))
return mux, nil
}
+69
View File
@@ -0,0 +1,69 @@
// Package server provides the http server for schreifuchs.ch.
package server
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
)
type Server struct {
port int
cfg config.Cfg
}
// Start starts a new http server. When the context gets canceled, the server stops gracefully.
func Start(ctx context.Context, cfg config.Cfg) (err error) {
port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 {
port = 8080
}
s := &Server{
port: port,
cfg: cfg,
}
hander, err := s.allRoutes()
if err != nil {
err = fmt.Errorf("could not setup routes: %w", err)
return
}
// Declare Server config
srv := &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: hander,
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
idleConnsClosed := make(chan struct{})
go func() {
<-ctx.Done()
slog.Info("shutting down server")
// Shutdown server when ctx done
if err = srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
err = fmt.Errorf("error while shutting down server: %w", err)
}
close(idleConnsClosed)
}()
slog.Info("starting server", "address", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
return fmt.Errorf("error while serving: %w", err)
}
<-idleConnsClosed
return err
}
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"io/fs"
"log/slog"
"net/http"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
)
// registerStatic registers all the static files.
func registerStatic(mux *http.ServeMux) {
fileSystem := web.GetStaticFS()
// we still use go's fileServer to avoid unnecessary implementation
fileServer := middleware.Cache(time.Hour * 24)(http.FileServer(http.FS(fileSystem)))
err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
path = "/" + path
slog.Debug("registered static file", "path", path)
mux.Handle(path, fileServer)
return nil
})
if err != nil {
slog.Error("could not register static files", "err", err)
panic(err)
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://inlang.com/schema/message-format-plugin",
"hello_world": "Hallo Welt",
"pfadi": "Pfadi",
"site_title": "Schreifuchs.ch",
"site_description": "Portfolio von Niklas - Fotografie, Informatik, Musik und Video."
}
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://inlang.com/schema/message-format-plugin",
"hello_world": "Hello World",
"pfadi": "Scouts",
"site_title": "Schreifuchs.ch",
"site_description": "Portfolio of Niklas - Photography, Informatics, Music and Video."
}
+26 -35
View File
@@ -1,37 +1,28 @@
{ {
"name": "schreifuchs", "name": "schreifuchs.ch",
"version": "0.0.1", "version": "1.0.0",
"private": true, "description": "",
"scripts": { "main": "index.js",
"dev": "vite dev", "scripts": {
"build": "vite build", "build:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --minify",
"preview": "vite preview", "watch:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --watch",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "build:js": "node scripts/build.mjs"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" },
}, "keywords": [],
"devDependencies": { "author": "",
"@sveltejs/adapter-auto": "^3.0.0", "license": "ISC",
"@sveltejs/adapter-node": "^5.5.1", "packageManager": "pnpm@10.28.2",
"@sveltejs/kit": "^2.5.27", "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/cli": "^4.1.18",
"@types/mime-types": "^3.0.1", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^25.0.9", "autoprefixer": "^10.4.24",
"autoprefixer": "^10.4.19", "esbuild": "^0.27.4",
"postcss": "^8.4.38", "postcss": "^8.5.6",
"svelte": "^5.0.0", "tailwindcss": "^4.1.18"
"svelte-check": "^4.0.0", },
"tailwindcss": "^3.4.3", "dependencies": {
"typescript": "^5.5.0", "htmx-ext-head-support": "^2.0.5",
"vite": "^5.4.4" "htmx-ext-preload": "^2.1.2",
}, "htmx.org": "^2.0.8"
"type": "module", }
"dependencies": {
"@fontsource/outfit": "^5.0.13",
"@inlang/paraglide-js": "^1.11.0",
"@inlang/paraglide-sveltekit": "^0.16.1",
"@ts-stack/markdown": "^1.5.0",
"mime-types": "^3.0.2",
"svelte-preprocess": "^6.0.2",
"webdav": "^5.8.0"
}
} }
+635 -3104
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "de",
"languageTags": [
"de",
"en"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
}
}
+69
View File
@@ -0,0 +1,69 @@
import * as esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
const filesToCopy = [
"node_modules/htmx.org/dist/htmx.min.js",
"node_modules/htmx-ext-preload/dist/preload.min.js",
"node_modules/htmx-ext-head-support/dist/head-support.min.js",
];
const filesToMinifyRoot = "web/src/js";
const outputRoot = "web/static/js";
async function minifyFiles() {
try {
const files = (await fs.readdir(filesToMinifyRoot))
.filter((p) => p.endsWith(".js"))
.map((p) => {
return {
src: path.join(filesToMinifyRoot, p),
dest: path.join(outputRoot, p.substring(0, p.length - 3) + ".min.js"),
};
});
for (const file of files) {
await esbuild.build({
entryPoints: [file.src],
bundle: false,
minify: true,
outfile: file.dest,
});
console.log(`Minified ${file.src} to ${file.dest}`);
}
} catch (err) {
console.error("could not minify files", err);
}
}
async function coppyFromNodemodules() {
try {
const fileMap = filesToCopy.map((file) => {
return {
src: file,
dest: path.join(outputRoot, path.basename(file)),
};
});
for (const file of fileMap) {
await fs.copyFile(file.src, file.dest);
console.log(`Copied ${path.basename(file.dest)}`);
}
} catch (err) {
console.error("JS build failed:", err);
process.exit(2);
}
}
async function build() {
console.log("Starting JS build...");
await minifyFiles();
await coppyFromNodemodules();
console.log("JS build complete!");
}
build();
-5
View File
@@ -1,5 +0,0 @@
@import "@fontsource/outfit";
@tailwind base;
@tailwind components;
@tailwind utilities;
-13
View File
@@ -1,13 +0,0 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>schreifuchs.ch</title>
<meta name="description" content="Portfolio von Niklas - Fotografie, Informatik, Musik und Video." />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
-3
View File
@@ -1,3 +0,0 @@
import { i18n } from "$lib/i18n"
export const handle = i18n.handle()
-27
View File
@@ -1,27 +0,0 @@
<script lang="ts">
import ImageTile from "./ImageTile.svelte";
import type { Snippet } from 'svelte';
interface Props {
src: string;
href: string;
target?: "_self" | "_blank" | "_parent" | "_top";
children?: Snippet;
}
let {
src,
href,
target = "_self",
children
}: Props = $props();
</script>
<div>
<ImageTile {src}>
<a {href} {target} class="flex items-center justify-center h-full">
{@render children?.()}
</a>
</ImageTile>
</div>
-50
View File
@@ -1,50 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
src: string;
alt?: string;
children?: Snippet;
}
let { src, alt = "", children }: Props = $props();
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
let imageSrc = $derived(src || placeholder);
// Generate srcset for optimized images from our proxy
let srcset = $derived.by(() => {
if (!src || !src.startsWith('/assets/')) return undefined;
const widths = [400, 800, 1200, 1600];
const sets = widths.map(w => `${src}${src.includes('?') ? '&' : '?'}w=${w} ${w}w`);
// Add original as fallback
sets.push(`${src} 2000w`);
return sets.join(', ');
});
const sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
</script>
<div class="grid grid-cols-1 grid-rows-1">
<img
src={imageSrc}
{srcset}
{sizes}
{alt}
loading="lazy"
class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.src = placeholder;
// Clear srcset on error to avoid loading broken thumbnails
img.srcset = "";
}}
/>
<div class="row-start-1 col-start-1">
{@render children?.()}
</div>
</div>
-98
View File
@@ -1,98 +0,0 @@
<script lang="ts">
import { Marked, Renderer } from "@ts-stack/markdown";
interface Props {
content?: string;
basePath?: string;
}
let { content = "", basePath = "" }: Props = $props();
const renderer = new Renderer();
// Custom image renderer to proxy through /assets/
renderer.image = (href, title, text) => {
let src = href;
if (href && !href.startsWith('http')) {
// Ensure basePath starts with / if provided
const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
// If href starts with /, it's relative to root. Otherwise, relative to basePath.
if (!href.startsWith('/')) {
src = `${normalizedBasePath}/${href}`.replace(/\/+/g, '/');
}
src = `/assets${src}`;
// Generate srcset for optimized images
const widths = [400, 800, 1200, 1600, 2000];
const srcset = widths
.map(w => `${src}?w=${w} ${w}w`)
.join(', ');
const sizes = "(max-width: 1024px) 100vw, 1024px";
return `<img src="${src}" srcset="${srcset}" sizes="${sizes}" alt="${text}" title="${title || ''}" loading="lazy" />`;
}
return `<img src="${src}" alt="${text}" title="${title || ''}" loading="lazy" />`;
};
let html = $derived(
Marked.parse(content || "__nothing to see here__", {
renderer,
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: false,
smartLists: true,
smartypants: false,
})
);
</script>
<div class="markdown">
{@html html}
</div>
<style global>
.markdown {
color: white;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.markdown h1 {
font-size: 2rem;
}
.markdown h2 {
font-size: 1.75rem;
}
.markdown h3 {
font-size: 1.5rem;
}
.markdown h4 {
font-size: 1.25rem;
}
.markdown * {
margin-bottom: 1rem;
}
.markdown img {
max-height: 90vh;
}
.markdown table,
.markdown tr,
.markdown th,
.markdown td {
border-collapse: collapse;
border: 1px solid;
padding: 0.3rem 1rem;
}
.markdown th {
font-size: 1.2rem;
}
</style>
-11
View File
@@ -1,11 +0,0 @@
import { createI18n } from "@inlang/paraglide-sveltekit"
import * as runtime from "$lib/paraglide/runtime"
export const i18n = createI18n(runtime, {
pathnames: {
"/about": {
en: "/about",
de: "/ueber-uns"
}
}
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
-43
View File
@@ -1,43 +0,0 @@
const cache = new Map<string, { timestamp: number; data: any }>();
const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
/**
* Caches the result of a promise-returning function.
* @param key Unique key for the cache entry.
* @param fetcher Function that returns a promise with the data to cache.
* @param ttl Time to live in milliseconds. Defaults to 15 minutes.
*/
export async function withCache<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL): Promise<T> {
const now = Date.now();
const entry = cache.get(key);
if (entry && (now - entry.timestamp < ttl)) {
return entry.data as T;
}
try {
const data = await fetcher();
cache.set(key, { timestamp: now, data });
return data;
} catch (error) {
console.error(`Error fetching data for key ${key}:`, error);
// If fetch fails but we have stale data, return it?
// For now, let's bubble the error up so the page handles it (e.g. 404),
// or maybe just re-throw.
throw error;
}
}
/**
* Manually clears a specific cache key.
*/
export function clearCache(key: string) {
cache.delete(key);
}
/**
* Clears the entire cache.
*/
export function clearAllCache() {
cache.clear();
}
-122
View File
@@ -1,122 +0,0 @@
import { createClient } from "webdav";
import { env } from "$env/dynamic/private";
import { withCache } from "./cache";
if (!env.NEXTCLOUD_URL || !env.NEXTCLOUD_USER || !env.NEXTCLOUD_PASSWORD) {
console.warn(
"Nextcloud environment variables are not fully set. WebDAV client might not work correctly.",
);
}
// Construct the standard Nextcloud WebDAV URL: https://example.com/remote.php/dav/files/USERNAME/[BASE_DIR/]
const getWebdavUrl = () => {
if (!env.NEXTCLOUD_URL) return "";
let url = env.NEXTCLOUD_URL.endsWith("/")
? env.NEXTCLOUD_URL
: `${env.NEXTCLOUD_URL}/`;
url += `remote.php/dav/files/${env.NEXTCLOUD_USER}/`;
if (env.NEXTCLOUD_BASE_DIR) {
// Remove leading/trailing slashes from base dir to ensure clean concatenation
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
if (baseDir) {
url += `${baseDir}/`;
}
}
return url;
};
export const client = createClient(getWebdavUrl(), {
username: env.NEXTCLOUD_USER || "",
password: env.NEXTCLOUD_PASSWORD || "",
});
export async function getCoverImage(slug: string): Promise<string | null> {
return withCache(`cover:${slug}`, async () => {
try {
const contents = await client.getDirectoryContents("/" + slug);
if (!Array.isArray(contents)) return null;
const cover = contents.find(
(item: any) =>
item.type === "file" &&
item.basename.match(/^cover\.(webp|jpg|jpeg|png)$/i),
);
return cover ? `/assets/${slug}/${cover.basename}` : null;
} catch (e) {
console.warn(`Failed to check cover image for ${slug}:`, e);
return null;
}
});
}
export async function getPages(lang: string = 'de') {
return withCache(`pages:${lang}`, async () => {
try {
const items = await client.getDirectoryContents("/");
if (!Array.isArray(items)) {
return [];
}
const directories = items.filter(
(item: any) =>
item.type === "directory" && !item.basename.startsWith("."),
);
const pages = await Promise.all(
directories.map(async (item: any) => {
const slug = item.basename;
const baseName = slug.charAt(0).toUpperCase() + slug.slice(1);
const filename = lang === 'de' ? `${baseName}.md` : `${baseName}.${lang}.md`;
const path = `/${slug}/${filename}`;
let title = baseName;
try {
const content = await getPageContent(path);
const headerMatch = content.match(/^#{1,6}\s+(.+)$/m);
if (headerMatch) {
title = headerMatch[1];
}
} catch (e) {
// Fallback to German if English is missing
if (lang !== 'de') {
try {
const fallbackContent = await getPageContent(`/${slug}/${baseName}.md`);
const headerMatch = fallbackContent.match(/^#{1,6}\s+(.+)$/m);
if (headerMatch) {
title = headerMatch[1];
}
} catch (innerE) {
console.warn(`Could not fetch fallback title for ${slug}`);
}
}
}
const coverImage = await getCoverImage(slug);
return {
slug,
title,
coverImage,
};
}),
);
return pages;
} catch (e) {
console.error("Failed to fetch pages from Nextcloud:", e);
return [];
}
});
}
export async function getPageContent(path: string): Promise<string> {
return withCache(`content:${path}`, async () => {
const content = await client.getFileContents(path, { format: "text" });
return content as string;
});
}
-5
View File
@@ -1,5 +0,0 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return ['de', 'en'].includes(param);
};
-9
View File
@@ -1,9 +0,0 @@
import type { LayoutServerLoad } from './$types';
import { getPages } from '$lib/server/nextcloud';
export const load: LayoutServerLoad = async ({ params }) => {
const pages = await getPages(params.lang);
return {
pages
};
};
-40
View File
@@ -1,40 +0,0 @@
<script lang="ts">
import "../app.css";
import type { Snippet } from 'svelte';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
import { languageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages';
let { children }: { children?: Snippet } = $props();
</script>
<svelte:head>
<title>{m.site_title()}</title>
<meta name="description" content={m.site_description()} />
</svelte:head>
<ParaglideJS {i18n}>
<header class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start">
<a href="/" class="text-white text-2xl">schreifuchs.ch</a>
<nav class="flex gap-2 p-2">
<a
href="/"
hreflang="de"
data-no-translate
class="text-white hover:underline {languageTag() === 'de' ? 'font-bold' : ''}"
>
DE
</a>
<a
href="/en"
hreflang="en"
data-no-translate
class="text-white hover:underline {languageTag() === 'en' ? 'font-bold' : ''}"
>
EN
</a>
</nav>
</header>
{@render children?.()}
</ParaglideJS>
-3
View File
@@ -1,3 +0,0 @@
// This can be false if you're using a fallback (i.e. SPA mode)
// export const prerender = true;
// export const trailingSlash = 'always';
-49
View File
@@ -1,49 +0,0 @@
<script lang="ts">
import ImageLinkTile from "$lib/components/ImageLinkTile.svelte";
import type { PageData } from "./$types";
import { i18n } from "$lib/i18n";
import * as m from "$lib/paraglide/messages";
// Import local images to preserve existing design
import mouse from "$lib/images/home/mouse.webp";
import drummachine from "$lib/images/home/drummachine.webp";
import scouts from "$lib/images/home/scouts.webp";
import console_image from "$lib/images/home/console.webp";
import lauch from "$lib/images/home/lauch.webp";
let { data }: { data: PageData } = $props();
// Map slugs to local images for backward compatibility/styling
const staticImages: Record<string, string> = {
informatik: console_image,
fotos: mouse,
video: lauch,
musig: drummachine,
};
// Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile)
const getTileImage = (page: { slug: string; coverImage: string | null }) => {
return staticImages[page.slug.toLowerCase()] || page.coverImage || "";
};
</script>
<main class="">
{#if data.pages}
{#each data.pages as page}
<ImageLinkTile src={getTileImage(page)} href={`/${page.slug}`}>
<h2
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
>
{page.title}
</h2>
</ImageLinkTile>
{/each}
{/if}
<!-- Static/External Links -->
<ImageLinkTile src={scouts} href="https://pfadifrisco.ch/" target="_blank">
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
{m.pfadi()}
</h2>
</ImageLinkTile>
</main>
@@ -1,49 +0,0 @@
import { getCoverImage, getPageContent } from "$lib/server/nextcloud";
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ params }) => {
const { slug, lang } = params;
const language = lang || 'de';
// Capitalize the first letter to match the convention: fotos -> Fotos.md
const baseName = slug.charAt(0).toUpperCase() + slug.slice(1);
const filename = language === 'de' ? `${baseName}.md` : `${baseName}.${language}.md`;
const path = `/${slug}/${filename}`;
try {
let rawContent;
try {
rawContent = await getPageContent(path);
} catch (e) {
if (language !== 'de') {
console.warn(`Localized content ${filename} not found, falling back to German.`);
rawContent = await getPageContent(`/${slug}/${baseName}.md`);
} else {
throw e;
}
}
// Extract the first header (e.g., # Title or ## Title)
const headerMatch = rawContent.match(/^#{1,6}\s+(.+)$/m);
const title = headerMatch ? headerMatch[1] : baseName;
// Remove the first header from content so it's not rendered twice
const content = headerMatch
? rawContent.replace(headerMatch[0], "").trim()
: rawContent;
const coverImage = await getCoverImage(slug);
return {
content,
title,
slug,
coverImage,
lang: language
};
} catch (e) {
console.error(`Error fetching ${filename} from Nextcloud`, e);
throw error(404, "Page not found");
}
};
@@ -1,46 +0,0 @@
<script lang="ts">
import ImageTile from "$lib/components/ImageTile.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import type { PageData } from "./$types";
// Legacy images
import sunne_untergang from "$lib/images/fotos/sunne_untergang.webp";
import monitor from "$lib/images/informatik/monitor.webp";
import plattespiler from "$lib/images/musig/plattespiler.webp";
import jochen from "$lib/images/video/jochen.webp";
let { data }: { data: PageData } = $props();
const legacyHeaders: Record<string, string> = {
fotos: sunne_untergang,
informatik: monitor,
musig: plattespiler,
video: jochen
};
// Fallback image data URI
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
// Use the cover image found by the server, or legacy local image, or placeholder
let headerImage = $derived(
data.coverImage ||
legacyHeaders[data.slug.toLowerCase()] ||
"" // ImageTile handles empty/null src with placeholder or its own logic
);
</script>
<svelte:head>
<title>{data.title} - schreifuchs.ch</title>
</svelte:head>
<ImageTile src={headerImage} alt={data.title}>
<h2 class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg">
{data.title}
</h2>
</ImageTile>
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
<section class="max-w-screen-md w-full">
<Markdown content={data.content} basePath={`/${data.slug}`} />
</section>
</main>
-85
View File
@@ -1,85 +0,0 @@
import { client } from "$lib/server/nextcloud";
import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import mime from "mime-types";
import { env } from "$env/dynamic/private";
export const GET: RequestHandler = async ({ params, url }) => {
const { path } = params;
const width = url.searchParams.get("w");
const height = url.searchParams.get("h");
if (!path) {
throw error(400, "Path is required");
}
try {
let buffer: ArrayBuffer;
let contentType: string;
if (width || height) {
// Use Nextcloud Preview API
const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`;
let fullPath = path;
if (env.NEXTCLOUD_BASE_DIR) {
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
fullPath = baseDir ? `${baseDir}/${path}` : path;
}
const previewUrl = new URL(`${baseUrl}index.php/core/preview`);
// Ensure leading slash for the file parameter
const fileParam = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
previewUrl.searchParams.set("file", fileParam);
if (width) previewUrl.searchParams.set("x", width);
if (height) previewUrl.searchParams.set("y", height);
previewUrl.searchParams.set("forceIcon", "0");
const auth = Buffer.from(`${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`).toString("base64");
console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`);
try {
const response = await fetch(previewUrl.toString(), {
headers: {
"Authorization": `Basic ${auth}`
}
});
if (response.ok) {
buffer = await response.arrayBuffer();
contentType = response.headers.get("Content-Type") || "image/png";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
"Vary": "Accept-Encoding"
}
});
} else {
console.warn(`Nextcloud preview failed (${response.status} ${response.statusText}) for ${fullPath}. Falling back to original.`);
}
} catch (fetchError) {
console.error(`Fetch error during preview request for ${fullPath}:`, fetchError);
}
}
// Fetch original via WebDAV (Fallback or no resize requested)
console.log(`Fetching original file via WebDAV: /${path}`);
const data = await client.getFileContents("/" + path);
buffer = data as ArrayBuffer;
contentType = mime.lookup(path) || "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
"Vary": "Accept-Encoding"
}
});
} catch (e) {
console.error(`Error fetching asset from Nextcloud: ${path}`, e);
throw error(404, "Asset not found");
}
};
-11
View File
@@ -1,11 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { sveltePreprocess } from 'svelte-preprocess';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
},
preprocess: [vitePreprocess(), sveltePreprocess({})]
};
export default config;
-29
View File
@@ -1,29 +0,0 @@
import plugin from "tailwindcss/plugin"
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'outfit', 'sans-serif']
},
textShadow: {
sm: '0 1px 2px #000',
DEFAULT: '0 2px 4px #000',
lg: '0px 0px 20px #000',
},
},
},
plugins: [
plugin(function({ matchUtilities, theme }) {
matchUtilities(
{
'text-shadow': (value) => ({
textShadow: value,
}),
},
{ values: theme('textShadow') }
)
}),
],
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+62
View File
@@ -0,0 +1,62 @@
package testdata
import (
"bytes"
"embed"
"io"
"os"
"strings"
)
//go:embed *
var fs embed.FS
func FS() mockFS {
return mockFS{
FS: fs,
}
}
type mockFS struct {
embed.FS
}
type mockMimer struct{ os.FileInfo }
func (m *mockMimer) ContentType() string {
parts := strings.Split(m.Name(), ".")
suffix := parts[len(parts)-1]
switch suffix {
case "jpg":
return "image/jpeg"
default:
return ""
}
}
func (m mockFS) Read(path string) (b []byte, err error) {
file, err := m.Open(path)
if err != nil {
return
}
defer file.Close()
buff := bytes.NewBuffer(make([]byte, 0, 1024))
_, err = io.Copy(buff, file)
return buff.Bytes(), err
}
func (m mockFS) Stat(path string) (info os.FileInfo, err error) {
file, err := m.Open(path)
if err != nil {
return
}
info, err = file.Stat()
info = &mockMimer{info}
return
}
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

Some files were not shown because too many files have changed in this diff Show More