Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db36e2ada | ||
|
|
9ca81d7f13 | ||
|
|
c55e6bc43f | ||
|
|
efcc32b522 | ||
|
|
8a72b96e66 | ||
|
|
9282f1f634 | ||
|
|
ee3eba3cd9 | ||
|
|
b384c70c39 | ||
|
|
1c67eb0cff | ||
|
|
16e5107185 | ||
|
|
2b07701762 | ||
|
|
a27b4a66e6 | ||
|
|
bc900e1450 | ||
|
|
1310b7b243 | ||
|
|
01b24f830f | ||
|
|
eab7eb3879 | ||
|
|
fca1030e1a | ||
|
|
2f7ff28c9a | ||
|
|
82e601fd7f | ||
|
|
f5bcc7c381 | ||
|
|
4efd3ee396 | ||
|
|
9ad26910a9 | ||
|
|
e575f34b34 | ||
|
|
2491d21963 | ||
|
|
6c1387c1cd | ||
|
|
3cc632e358 | ||
|
|
e689ab08c9 | ||
|
|
19fb5b9292 | ||
|
|
9a1f7e0d99 | ||
|
|
dd5e32cc4d | ||
|
|
b4e8baabdc | ||
|
|
ab3c8982e2 | ||
|
|
df9e6e95cb | ||
|
|
2154d6cfa8 | ||
|
|
9002f13f80 | ||
|
|
c937be1274 |
@@ -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
|
||||
@@ -8,6 +8,5 @@ build
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
TODO_NEXTCLOUD.md
|
||||
GEMINI.md
|
||||
pnpm-workspace.yaml
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/tmp
|
||||
/tmp/*
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
/web/**/*_templ.go
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,46 +1,48 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:24-slim AS builder
|
||||
|
||||
# Enable corepack to use pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Stage 1: Build frontend assets
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy configuration files
|
||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||
# Enable pnpm via corepack
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Install all dependencies (including devDependencies)
|
||||
# Install dependencies
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
# Copy source and build assets
|
||||
COPY . .
|
||||
RUN pnpm run build:css && pnpm run build:js
|
||||
|
||||
# Build the SvelteKit application
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Run the application
|
||||
FROM node:24-slim AS runner
|
||||
|
||||
# Enable corepack to use pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.26-alpine AS go-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the build output and necessary files for production
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=builder /app/.npmrc ./.npmrc
|
||||
# Download Go modules
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Install only production dependencies
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose the port the app runs on (SvelteKit defaults to 3000)
|
||||
EXPOSE 3000
|
||||
# Copy built frontend assets from the previous stage
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
# Generate templ files using the tool defined in go.mod
|
||||
RUN go tool templ generate
|
||||
|
||||
# Run the application
|
||||
CMD ["node", "build"]
|
||||
# Build the optimized binary
|
||||
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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
.PHONY: run
|
||||
run: docker
|
||||
air
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
docker compose up -d
|
||||
@@ -2,33 +2,40 @@
|
||||
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **Performance:** In-memory caching for Nextcloud requests (15-minute TTL).
|
||||
- **Responsive Design:** Styled with Tailwind CSS, optimized for all screen sizes.
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
- **Backend:** Node.js (via `@sveltejs/adapter-node`)
|
||||
- **Integration:** `webdav` library for Nextcloud communication
|
||||
- **Content:** `@ts-stack/markdown` for rendering
|
||||
- **Caching:** Valkey
|
||||
- **Integration:** `gowebdav` for Nextcloud communication
|
||||
- **Content:** `github.com/gomarkdown/markdown` for rendering
|
||||
|
||||
## Configuration
|
||||
|
||||
The application requires the following environment variables in a `.env` file:
|
||||
|
||||
```env
|
||||
NEXTCLOUD_URL=https://your-nextcloud-instance.com
|
||||
NEXTCLOUD_USER=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content
|
||||
FILESYSTEM_URL=https://your-nextcloud-instance.com/remote.php/dav/files/user/
|
||||
FILESYSTEM_USERNAME=your_username
|
||||
FILESYSTEM_PASSWORD=your_app_password
|
||||
CACHE_ADDRESS=localhost:6379
|
||||
CACHE_PASSWORD=your_valkey_password
|
||||
PORT=8080
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
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.
|
||||
4. **Add Assets:** Any other images or files uploaded to the folder can be linked in the Markdown.
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies:
|
||||
Install dependencies (Go, pnpm, and the `templ` binary):
|
||||
|
||||
```bash
|
||||
# Install Go dependencies
|
||||
go mod tidy
|
||||
# Install JS/CSS dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
Start the development server with hot reloading:
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
air
|
||||
```
|
||||
|
||||
For CSS changes, run in a separate terminal:
|
||||
|
||||
```bash
|
||||
pnpm run watch:css
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Build for production (Node.js):
|
||||
Build the project for production:
|
||||
|
||||
```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
|
||||
|
||||
To optimize images before uploading to Nextcloud:
|
||||
@@ -71,3 +91,4 @@ To optimize images before uploading to Nextcloud:
|
||||
```bash
|
||||
convert input.png -resize 3000 -quality 80 output.webp
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,86 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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, 999999)
|
||||
|
||||
for len(imgs) > 0 {
|
||||
unused := make([]Image, 0, len(imgs))
|
||||
for _, img := range imgs {
|
||||
adjustImageSize(&img, 200)
|
||||
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
|
||||
}
|
||||
|
||||
// adjustImageSize adjusts the image size so that the aspect ratio stays the same
|
||||
// but the image area is the given parameter.
|
||||
func adjustImageSize(img *Image, area int) {
|
||||
// Calculate the original aspect ratio (W / H)
|
||||
aspectRatio := float64(img.Width) / float64(img.Height)
|
||||
|
||||
// Calculate new height and width using the derived formulas
|
||||
newHeight := math.Sqrt(float64(area) / aspectRatio)
|
||||
newWidth := newHeight * aspectRatio
|
||||
|
||||
// Round to the nearest integer to minimize precision loss
|
||||
img.Width = int(math.Round(newWidth))
|
||||
img.Height = int(math.Round(newHeight))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
slog.Debug("loaded files", "files", files)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
imgs = append(imgs, Image{
|
||||
UID: uid,
|
||||
Width: img.Bounds().Dx(),
|
||||
Height: img.Bounds().Dy(),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FS interface {
|
||||
ReadDir(path string) ([]os.FileInfo, error)
|
||||
Read(path string) ([]byte, error)
|
||||
Stat(path string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type FileInfo interface {
|
||||
os.FileInfo
|
||||
ContentType() string
|
||||
Path() string
|
||||
}
|
||||
|
||||
// File implements FileInfo
|
||||
type File struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
isDir bool
|
||||
contentType string
|
||||
path string
|
||||
}
|
||||
|
||||
func (f File) Name() string { return f.name }
|
||||
func (f File) Size() int64 { return f.size }
|
||||
func (f File) Mode() os.FileMode { return f.mode }
|
||||
func (f File) ModTime() time.Time { return f.modTime }
|
||||
func (f File) IsDir() bool { return f.isDir }
|
||||
func (f File) Sys() any { return nil }
|
||||
func (f File) ContentType() string { return f.contentType }
|
||||
func (f File) Path() string { return f.path }
|
||||
|
||||
type serializableFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Mode os.FileMode `json:"mode"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ContentType string `json:"contentType"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (f File) MarshalJSON() ([]byte, error) {
|
||||
// Create a local shadow struct with exported fields for the JSON package
|
||||
return json.Marshal(serializableFile{
|
||||
Name: f.name,
|
||||
Size: f.size,
|
||||
Mode: f.mode,
|
||||
ModTime: f.modTime,
|
||||
IsDir: f.isDir,
|
||||
ContentType: f.contentType,
|
||||
Path: f.path,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler
|
||||
func (f *File) UnmarshalJSON(data []byte) error {
|
||||
// Define a shadow struct to capture the incoming JSON
|
||||
shadow := serializableFile{}
|
||||
|
||||
if err := json.Unmarshal(data, &shadow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Transfer values to the actual struct
|
||||
f.name = shadow.Name
|
||||
f.size = shadow.Size
|
||||
f.mode = shadow.Mode
|
||||
f.modTime = shadow.ModTime
|
||||
f.isDir = shadow.IsDir
|
||||
f.contentType = shadow.ContentType
|
||||
f.path = shadow.Path
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseToFile(file os.FileInfo) File {
|
||||
if fi, ok := file.(FileInfo); ok {
|
||||
return File{
|
||||
name: fi.Name(),
|
||||
size: fi.Size(),
|
||||
mode: fi.Mode(),
|
||||
modTime: fi.ModTime(),
|
||||
isDir: fi.IsDir(),
|
||||
contentType: fi.ContentType(),
|
||||
path: fi.Path(),
|
||||
}
|
||||
} else {
|
||||
// Fallback if underlying impl returns generic os.FileInfo
|
||||
return File{
|
||||
name: file.Name(),
|
||||
size: file.Size(),
|
||||
mode: file.Mode(),
|
||||
modTime: file.ModTime(),
|
||||
isDir: file.IsDir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -1,37 +1,28 @@
|
||||
{
|
||||
"name": "schreifuchs",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.1",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^25.0.9",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.4"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"name": "schreifuchs.ch",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --minify",
|
||||
"watch:css": "tailwindcss -i ./web/src/css/input.css -o ./web/static/css/output.css --watch",
|
||||
"build:js": "node scripts/build.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"esbuild": "^0.27.4",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"htmx-ext-head-support": "^2.0.5",
|
||||
"htmx-ext-preload": "^2.1.2",
|
||||
"htmx.org": "^2.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
@import "@fontsource/outfit";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
import { i18n } from "$lib/i18n"
|
||||
|
||||
export const handle = i18n.handle()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Before Width: | Height: | Size: 661 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 766 KiB |
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return ['de', 'en'].includes(param);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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') }
|
||||
)
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 MiB |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||