Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e55e9871cb | |||
| f9ba22ffba | |||
| 8288827c69 | |||
| 8f15d3202c | |||
| 34680480ed | |||
| c00f33fa7d | |||
| 0b55e97cad | |||
| ed13013e71 | |||
| 5b2f35f4ff | |||
| c83768785f |
-134
@@ -1,134 +0,0 @@
|
||||
# PNGer Development & Technical Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
PNGer is a single-container web application for PNG editing and resizing, designed for deployment on Unraid with Gitea version control. It features a modern Svelte frontend and a high-performance Node.js/Sharp backend.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
PNGer uses a multi-layered architecture designed for responsiveness and efficiency:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User Browser] --> B[Svelte Frontend]
|
||||
B --> C[Canvas API (Live Preview)]
|
||||
B --> D[Express API (Backend)]
|
||||
D --> E[Sharp Image Library]
|
||||
D --> F[Runtime Environment (Docker)]
|
||||
```
|
||||
|
||||
### Backend (Express + Sharp)
|
||||
|
||||
The backend is built with Node.js and TypeScript, using Express for the API and Sharp for high-performance image processing.
|
||||
|
||||
**Key Endpoints:**
|
||||
- `POST /api/transform` - Transform image (resize, crop, compress, convert)
|
||||
- `GET /api/health` - Health check
|
||||
|
||||
**Key Dependencies:**
|
||||
- `sharp`: High-performance image processing (handles resizing, cropping, and format conversion).
|
||||
- `multer`: Middleware for handling `multipart/form-data`, used for file uploads.
|
||||
- `express`: Web framework for the API.
|
||||
|
||||
### Frontend (Svelte + Vite)
|
||||
|
||||
The frontend is a reactive Svelte application that prioritizes real-time feedback and UX.
|
||||
|
||||
**Core Components & Modules:**
|
||||
- `App.svelte`: Main application container managing state and UI.
|
||||
- `lib/api.ts`: API client for backend communication.
|
||||
- `lib/preview.ts`: Client-side preview logic using the Canvas API for instant feedback.
|
||||
- `lib/theme.ts`: Dark/light theme management with persistent storage.
|
||||
- `lib/presets.ts`: Configuration for smart image presets.
|
||||
|
||||
### Design System (Dark/Light Modes)
|
||||
|
||||
The UI uses a modern design system with CSS custom properties for theming:
|
||||
- **Light Mode**: Clean white background with dark gold (`#b8860b`) accents.
|
||||
- **Dark Mode**: Deep black (`#0a0a0a`) background with light gold (`#daa520`) accents.
|
||||
- **Transparencies**: Dark/Light toggling allows users to inspect PNG transparency against different backgrounds.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Live Preview System
|
||||
|
||||
Live preview is implemented using a client-side Canvas-based approach to provide instant feedback (< 100ms) without server round-trips.
|
||||
|
||||
**How it works:**
|
||||
1. User uploads an image.
|
||||
2. The browser loads the image into an HTML5 Canvas.
|
||||
3. On any parameter change (width, quality, etc.), a debounced (300ms) update applies transformations to the canvas.
|
||||
4. The canvas content is displayed side-by-side with the original for comparison.
|
||||
5. File sizes are estimated from the Canvas data URL to provide immediate feedback on optimization savings.
|
||||
|
||||
### Docker Strategy & Fixes
|
||||
|
||||
PNGer uses a multi-stage Docker build to minimize image size and maximize security.
|
||||
|
||||
**Optimization Fixes applied:**
|
||||
- **Dependency Installation**: Uses `npm install` instead of `npm ci` to handle missing lockfiles gracefully while maintaining stability via caret ranges in `package.json`.
|
||||
- **Security**: Runs as a non-root `node` user in the final Alpine-based image.
|
||||
- **Multer Upgrade**: Upgraded to `v2.1.0` for improved security and performance.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- npm or pnpm
|
||||
- Docker (optional)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
|
||||
# Setup backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Setup frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Backend (.env):**
|
||||
- `PORT`: 3000 (internal)
|
||||
- `MAX_FILE_SIZE`: 10485760 (10MB default)
|
||||
- `CORS_ORIGIN`: http://localhost:5173
|
||||
|
||||
**Frontend (.env):**
|
||||
- `VITE_API_URL`: http://localhost:3000/api
|
||||
|
||||
## Development Workflow & Standards
|
||||
|
||||
### Workflow
|
||||
1. **Feature Branch**: Create from `main`.
|
||||
2. **Develop**: Use hot-reload (`npm run dev`).
|
||||
3. **Test**: Perform manual testing of image operations and presets.
|
||||
4. **Commit**: Use `type: description` format (e.g., `feat: add rotation`).
|
||||
5. **Merge**: Merge into `main` after review.
|
||||
|
||||
### Code Standards
|
||||
- **TypeScript**: Use strict types where possible.
|
||||
- **Svelte**: Keep components modular and under 300 lines.
|
||||
- **Async/Await**: Use for all asynchronous operations.
|
||||
- **Semantic HTML**: Ensure accessibility and proper structure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Port in use**: `lsof -ti:3000 | xargs kill -9`
|
||||
- **Sharp issues**: `npm rebuild sharp`
|
||||
- **Docker Cache**: `docker builder prune` if builds fail unexpectedly.
|
||||
- **Preview Glitches**: Check browser console for Canvas API errors.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: March 12, 2026
|
||||
@@ -1,49 +1,80 @@
|
||||
# PNGer - Modern PNG Editor & Resizer
|
||||
# PNGer — Modern PNG Editor & Resizer
|
||||
|
||||
A sleek, modern PNG editor and resizer with **live preview**, **drag & drop**, **smart presets**, **keyboard shortcuts**, and **dark/light mode theming**. Built with TypeScript and optimized for deployment as a single Docker container.
|
||||
A sleek, self-hosted image resizer with **live preview**, **drag & drop**, **smart presets**, **keyboard shortcuts**, and **dark/light theming**. Drop an image in, watch the before/after and the file-size delta update as you drag the sliders, hit Enter, get the file. Nothing is stored on the server — uploads are processed in memory and streamed straight back as a download.
|
||||
|
||||
## ✨ Features
|
||||
Svelte + Vite on the front, Express + [Sharp](https://sharp.pixelplumbing.com/) on the back, shipped as one Docker container that serves both. Built for Unraid.
|
||||
|
||||
- **🎨 Modern UI**: Beautiful dark/light mode for inspecting transparency.
|
||||
- **⚡ Live Preview**: Instant side-by-side comparison with file size analysis.
|
||||
- **🚀 Efficiency**: Drag & Drop upload, clipboard paste (`Ctrl+V`), and smart presets.
|
||||
- **🖼️ Precision**: Control width, height, quality, and crop positions (9 modes).
|
||||
- **📦 Reliable Deployment**: Multi-stage Docker build optimized for Unraid and Gitea.
|
||||
Despite the name it is not PNG-only: it reads anything Sharp can decode and writes **PNG, WebP or JPEG**.
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Docs
|
||||
|
||||
- **[Installation & configuration guide](docs/INSTALL.md)** — start here. Local dev, Docker, `docker compose`, the full env-var table, CI, health checks, upgrades.
|
||||
- **[Unraid install](docs/UNRAID.md)** — step-by-step Docker GUI walkthrough (template fields, PUID/PGID/TZ, ports).
|
||||
- **[Development & technical notes](docs/INSTRUCTIONS.md)** — architecture, the live-preview design, code standards, troubleshooting.
|
||||
- **[Roadmap](docs/ROADMAP.md)** — what shipped in Sprints 0–1 and what is queued for Sprints 2–4.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Resizes and crops** — set a width, a height, or both. *Resize only* (`fit: inside`) preserves aspect ratio; *Crop to fit box* (`fit: cover`) fills the box and crops the overflow from one of **nine anchor positions** (center, the four edges, the four corners).
|
||||
- **Converts and compresses** — output as PNG (compression level 9), WebP or JPEG, with a 1–100 quality control.
|
||||
- **Previews client-side before it touches the server** — the browser re-renders the image on a `<canvas>` 300 ms after you stop typing, so you get a side-by-side original/result comparison plus an estimated file-size delta ("↓ 412.3 KB saved (68.1%)") with no round-trip. Only the final download hits the API.
|
||||
- **Eight smart presets** — Web Thumbnail (300×300 WebP), Social Media / Open Graph (1200×630), Profile Picture (400×400), Email Friendly (600 wide JPEG q70), HD Quality (1920 wide), Retina @2x (doubles whatever you have typed), Icon Small (64×64) and Icon Large (256×256). One click sets width, height, quality, format and fit.
|
||||
- **Gets images in three ways** — drag & drop onto the drop zone, click to browse, or paste from the clipboard with `Ctrl+V` / `Cmd+V`.
|
||||
- **Themes light or dark** — gold-accented design system driven by CSS custom properties, remembered in `localStorage`, defaulting to your OS `prefers-color-scheme`. The point is practical: toggle the background to check PNG transparency against black and white.
|
||||
- **Stores nothing** — `multer` keeps the upload in memory, Sharp transforms it, Express sends the buffer back with `Content-Disposition: attachment`. No database, no disk writes, no cleanup job.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Ctrl+V` / `Cmd+V` | Paste an image from the clipboard |
|
||||
| `Enter` | Transform & download (when no input is focused) |
|
||||
| `Ctrl+Enter` / `Cmd+Enter` | Transform & download (works from anywhere) |
|
||||
| `?` | Toggle the shortcuts dialog |
|
||||
| `Esc` | Close the shortcuts dialog |
|
||||
|
||||
## Shape
|
||||
|
||||
- **Stack** — frontend: Svelte 4 + Vite 5 + TypeScript, no UI framework, no router, one `App.svelte` plus four `lib/` modules (`api`, `preview`, `presets`, `theme`). Backend: Node 20 + Express 4 + TypeScript, `sharp` for the pixels, `multer` (memory storage) for the upload, `cors`.
|
||||
- **API** — one endpoint. `POST /api/transform`, `multipart/form-data`, field `file`, with optional `width`, `height`, `quality` (default 80), `format` (`png` | `webp` | `jpeg`, default `png`), `fit` (default `inside`) and `position` (default `center`). Responds with the image bytes as an attachment, or `400` if no file / `500` if Sharp throws. Resizes use `withoutEnlargement: true`, so output is never larger than the source.
|
||||
- **Data** — none. Stateless; no volumes required.
|
||||
- **Serving** — in production the compiled frontend is copied to `dist/public` and served by the same Express process that answers `/api`, with an `app.get("*")` fallback to `index.html` for SPA routing. One port, one origin, no CORS in play.
|
||||
- **Hosting** — three-stage Docker build (frontend build → backend `tsc` → `node:20-alpine` runtime with `apk upgrade`, `su-exec` + `shadow`). `docker-entrypoint.sh` remaps the `node` user to `PUID`/`PGID` (Unraid defaults 99/100), chowns `/app`, then drops privileges. Listens on `3000`, `HEALTHCHECK` every 30 s, `EXPOSE 3000`. CI builds on every push to `main` and pushes `registry.alwisp.com/jason/pnger:latest`.
|
||||
|
||||
## Quick start
|
||||
|
||||
**Docker (single container):**
|
||||
|
||||
### Docker/Unraid Deployment
|
||||
1. **Clone & Build**:
|
||||
```bash
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
docker build -t pnger:latest .
|
||||
docker compose up -d --build
|
||||
```
|
||||
2. **Run**:
|
||||
|
||||
Then open <http://localhost:8080> (host port is `HOST_PORT`, default `8080` → container `3000`).
|
||||
|
||||
**Local development** — two terminals, Node 20+:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
cd backend && npm install && npm run dev # tsc watch on :3000
|
||||
cd frontend && npm install && npm run dev # Vite on :5173
|
||||
```
|
||||
3. **Access**: `http://localhost:8080` (or your Unraid IP)
|
||||
|
||||
### Local Development
|
||||
1. **Install & Run Backend**: `cd backend && npm install && npm run dev`
|
||||
2. **Install & Run Frontend**: `cd frontend && npm install && npm run dev`
|
||||
3. **Access**: `http://localhost:5173`
|
||||
Open <http://localhost:5173>. In dev the frontend calls `http://localhost:3000/api` directly (`import.meta.env.DEV` branch in `lib/api.ts`); in a production build it calls the same-origin `/api`.
|
||||
|
||||
## 📚 Documentation
|
||||
Full details, env vars and the Unraid path: **[docs/INSTALL.md](docs/INSTALL.md)**.
|
||||
|
||||
For more detailed information, please refer to:
|
||||
- **[INSTRUCTIONS.md](./INSTRUCTIONS.md)**: Technical architecture, development setup, code standards, and troubleshooting.
|
||||
- **[ROADMAP.md](./ROADMAP.md)**: Project history, sprint updates, and future feature plans.
|
||||
## Known rough edges
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
Documented rather than hidden — see [docs/INSTRUCTIONS.md](docs/INSTRUCTIONS.md) for the detail:
|
||||
|
||||
- `Ctrl+V`: Paste image from clipboard
|
||||
- `Enter`: Download (when input not focused)
|
||||
- `?`: Show shortcuts help
|
||||
- `Esc`: Close dialogs
|
||||
- **The preview is an approximation.** It is a Canvas re-encode, not Sharp. Sizes are estimated from the data URL, PNG quality has no effect in Canvas (it is always lossless there) but does affect the real output, and the preview will happily upscale where the server will not.
|
||||
- **`MAX_FILE_SIZE` is not enforced.** It is set in the `Dockerfile`, compose file and Unraid docs, but the live upload middleware has no `limits`, so the value currently does nothing.
|
||||
- **`backend/src/server.js` is not the server.** It is an older standalone implementation (`/api/health`, `/api/process`, `/api/metadata`, helmet, PNG-only filter, size limits) that nothing compiles or runs. The live entrypoint is `backend/src/index.ts`.
|
||||
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
**Repository**: [https://git.alwisp.com/jason/pnger](https://git.alwisp.com/jason/pnger)
|
||||
**License**: MIT · **Repository**: <https://git.alwisp.com/jason/pnger>
|
||||
|
||||
---
|
||||
*MPM — Born to Innovate. Built to Last.*
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Installation & configuration
|
||||
|
||||
PNGer is a single stateless container: Express serves both the API and the compiled Svelte frontend on one port. There is no database, no persistent volume, and nothing to migrate.
|
||||
|
||||
| Path | Use when | Where |
|
||||
| --- | --- | --- |
|
||||
| **Unraid Docker GUI** | Production on Unraid. | [UNRAID.md](UNRAID.md) — click-by-click. |
|
||||
| **`docker compose`** | Any Docker host. | §2 below. |
|
||||
| **Plain `docker run`** | Ad hoc. | §3 below. |
|
||||
| **Local Node** | Development. | §4 below. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- **Docker** for the container paths. Nothing else — no runtime deps on the host.
|
||||
- **Node 20+ and npm** for local development. Sharp ships prebuilt binaries for common platforms; if `npm install` builds it from source you also need a C++ toolchain.
|
||||
- **A free host port.** The container listens on `3000`; the compose file publishes it on `8080` by default.
|
||||
|
||||
No volume is required. No reverse proxy is required either, though you will want one if you expose it beyond the LAN — PNGer has **no authentication of any kind**.
|
||||
|
||||
---
|
||||
|
||||
## 2. `docker compose`
|
||||
|
||||
```bash
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open <http://localhost:8080>.
|
||||
|
||||
`docker-compose.yml` builds locally, tags `pnger:latest`, publishes `${HOST_PORT:-8080}:3000`, sets `restart: unless-stopped`, caps the container at `512m` / `1.0` CPU (`MEM_LIMIT` / `CPU_LIMIT`), rotates JSON logs at 10 MB × 3, and runs the same healthcheck as the image. Override anything through a `.env` file next to the compose file or through the shell environment:
|
||||
|
||||
```bash
|
||||
HOST_PORT=9000 TZ=America/Chicago docker compose up -d
|
||||
```
|
||||
|
||||
The optional `/app/temp` volume mount is commented out in the compose file and is not needed — see [temp directory](#the-temp-directory-does-nothing) below.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build and run the image directly
|
||||
|
||||
```bash
|
||||
docker build -t pnger:latest .
|
||||
docker run -d --name pnger -p 8080:3000 \
|
||||
-e PUID=99 -e PGID=100 -e TZ=America/Chicago \
|
||||
--restart unless-stopped pnger:latest
|
||||
```
|
||||
|
||||
The `Dockerfile` is a three-stage build:
|
||||
|
||||
1. **frontend-builder** — `npm install` then `vite build` in `frontend/`, producing `frontend/dist`.
|
||||
2. **backend-builder** — `npm install` then `tsc` in `backend/`, producing `backend/dist`.
|
||||
3. **runtime** — `node:20-alpine` with `apk upgrade --no-cache` and `su-exec` + `shadow` installed; production-only backend deps (`npm install --omit=dev`); the compiled backend at `/app/dist`; the built frontend at `/app/dist/public`; `/app/temp` created; `docker-entrypoint.sh` installed.
|
||||
|
||||
`npm install` is used rather than `npm ci` on purpose — the committed lockfiles are stubs, and caret ranges in `package.json` are what actually pin the tree.
|
||||
|
||||
At start, `docker-entrypoint.sh` runs as root: it `usermod`/`groupmod`s the built-in `node` user to the requested `PUID`/`PGID`, `chown -R node:node /app`, then `exec su-exec node "$@"`. The app itself never runs as root.
|
||||
|
||||
### CI
|
||||
|
||||
`.gitea/workflows/docker-build.yml` runs on every push to `main` (plus `workflow_dispatch`) on the `host`-labelled Gitea runner — bundled Docker CLI, mounted `/var/run/docker.sock`, legacy builder. It logs in to `registry.alwisp.com` with `REGISTRY_USER` / `REGISTRY_TOKEN`, builds with the labels `org.alwisp.git-sha`, `org.alwisp.version` (`v1.<commit-count-1>`) and `org.alwisp.repo`, pushes `registry.alwisp.com/jason/pnger:latest`, then prunes dangling images on the host.
|
||||
|
||||
Only `:latest` is published — there is no immutable per-SHA tag, so rollback means rebuilding from an older commit.
|
||||
|
||||
To run the published image instead of building locally, point `docker run` (or the Unraid **Repository** field) at `registry.alwisp.com/jason/pnger:latest` after a `docker login registry.alwisp.com`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Local development
|
||||
|
||||
Two terminals:
|
||||
|
||||
```bash
|
||||
# terminal 1 — API on :3000, ts-node-dev with --respawn
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# terminal 2 — Vite dev server on :5173 with HMR
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open <http://localhost:5173>.
|
||||
|
||||
`frontend/src/lib/api.ts` switches on `import.meta.env.DEV`: in dev it posts to `http://localhost:3000/api`, in a production build to the same-origin `/api`. There is no Vite proxy — the backend's `app.use(cors())` allows the cross-origin call.
|
||||
|
||||
| Script | Where | Does |
|
||||
| --- | --- | --- |
|
||||
| `npm run dev` | backend | `ts-node-dev --respawn --transpile-only src/index.ts` |
|
||||
| `npm run build` | backend | `tsc` → `dist/` |
|
||||
| `npm start` | backend | `node dist/index.js` |
|
||||
| `npm run dev` | frontend | Vite dev server on 5173 |
|
||||
| `npm run build` | frontend | `vite build` → `frontend/dist` |
|
||||
| `npm run preview` | frontend | Serve the production build locally |
|
||||
|
||||
There are no tests and no lint step. Verification is manual: resize a large PNG, convert it to WebP and JPEG, try each crop anchor, toggle the theme, paste from the clipboard.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Everything is optional — the image ships a working default for each. There is no `.env` file in the container; set them on the container.
|
||||
|
||||
| Variable | Default (image) | Read by | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `PORT` | `3000` | `backend/src/index.ts` | Express listen port. Changing it means changing the healthcheck and port mapping too — easier to leave it and remap on the host. |
|
||||
| `PUID` | `99` | `docker-entrypoint.sh` | UID the `node` user is remapped to. `99` = Unraid `nobody`. |
|
||||
| `PGID` | `100` | `docker-entrypoint.sh` | GID the `node` group is remapped to. `100` = Unraid `users`. |
|
||||
| `TZ` | `UTC` | base image | Container timezone, e.g. `America/Chicago`. Affects log timestamps only. |
|
||||
| `NODE_ENV` | `production` | Express / Node | Set to `development` locally. |
|
||||
| `MAX_FILE_SIZE` | `10485760` | **nothing** | ⚠️ Declared in the `Dockerfile`, the compose file and the Unraid docs, but the live upload middleware (`backend/src/routes/image.ts`) creates `multer` with no `limits`, so no size cap is applied. The only code that ever honoured it is the dead `backend/src/server.js`. Treat uploads as unbounded until this is wired up. |
|
||||
| `TEMP_DIR` | `/app/temp` | **nothing** | ⚠️ See below. |
|
||||
| `CORS_ORIGIN` | — | **nothing** | ⚠️ Listed in `backend/.env.example` and older docs. `index.ts` calls `app.use(cors())` with no options, so all origins are allowed and this variable is ignored. |
|
||||
| `HOST_PORT` | `8080` | `docker-compose.yml` | Host side of the port mapping. Compose only. |
|
||||
| `MEM_LIMIT` | `512m` | `docker-compose.yml` | Container memory cap. Compose only. |
|
||||
| `CPU_LIMIT` | `1.0` | `docker-compose.yml` | Container CPU cap. Compose only. |
|
||||
| `VITE_API_URL` | — | **nothing** | ⚠️ Documented historically; `lib/api.ts` derives the base URL from `import.meta.env.DEV` instead. Setting it has no effect. |
|
||||
|
||||
`backend/.env.example` also predates the current server: it lists `MAX_FILE_SIZE=10` (megabytes) while the `Dockerfile` sets `10485760` (bytes). Neither is read today.
|
||||
|
||||
### The temp directory does nothing
|
||||
|
||||
`/app/temp` is created by the `Dockerfile`, `TEMP_DIR` points at it, and `UNRAID.md` offers an optional volume mapping for it. Nothing writes there. Uploads live in memory (`multer.memoryStorage()`), Sharp transforms the buffer, Express returns it. Mounting a volume is harmless but pointless.
|
||||
|
||||
---
|
||||
|
||||
## Health checks
|
||||
|
||||
The image's `HEALTHCHECK` requests `http://localhost:3000/` every 30 s and passes on HTTP 200 — that is the SPA `index.html`, so it confirms the process is up and serving.
|
||||
|
||||
Be careful with `/api/health`: **it does not exist.** The router only defines `POST /api/transform`, so a GET to `/api/health` falls through to the `app.get("*")` SPA handler and returns `index.html` with a 200. Any monitor pointed at it will report healthy no matter what state the API is in. Use `POST /api/transform` with a small test image if you need a real probe.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
- **Compose / local build:** `git pull && docker compose up -d --build`.
|
||||
- **Registry image:** `docker pull registry.alwisp.com/jason/pnger:latest && docker restart pnger`, or Unraid → **Docker** tab → **Force update**.
|
||||
|
||||
There is no state, so upgrades and rollbacks are just container recreations. Nothing to back up — but note that only `:latest` is published, so rolling back means rebuilding from the older commit.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| Container exits immediately | Host port already in use, or `PUID`/`PGID` collide with an existing user in a way `usermod` rejects. `docker logs pnger`. |
|
||||
| `Transform failed` in the UI | The API returned non-200. Check `docker logs` — Sharp throws a 500 on files it cannot decode (the client only checks the MIME type starts with `image/`). |
|
||||
| Large upload hangs or the container OOMs | No upload size limit is enforced and processing is entirely in memory. Raise `MEM_LIMIT`, or put a body-size cap on your reverse proxy. |
|
||||
| Preview does not match the download | Expected. The preview is a Canvas re-encode, the output is Sharp. PNG quality is ignored by Canvas, and the preview will upscale where the server's `withoutEnlargement: true` will not. |
|
||||
| Preview never appears | Browser console — the preview path is pure Canvas API and fails silently into `console.error`. |
|
||||
| Port already in use in dev | `lsof -ti:3000 \| xargs kill -9` |
|
||||
| Sharp fails to load after a Node upgrade | `cd backend && npm rebuild sharp` |
|
||||
| Docker build fails oddly | `docker builder prune`, then rebuild. |
|
||||
|
||||
---
|
||||
*MPM — Born to Innovate. Built to Last.*
|
||||
@@ -0,0 +1,153 @@
|
||||
# PNGer Development & Technical Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
PNGer is a single-container web application for PNG editing and resizing, designed for deployment on Unraid with Gitea version control. It features a modern Svelte frontend and a high-performance Node.js/Sharp backend.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
PNGer uses a multi-layered architecture designed for responsiveness and efficiency:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User Browser] --> B[Svelte Frontend]
|
||||
B --> C["Canvas API (live preview, client-side)"]
|
||||
B --> D["Express API (POST /api/transform)"]
|
||||
D --> E[Sharp Image Library]
|
||||
E --> F[Response buffer streamed back as a download]
|
||||
```
|
||||
|
||||
Nothing is persisted at any point: the upload lives in memory for the duration of the request and the result is streamed straight back.
|
||||
|
||||
### Backend (Express + Sharp)
|
||||
|
||||
The backend is built with Node.js and TypeScript, using Express for the API and Sharp for high-performance image processing.
|
||||
|
||||
**Endpoints** — there is exactly one:
|
||||
- `POST /api/transform` — `multipart/form-data`, field `file`, plus optional `width`, `height`, `quality` (default 80), `format` (`png` | `webp` | `jpeg`, default `png`), `fit` (default `inside`) and `position` (default `center`). Returns the image bytes with `Content-Disposition: attachment`; `400` if no file, `500` if Sharp throws.
|
||||
|
||||
> **There is no `GET /api/health`.** Earlier revisions of this document claimed one. The router defines only `POST /transform`, so a GET to `/api/health` falls through to the SPA catch-all and returns `index.html` with a 200 — which means a monitor pointed at it will always report healthy. The container's own `HEALTHCHECK` correctly probes `/` instead.
|
||||
|
||||
**Key Dependencies:**
|
||||
- `sharp`: High-performance image processing (handles resizing, cropping, and format conversion). Resizes use `withoutEnlargement: true`, so output never exceeds the source dimensions.
|
||||
- `multer`: Middleware for handling `multipart/form-data`, used for file uploads. Configured with `memoryStorage()` and **no `limits`** — see the dead-code note below.
|
||||
- `express`: Web framework for the API; also serves the built frontend from `dist/public` with an `app.get("*")` SPA fallback.
|
||||
- `cors`: Applied wide open (`app.use(cors())`) so the Vite dev server on `:5173` can reach the API on `:3000`. In production both are the same origin.
|
||||
|
||||
### Dead code you will trip over
|
||||
|
||||
- **`backend/src/server.js`** is not the server. The entrypoint is `src/index.ts` → `dist/index.js`. `server.js` is an older standalone CommonJS implementation with `/api/health`, `/api/process` and `/api/metadata`, `helmet`, a PNG-only file filter and a real `MAX_FILE_SIZE` limit. `tsconfig.json` has no `allowJs`, so `tsc` never compiles it and it never reaches `dist/`; it also `require`s `helmet` and `dotenv`, which are not in `package.json`. It is the source of most of the features these docs used to describe. Either port the good parts (size limit, MIME filter, real health endpoint) into `routes/image.ts` or delete it.
|
||||
- **`frontend/src/main.js` and `frontend/src/main.ts`** both exist. `index.html` loads `main.js`, and only `main.js` imports `./app.css` — "fixing" the script tag to point at `main.ts` would silently drop every style in the app.
|
||||
|
||||
### Frontend (Svelte + Vite)
|
||||
|
||||
The frontend is a reactive Svelte application that prioritizes real-time feedback and UX.
|
||||
|
||||
**Core Components & Modules:**
|
||||
- `App.svelte`: Main application container managing state and UI.
|
||||
- `lib/api.ts`: API client for backend communication.
|
||||
- `lib/preview.ts`: Client-side preview logic using the Canvas API for instant feedback.
|
||||
- `lib/theme.ts`: Dark/light theme management with persistent storage.
|
||||
- `lib/presets.ts`: Configuration for smart image presets.
|
||||
|
||||
### Design System (Dark/Light Modes)
|
||||
|
||||
The UI uses a modern design system with CSS custom properties for theming:
|
||||
- **Light Mode**: Clean white background with dark gold (`#b8860b`) accents.
|
||||
- **Dark Mode**: Deep black (`#0a0a0a`) background with light gold (`#daa520`) accents.
|
||||
- **Transparencies**: Dark/Light toggling allows users to inspect PNG transparency against different backgrounds.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Live Preview System
|
||||
|
||||
Live preview is implemented using a client-side Canvas-based approach to provide instant feedback (< 100ms) without server round-trips.
|
||||
|
||||
**How it works:**
|
||||
1. User uploads an image.
|
||||
2. The browser loads the image into an HTML5 Canvas.
|
||||
3. On any parameter change (width, quality, etc.), a debounced (300ms) update applies transformations to the canvas.
|
||||
4. The canvas content is displayed side-by-side with the original for comparison.
|
||||
5. File sizes are estimated from the Canvas data URL to provide immediate feedback on optimization savings.
|
||||
|
||||
**Fidelity caveats — the preview is an approximation, not the output:**
|
||||
- The preview is encoded by the browser's Canvas encoder; the download is encoded by Sharp. Byte counts will differ.
|
||||
- Canvas ignores the quality argument for PNG (it is always lossless), so dragging quality changes nothing in a PNG preview while it does change the real output.
|
||||
- The preview's `calculateDimensions()` will happily scale an image **up**; the server sets `withoutEnlargement: true` and will not. The "Retina @2x" preset is exactly this case — the preview grows, the downloaded file does not.
|
||||
|
||||
### Docker Strategy & Fixes
|
||||
|
||||
PNGer uses a multi-stage Docker build to minimize image size and maximize security.
|
||||
|
||||
**Optimization Fixes applied:**
|
||||
- **Dependency Installation**: Uses `npm install` instead of `npm ci` to handle missing lockfiles gracefully while maintaining stability via caret ranges in `package.json`.
|
||||
- **Security**: Runs as a non-root `node` user in the final Alpine-based image.
|
||||
- **Multer Upgrade**: Upgraded to `v2.1.0` for improved security and performance.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- npm or pnpm
|
||||
- Docker (optional)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.alwisp.com/jason/pnger.git
|
||||
cd pnger
|
||||
|
||||
# Setup backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Setup frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Only `PORT` is actually read by the application. The full table, including which variables are declared-but-unused, is in [INSTALL.md](INSTALL.md#environment-variables).
|
||||
|
||||
**Backend:**
|
||||
- `PORT`: 3000 — read by `src/index.ts`.
|
||||
- `MAX_FILE_SIZE`: declared in the `Dockerfile` and compose file, **not enforced** — the live `multer` config sets no `limits`.
|
||||
- `CORS_ORIGIN`: in `backend/.env.example`, **not read** — `app.use(cors())` takes no options.
|
||||
- `TEMP_DIR` / `/app/temp`: created by the `Dockerfile`, **never written to** — processing is entirely in memory.
|
||||
|
||||
**Frontend:**
|
||||
- `VITE_API_URL`: **not read.** `lib/api.ts` picks its base URL from `import.meta.env.DEV` (`http://localhost:3000/api` in dev, `/api` in a build).
|
||||
|
||||
## Development Workflow & Standards
|
||||
|
||||
### Workflow
|
||||
1. **Feature Branch**: Create from `main`.
|
||||
2. **Develop**: Use hot-reload (`npm run dev`).
|
||||
3. **Test**: Perform manual testing of image operations and presets.
|
||||
4. **Commit**: Use `type: description` format (e.g., `feat: add rotation`).
|
||||
5. **Merge**: Merge into `main` after review.
|
||||
|
||||
### Code Standards
|
||||
- **TypeScript**: Use strict types where possible.
|
||||
- **Svelte**: Keep components modular and under 300 lines.
|
||||
- **Async/Await**: Use for all asynchronous operations.
|
||||
- **Semantic HTML**: Ensure accessibility and proper structure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
More symptoms, including container-level ones, in [INSTALL.md](INSTALL.md#troubleshooting).
|
||||
|
||||
- **Port in use**: `lsof -ti:3000 | xargs kill -9`
|
||||
- **Sharp issues**: `npm rebuild sharp`
|
||||
- **Docker Cache**: `docker builder prune` if builds fail unexpectedly.
|
||||
- **Preview Glitches**: Check browser console for Canvas API errors.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: July 2026 — reconciled against the code during the docs standardization pass. Deployment and configuration detail now lives in [INSTALL.md](INSTALL.md) and [UNRAID.md](UNRAID.md).
|
||||
@@ -1,6 +1,8 @@
|
||||
# Unraid Installation Guide for PNGer
|
||||
|
||||
This guide walks you through installing PNGer on Unraid using the Docker tab and "Add Container" feature.
|
||||
This guide walks you through installing PNGer on Unraid using the Docker tab and "Add Container" feature. For the compose/CLI paths, local development and the full environment-variable table, see [INSTALL.md](INSTALL.md).
|
||||
|
||||
> PNGer has **no authentication**. Anyone who can reach the port can upload images and burn CPU on your box. Keep it on the LAN, or put it behind a reverse proxy with auth before exposing it.
|
||||
|
||||
## Requirements
|
||||
- Unraid OS with Docker enabled.
|
||||
@@ -12,7 +14,7 @@ This guide walks you through installing PNGer on Unraid using the Docker tab and
|
||||
2. Scroll to the bottom and click on **Add Container**.
|
||||
3. Fill in the following details:
|
||||
- **Name**: `PNGer`
|
||||
- **Repository**: `jason/pnger:latest` (or the repository you pushed the image to, e.g., `ghcr.io/yourusername/pnger:latest` if hosted, or `pnger:latest` if built locally).
|
||||
- **Repository**: `registry.alwisp.com/jason/pnger:latest` — this is what the Gitea Actions workflow publishes on every push to `main`. Run `docker login registry.alwisp.com` once on the Unraid host so it can pull. If you built the image locally on the box instead (`docker build -t pnger:latest .`), use `pnger:latest`.
|
||||
- **Network Type**: `Bridge`
|
||||
|
||||
4. Click on **+ Add another Path, Port, Variable, Label or Device** to add the required parameters.
|
||||
@@ -42,13 +44,14 @@ Add the following variables by clicking **+ Add another Path, Port, Variable...*
|
||||
- **Key**: `TZ`
|
||||
- **Value**: `America/New_York` (Enter your specific Timezone here).
|
||||
|
||||
4. **MAX_FILE_SIZE** (Optional)
|
||||
4. **MAX_FILE_SIZE** (Optional — and currently inert)
|
||||
- **Name**: `Max Upload Size (Bytes)`
|
||||
- **Key**: `MAX_FILE_SIZE`
|
||||
- **Value**: `10485760` (Default is 10MB; 10485760 bytes).
|
||||
- **Value**: `10485760` (10 MB in bytes).
|
||||
- ⚠️ The application does **not** read this today: the live upload middleware sets no size limit, so uploads are effectively unbounded and are held in memory. Setting the variable does nothing. Until it is wired up, cap request bodies at your reverse proxy and give the container a sensible memory limit.
|
||||
|
||||
### Volume Mapping (Optional)
|
||||
If you require persistence for the temporary directory processing uploads (usually not required):
|
||||
### Volume Mapping (not needed)
|
||||
PNGer is stateless — uploads are processed in memory and streamed straight back, and nothing is ever written to `/app/temp` despite the directory existing. There is nothing to persist and nothing to back up. If you want the mapping anyway, it is harmless:
|
||||
- **Config Type**: `Path`
|
||||
- **Name**: `Temp Processing Dir`
|
||||
- **Container Path**: `/app/temp`
|
||||
@@ -62,5 +65,8 @@ Once the container states "started", you can access the Web GUI by navigating to
|
||||
|
||||
---
|
||||
|
||||
**Updates:**
|
||||
Every push to `main` rebuilds `registry.alwisp.com/jason/pnger:latest` on the Gitea runner. To take a new build: **Docker** tab → container row → **Force update** (or let the *CA Auto Update Applications* plugin do it on a schedule). There is no state, so an update is just a container recreation. Only `:latest` is published — there is no per-SHA tag to pin or roll back to.
|
||||
|
||||
**Troubleshooting:**
|
||||
If the container stops instantly, check the **Logs** in Unraid. Ensure that the port you selected on the host is not already in use by another container (like a web server or another app).
|
||||
If the container stops instantly, check the **Logs** in Unraid. Ensure that the port you selected on the host is not already in use by another container (like a web server or another app). If the container starts but the WebGUI is blank, confirm the host port maps to container port **3000**.
|
||||
Reference in New Issue
Block a user