refactor: remove lite variant, fix release workflow
- Remove all lite/full variant logic from frontend, API, shared constants, docs, and tests (single unified Docker image only) - Replace single QEMU multi-arch Docker build with per-architecture native builds (amd64 + arm64) and manifest merge to fix disk space exhaustion - Add disk cleanup step and per-platform build cache scopes - Switch release trigger from push to workflow_dispatch - Add GitHub issue templates and PR template
@@ -0,0 +1,85 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Before submitting
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and didn't find a duplicate
|
||||||
|
required: true
|
||||||
|
- label: I'm running the latest version of Stirling Image
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What happened, and what did you expect to happen?
|
||||||
|
placeholder: |
|
||||||
|
When I try to resize an image, the output is blank.
|
||||||
|
I expected the resized image to be returned normally.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: How can we reproduce this?
|
||||||
|
placeholder: |
|
||||||
|
1. Upload a PNG image
|
||||||
|
2. Select "Resize" tool
|
||||||
|
3. Set width to 500
|
||||||
|
4. Click "Process"
|
||||||
|
5. Downloaded file is 0 bytes
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: docker-setup
|
||||||
|
attributes:
|
||||||
|
label: Docker setup
|
||||||
|
description: Paste your `docker run` command or `docker-compose.yml` (remove any sensitive values).
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: image-tag
|
||||||
|
attributes:
|
||||||
|
label: Image tag
|
||||||
|
description: Which Docker image tag are you using?
|
||||||
|
placeholder: "e.g. latest, 1.11.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
options:
|
||||||
|
- AMD64 (Intel/AMD)
|
||||||
|
- ARM64 (Apple Silicon, Raspberry Pi)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser
|
||||||
|
placeholder: "e.g. Chrome 130, Firefox 133, Safari 18"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: Paste any relevant container logs (`docker logs <container>`).
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: If applicable, add screenshots to help explain the problem.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Documentation
|
||||||
|
url: https://stirling-image.github.io/stirling-image/
|
||||||
|
about: Check the docs for setup guides, configuration, and API reference.
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/stirling-image/stirling-image/security/advisories/new
|
||||||
|
about: Report security issues privately through GitHub Security Advisories.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new tool, improvement, or change
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Before submitting
|
||||||
|
options:
|
||||||
|
- label: I searched existing issues and didn't find a duplicate
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What problem does this solve? Describe the use case or frustration.
|
||||||
|
placeholder: |
|
||||||
|
I frequently need to convert AVIF files to JPEG, but there's no tool for it.
|
||||||
|
I currently have to use an external service which means my images leave my machine.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: How do you think this should work?
|
||||||
|
placeholder: |
|
||||||
|
Add an AVIF option to the format conversion tool, similar to how
|
||||||
|
HEIC and WebP are already supported.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Have you tried any workarounds? Are there other approaches that could work?
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Screenshots, links, examples, or anything else that helps explain the request.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for your interest in Stirling Image.
|
||||||
|
|
||||||
|
We don't accept pull requests. All development is handled internally to maintain architectural consistency and code quality.
|
||||||
|
|
||||||
|
If you've found a bug or have a feature idea, please open an issue instead:
|
||||||
|
https://github.com/stirling-image/stirling-image/issues
|
||||||
|
|
||||||
|
Your feedback is valuable and directly shapes the project.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**This project does not accept pull requests.** Please close this PR and [open an issue](https://github.com/stirling-image/stirling-image/issues/new/choose) instead. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -14,7 +13,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Semantic Release
|
name: Semantic Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
||||||
outputs:
|
outputs:
|
||||||
new_version: ${{ steps.check.outputs.version }}
|
new_version: ${{ steps.check.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
@@ -46,14 +44,36 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -f .release-version ]; then
|
if [ -f .release-version ]; then
|
||||||
echo "version=$(cat .release-version)" >> "$GITHUB_OUTPUT"
|
echo "version=$(cat .release-version)" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::error::semantic-release did not produce a new version. No releasable commits found."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Docker Build & Push
|
name: Build (${{ matrix.platform }})
|
||||||
needs: release
|
needs: release
|
||||||
if: needs.release.outputs.new_version != ''
|
strategy:
|
||||||
runs-on: ubuntu-latest
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||||
|
/usr/local/share/boost /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo docker system prune -af
|
||||||
|
df -h /
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout release tag
|
- name: Checkout release tag
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -62,8 +82,64 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Log in to Docker Hub
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
stirlingimage/stirling-image
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=stirlingimage/stirling-image,ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
manifest:
|
||||||
|
name: Create Multi-Arch Manifests
|
||||||
|
needs: [release, docker]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -91,14 +167,16 @@ jobs:
|
|||||||
type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }}
|
type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- name: Build and push
|
- name: Create Docker Hub manifest
|
||||||
uses: docker/build-push-action@v6
|
working-directory: /tmp/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create \
|
||||||
file: docker/Dockerfile
|
$(jq -cr '.tags | map(select(startswith("stirlingimage/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
push: true
|
$(printf 'stirlingimage/stirling-image@sha256:%s ' *)
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
- name: Create GHCR manifest
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
working-directory: /tmp/digests
|
||||||
cache-from: type=gha,scope=unified
|
run: |
|
||||||
cache-to: type=gha,mode=max,scope=unified
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map(select(startswith("ghcr.io/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ blob-report/
|
|||||||
|
|
||||||
# IDE / tool scratch
|
# IDE / tool scratch
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.claude/
|
|
||||||
CLAUDE.md
|
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
PRD.md
|
PRD.md
|
||||||
|
|
||||||
|
|||||||
@@ -699,7 +699,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Internal planning docs (`docs/superpowers/`) and Claude Code config (`.claude/`) from version control — these stay local only
|
- Internal planning docs (`docs/superpowers/`) and AI tool config from version control - these stay local only
|
||||||
|
|
||||||
## [0.7.0] - 2026-03-24
|
## [0.7.0] - 2026-03-24
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +1,25 @@
|
|||||||
# Contributing to Stirling Image
|
# Contributing to Stirling Image
|
||||||
|
|
||||||
Thanks for your interest in contributing. There are many ways to help beyond writing code: reporting bugs, suggesting features, improving docs, and adding translations.
|
Thanks for your interest in the project. Community feedback helps shape Stirling Image, and there are several ways to get involved.
|
||||||
|
|
||||||
## Issues
|
## How to contribute
|
||||||
|
|
||||||
Before opening an issue, search existing ones to avoid duplicates.
|
The best way to contribute is through [GitHub Issues](https://github.com/stirling-image/stirling-image/issues):
|
||||||
|
|
||||||
- **Bug reports**: Include steps to reproduce, expected vs. actual behavior, and your environment (OS, Docker version, browser).
|
- **Bug reports** - Found something broken? Open a bug report with steps to reproduce, your Docker setup, and what you expected to happen.
|
||||||
- **Feature requests**: Describe the problem you want solved, not just the solution. Context helps.
|
- **Feature requests** - Have an idea for a new tool or improvement? Describe the problem you want solved and why it matters to you.
|
||||||
- **Questions**: Open an issue. We will do our best to respond quickly.
|
- **Feedback** - Thoughts on the UI, workflow, documentation, or anything else? We want to hear it.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
1. **Open an issue first.** Describe what you want to change and why. Wait for a maintainer to confirm the direction before writing code.
|
We do not accept pull requests. All development is handled internally to maintain architectural consistency and code quality across the project.
|
||||||
2. **Fork the repo** and create a branch from `main`.
|
|
||||||
3. **Make your changes.** Follow the conventions in [CLAUDE.md](CLAUDE.md) (formatting, file structure, commit style).
|
|
||||||
4. **Test your changes.** Run `pnpm test` and `pnpm lint` before pushing. If you changed UI, run `pnpm test:e2e` too.
|
|
||||||
5. **Submit a PR.** Reference the issue number. Keep the title short and descriptive.
|
|
||||||
|
|
||||||
### Commit messages
|
If you've found a bug, please open an issue describing it rather than submitting a fix. If you have a suggestion for how something should work, describe it in a feature request. Your input is valuable even without a code contribution.
|
||||||
|
|
||||||
We use [Conventional Commits](https://www.conventionalcommits.org/) for automated releases:
|
## Forking
|
||||||
|
|
||||||
- `feat:` new feature (triggers a minor version bump)
|
You're welcome to fork the project for your own use under the terms of the [AGPLv3 license](LICENSE). The [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) covers setup, architecture, and how to add new tools.
|
||||||
- `fix:` bug fix (triggers a patch version bump)
|
|
||||||
- `docs:` documentation only
|
|
||||||
- `test:` adding or fixing tests
|
|
||||||
- `refactor:` code change that doesn't fix a bug or add a feature
|
|
||||||
- `chore:` maintenance (CI, deps, config)
|
|
||||||
|
|
||||||
Example: `feat: add HEIC to PNG conversion support`
|
## Security
|
||||||
|
|
||||||
### What makes a good PR
|
If you discover a security vulnerability, please report it privately through [GitHub Security Advisories](https://github.com/stirling-image/stirling-image/security/advisories/new) rather than opening a public issue.
|
||||||
|
|
||||||
- One logical change per PR. If you need to refactor something to add a feature, that can be one PR, but don't mix unrelated changes.
|
|
||||||
- Clear commit messages that explain why, not just what.
|
|
||||||
- Tests for new behavior when possible.
|
|
||||||
- No unrelated formatting changes (Biome handles formatting).
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
Full instructions are in the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer). The short version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/stirling-image/stirling-image.git
|
|
||||||
cd stirling-image
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # starts both frontend and backend
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend runs at http://localhost:1349 and proxies API calls to the backend on port 13490.
|
|
||||||
|
|
||||||
### Running tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm lint # Biome lint + format check
|
|
||||||
pnpm typecheck # TypeScript across all workspaces
|
|
||||||
pnpm test # unit + integration tests
|
|
||||||
pnpm test:e2e # Playwright end-to-end tests
|
|
||||||
```
|
|
||||||
|
|
||||||
All of these run in CI on every PR. Make sure they pass locally first.
|
|
||||||
|
|
||||||
## Adding a new tool
|
|
||||||
|
|
||||||
Tools follow a consistent pattern. You will need to touch three places:
|
|
||||||
|
|
||||||
1. **Backend route** in `apps/api/src/routes/tools/` using `createToolRoute()` from the tool factory.
|
|
||||||
2. **Frontend settings component** in `apps/web/src/components/tools/` with the tool's UI controls.
|
|
||||||
3. **i18n entry** in `packages/shared/src/i18n/en.ts` with the tool's name and description.
|
|
||||||
|
|
||||||
See the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) for a walkthrough.
|
|
||||||
|
|
||||||
## Translations
|
|
||||||
|
|
||||||
We currently ship English only, but the i18n system is designed for easy extension. If you want to add a language, see the [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations).
|
|
||||||
|
|
||||||
## Code style
|
|
||||||
|
|
||||||
Biome handles formatting and linting. The rules are in `biome.json` and enforced by a pre-commit hook. Don't modify the Biome or TypeScript config files to silence warnings. Fix the code instead.
|
|
||||||
|
|
||||||
Quick summary:
|
|
||||||
|
|
||||||
- Double quotes, semicolons, 2-space indentation
|
|
||||||
- ES modules everywhere
|
|
||||||
- Zod for API input validation
|
|
||||||
- No `any` types without justification
|
|
||||||
|
|
||||||
## License and Contributor License Agreement
|
|
||||||
|
|
||||||
Stirling Image is dual-licensed under the [AGPLv3](LICENSE) and a commercial license. To keep this dual-licensing possible, all contributions must be submitted under the following terms:
|
|
||||||
|
|
||||||
By submitting a pull request or otherwise contributing code to this project, you agree that:
|
|
||||||
|
|
||||||
1. Your contributions are your original work (or you have the right to submit them).
|
|
||||||
2. You grant me a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license to use, reproduce, modify, distribute, sublicense, and relicense your contributions under any license, including the AGPLv3 and any commercial license I offer for this project.
|
|
||||||
3. You understand that your contributions will be publicly available under the AGPLv3 and may also be included in commercially licensed versions of the software.
|
|
||||||
|
|
||||||
This is necessary because the project offers a commercial license alongside the open-source AGPLv3. Without this agreement, contributed code could only be distributed under the AGPLv3, which would prevent offering a commercial option.
|
|
||||||
|
|
||||||
If you have questions about this, open an issue before contributing.
|
|
||||||
|
|||||||
@@ -42,22 +42,16 @@ docker run -d -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:l
|
|||||||
Open http://localhost:1349 in your browser.
|
Open http://localhost:1349 in your browser.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><sub>Looking for the Lite or CUDA image? Click here.</sub></summary>
|
<summary><sub>Have an NVIDIA GPU? Click here for GPU acceleration.</sub></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
**Lite** (~1.5 GB) - all image tools, no AI:
|
Add `--gpus all` for GPU-accelerated background removal, upscaling, and OCR:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:lite
|
docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirling-image:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**CUDA** (~14 GB) - GPU-accelerated background removal, upscaling, and OCR:
|
> Requires an NVIDIA GPU and [Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags) for benchmarks and Docker Compose examples.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirling-image:cuda
|
|
||||||
```
|
|
||||||
|
|
||||||
> CUDA needs an NVIDIA GPU and [Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags) for benchmarks and Docker Compose examples.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -70,7 +64,7 @@ docker run -d -p 1349:1349 --gpus all -v stirling-data:/data stirlingimage/stirl
|
|||||||
|
|
||||||
You will be asked to change your password on first login. This is enforced for all new accounts and cannot be skipped in production.
|
You will be asked to change your password on first login. This is enforced for all new accounts and cannot be skipped in production.
|
||||||
|
|
||||||
For Docker Compose, persistent storage, and other setup options, see the [Getting Started Guide](https://stirling-image.github.io/stirling-image/guide/getting-started). For details on all image variants (full, lite, cuda), see [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags).
|
For Docker Compose, persistent storage, and other setup options, see the [Getting Started Guide](https://stirling-image.github.io/stirling-image/guide/getting-started). For GPU acceleration and tag details, see [Docker Tags](https://stirling-image.github.io/stirling-image/guide/docker-tags).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -81,13 +75,9 @@ For Docker Compose, persistent storage, and other setup options, see the [Gettin
|
|||||||
- [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer)
|
- [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer)
|
||||||
- [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations)
|
- [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations)
|
||||||
|
|
||||||
## Contributing
|
## Feedback
|
||||||
|
|
||||||
Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, the [Developer Guide](https://stirling-image.github.io/stirling-image/guide/developer) for setup, and the [Translation Guide](https://stirling-image.github.io/stirling-image/guide/translations) for adding languages.
|
Found a bug or have a feature idea? Open a [GitHub Issue](https://github.com/stirling-image/stirling-image/issues). We don't accept pull requests, but your feedback directly shapes the project. See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Bug reports and feature requests: [GitHub Issues](https://github.com/stirling-image/stirling-image/issues)
|
|
||||||
|
|
||||||
<!-- TODO: Add sponsorship links once Ko-fi and GitHub Sponsors are set up -->
|
<!-- TODO: Add sponsorship links once Ko-fi and GitHub Sponsors are set up -->
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ import { teamsRoutes } from "./routes/teams.js";
|
|||||||
import { registerToolRoutes } from "./routes/tools/index.js";
|
import { registerToolRoutes } from "./routes/tools/index.js";
|
||||||
import { userFileRoutes } from "./routes/user-files.js";
|
import { userFileRoutes } from "./routes/user-files.js";
|
||||||
|
|
||||||
// Warn about deprecated STIRLING_VARIANT env var
|
|
||||||
if (process.env.STIRLING_VARIANT) {
|
|
||||||
console.warn(
|
|
||||||
`WARNING: STIRLING_VARIANT="${process.env.STIRLING_VARIANT}" is set but ignored. ` +
|
|
||||||
"There is now a single unified image with all features. Remove STIRLING_VARIANT from your environment.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run before anything else
|
// Run before anything else
|
||||||
runMigrations();
|
runMigrations();
|
||||||
console.log("Database initialized");
|
console.log("Database initialized");
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* GET /api/v1/settings/:key — Get a specific setting
|
* GET /api/v1/settings/:key — Get a specific setting
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
@@ -27,10 +26,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
settings[row.key] = row.value;
|
settings[row.key] = row.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full";
|
return reply.send({ settings });
|
||||||
const variantUnavailableTools = variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : [];
|
|
||||||
|
|
||||||
return reply.send({ settings, variant, variantUnavailableTools });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/v1/settings — Save settings (admin only)
|
// PUT /api/v1/settings — Save settings (admin only)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { db, schema } from "../../db/index.js";
|
import { db, schema } from "../../db/index.js";
|
||||||
@@ -69,10 +69,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Build skip set
|
// Build skip set
|
||||||
const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]);
|
const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]);
|
||||||
|
|
||||||
// In lite mode, register 501 stubs for AI tools instead of real handlers
|
|
||||||
const isLite = process.env.STIRLING_VARIANT === "lite";
|
|
||||||
const liteStubTools = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
|
||||||
|
|
||||||
const toolRegistrations: Array<{
|
const toolRegistrations: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
register: (app: FastifyInstance) => void;
|
register: (app: FastifyInstance) => void;
|
||||||
@@ -131,7 +127,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let stubbed = 0;
|
|
||||||
for (const { id, register } of toolRegistrations) {
|
for (const { id, register } of toolRegistrations) {
|
||||||
if (skipTools.has(id)) {
|
if (skipTools.has(id)) {
|
||||||
app.log.info(`Skipping disabled/experimental tool: ${id}`);
|
app.log.info(`Skipping disabled/experimental tool: ${id}`);
|
||||||
@@ -139,24 +134,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLite && liteStubTools.has(id)) {
|
|
||||||
// Register a 501 stub instead of the real handler
|
|
||||||
app.post(`/api/v1/tools/${id}`, async (_request, reply) => {
|
|
||||||
return reply.status(501).send({
|
|
||||||
statusCode: 501,
|
|
||||||
error: "Not Available",
|
|
||||||
message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
stubbed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
register(app);
|
register(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
const registered = toolRegistrations.length - skipped - stubbed;
|
const registered = toolRegistrations.length - skipped;
|
||||||
app.log.info(
|
app.log.info(
|
||||||
`Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`,
|
`Tool routes: ${registered} active, ${skipped} skipped (${toolRegistrations.length} total)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
|||||||
`,
|
`,
|
||||||
customTemplateVariables: {
|
customTemplateVariables: {
|
||||||
description:
|
description:
|
||||||
"Self-hosted, open-source image processing platform with 30+ tools. Runs in a single Docker container. Available as :latest (full, ~11 GB with AI/ML) or :lite (~1.5 GB, image processing only).",
|
"Self-hosted, open-source image processing platform with 30+ tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
|
||||||
details:
|
details:
|
||||||
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
|
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,9 +110,16 @@ Set `client_max_body_size` to match your `MAX_UPLOAD_SIZE_MB` value.
|
|||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
The GitHub repository has two workflows:
|
The GitHub repository has three workflows:
|
||||||
|
|
||||||
- **release.yml** -- On release, builds a multi-arch Docker image (amd64 + arm64), and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`).
|
- **ci.yml** -- Runs automatically on every push and PR. Lints, typechecks, tests, builds, and validates the Docker image (without pushing).
|
||||||
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages.
|
- **release.yml** -- Triggered manually via `workflow_dispatch`. Runs semantic-release to create a version tag and GitHub release, then builds a multi-arch Docker image (amd64 + arm64) and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`).
|
||||||
|
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages on push to `main`.
|
||||||
|
|
||||||
Both run automatically. No manual steps needed after merging to `main`.
|
To create a release, go to **Actions > Release > Run workflow** in the GitHub UI, or run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow run release.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Semantic-release determines the version from commit history. The `latest` Docker tag always points to the most recent release.
|
||||||
|
|||||||
@@ -198,12 +198,6 @@ Build the full production image locally:
|
|||||||
docker build -f docker/Dockerfile -t stirling-image:latest .
|
docker build -f docker/Dockerfile -t stirling-image:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the lite image (no Python/AI, ~1.5 GB):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite .
|
|
||||||
```
|
|
||||||
|
|
||||||
Use BuildKit cache mounts for faster rebuilds:
|
Use BuildKit cache mounts for faster rebuilds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -118,9 +118,6 @@ volumes:
|
|||||||
|
|
||||||
## Migration from previous tags
|
## Migration from previous tags
|
||||||
|
|
||||||
If you were using `:lite` or `:cuda` tags, switch to `:latest`:
|
If you were using the `:cuda` tag, switch to `:latest` and keep `--gpus all`. Same GPU support, unified image.
|
||||||
|
|
||||||
- **From `:lite`**: Pull `:latest`. You now have all AI tools included.
|
|
||||||
- **From `:cuda`**: Pull `:latest` and keep `--gpus all`. Same GPU support, unified image.
|
|
||||||
|
|
||||||
Your data and settings are preserved in the volumes.
|
Your data and settings are preserved in the volumes.
|
||||||
|
|||||||
@@ -14,21 +14,11 @@ docker run -d \
|
|||||||
|
|
||||||
Open `http://localhost:1349` in your browser. Log in with `admin` / `admin`.
|
Open `http://localhost:1349` in your browser. Log in with `admin` / `admin`.
|
||||||
|
|
||||||
::: tip Lite image
|
|
||||||
Don't need AI tools (background removal, upscaling, OCR, face blur, object eraser)? Use the lite image instead - 1.5 GB vs 11 GB:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stirlingimage/stirling-image:lite
|
|
||||||
```
|
|
||||||
|
|
||||||
All 27+ image processing tools work the same. See [Docker Tags](./docker-tags) for the full comparison.
|
|
||||||
:::
|
|
||||||
|
|
||||||
::: tip GPU acceleration
|
::: tip GPU acceleration
|
||||||
Have an NVIDIA GPU? The CUDA image auto-detects your GPU and accelerates background removal (2.7x), upscaling (3x), and OCR (1.5x):
|
Have an NVIDIA GPU? Add `--gpus all` to accelerate background removal (2.7x), upscaling (3x), and OCR (1.5x):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:cuda
|
docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](./docker-tags) for details and benchmarks.
|
Requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](./docker-tags) for details and benchmarks.
|
||||||
|
|||||||
@@ -1,58 +1,17 @@
|
|||||||
import type { Tool } from "@stirling-image/shared";
|
import type { Tool } from "@stirling-image/shared";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
import { FileImage, Sparkles, Star } from "lucide-react";
|
import { FileImage, Star } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
variantUnavailable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCard({ tool, variantUnavailable }: ToolCardProps) {
|
export function ToolCard({ tool }: ToolCardProps) {
|
||||||
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
|
||||||
const IconComponent = iconsMap[tool.icon] || FileImage;
|
const IconComponent = iconsMap[tool.icon] || FileImage;
|
||||||
|
|
||||||
if (variantUnavailable) {
|
|
||||||
return (
|
|
||||||
<div className="group flex items-center gap-3 relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
|
|
||||||
title="Add to favourites"
|
|
||||||
>
|
|
||||||
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
toast("This tool requires the full image.", {
|
|
||||||
description:
|
|
||||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
|
||||||
action: {
|
|
||||||
label: "Learn more",
|
|
||||||
onClick: () =>
|
|
||||||
window.open(
|
|
||||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
|
||||||
"_blank",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium text-foreground">{tool.name}</span>
|
|
||||||
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
|
||||||
<Sparkles className="h-2.5 w-2.5" />
|
|
||||||
AI
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-3 relative">
|
<div className="group flex items-center gap-3 relative">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||||
import { Info } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { SearchBar } from "../common/search-bar";
|
import { SearchBar } from "../common/search-bar";
|
||||||
@@ -7,15 +6,12 @@ import { ToolCard } from "../common/tool-card";
|
|||||||
|
|
||||||
export function ToolPanel() {
|
export function ToolPanel() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { variant, disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
|
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
|
||||||
useSettingsStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch();
|
fetch();
|
||||||
}, [fetch]);
|
}, [fetch]);
|
||||||
|
|
||||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
|
||||||
|
|
||||||
const visibleTools = useMemo(() => {
|
const visibleTools = useMemo(() => {
|
||||||
if (!loaded) return [];
|
if (!loaded) return [];
|
||||||
return TOOLS.filter((t) => {
|
return TOOLS.filter((t) => {
|
||||||
@@ -49,27 +45,6 @@ export function ToolPanel() {
|
|||||||
<SearchBar value={search} onChange={setSearch} />
|
<SearchBar value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pb-4 flex-1">
|
<div className="px-3 pb-4 flex-1">
|
||||||
{variant === "lite" && (
|
|
||||||
<div className="mb-3 p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<div className="text-xs leading-relaxed">
|
|
||||||
<p className="font-medium">Lite mode</p>
|
|
||||||
<p className="mt-0.5 text-amber-700 dark:text-amber-400">
|
|
||||||
AI tools are unavailable. Use the{" "}
|
|
||||||
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
|
|
||||||
latest
|
|
||||||
</code>{" "}
|
|
||||||
or{" "}
|
|
||||||
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
|
|
||||||
full
|
|
||||||
</code>{" "}
|
|
||||||
tag for all features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
||||||
<div key={category.id} className="mb-4">
|
<div key={category.id} className="mb-4">
|
||||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||||
@@ -77,11 +52,7 @@ export function ToolPanel() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{groupedTools.get(category.id)?.map((tool) => (
|
{groupedTools.get(category.id)?.map((tool) => (
|
||||||
<ToolCard
|
<ToolCard key={tool.id} tool={tool} />
|
||||||
key={tool.id}
|
|
||||||
tool={tool}
|
|
||||||
variantUnavailable={unavailableSet.has(tool.id)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
import { ImageViewer } from "@/components/common/image-viewer";
|
||||||
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
@@ -17,14 +15,12 @@ export function HomePage() {
|
|||||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();
|
const { fetch: fetchSettings } = useSettingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
}, [fetchSettings]);
|
}, [fetchSettings]);
|
||||||
|
|
||||||
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
|
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
(newFiles: File[]) => {
|
(newFiles: File[]) => {
|
||||||
reset();
|
reset();
|
||||||
@@ -33,25 +29,6 @@ export function HomePage() {
|
|||||||
[setFiles, reset],
|
[setFiles, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToolClick = (route: string, toolId: string) => {
|
|
||||||
if (unavailableSet.has(toolId)) {
|
|
||||||
toast("This tool requires the full image.", {
|
|
||||||
description:
|
|
||||||
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
|
|
||||||
action: {
|
|
||||||
label: "Learn more",
|
|
||||||
onClick: () =>
|
|
||||||
window.open(
|
|
||||||
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
|
|
||||||
"_blank",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(route);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
|
|
||||||
// If no file uploaded, show default layout (tool panel + dropzone)
|
// If no file uploaded, show default layout (tool panel + dropzone)
|
||||||
@@ -103,11 +80,8 @@ export function HomePage() {
|
|||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
onClick={() => navigate(tool.route)}
|
||||||
className={cn(
|
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||||
"flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
|
|
||||||
unavailableSet.has(id) && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
@@ -148,13 +122,8 @@ export function HomePage() {
|
|||||||
<button
|
<button
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToolClick(tool.route, tool.id)}
|
onClick={() => navigate(tool.route)}
|
||||||
className={cn(
|
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors hover:bg-muted text-foreground"
|
||||||
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
|
|
||||||
unavailableSet.has(tool.id)
|
|
||||||
? "opacity-50 hover:bg-muted/50"
|
|
||||||
: "hover:bg-muted text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm">{tool.name}</span>
|
<span className="text-sm">{tool.name}</span>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { create } from "zustand";
|
|||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
variant: "full" | "lite";
|
|
||||||
variantUnavailableTools: string[];
|
|
||||||
disabledTools: string[];
|
disabledTools: string[];
|
||||||
experimentalEnabled: boolean;
|
experimentalEnabled: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@@ -11,8 +9,6 @@ interface SettingsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||||
variant: "full",
|
|
||||||
variantUnavailableTools: [],
|
|
||||||
disabledTools: [],
|
disabledTools: [],
|
||||||
experimentalEnabled: false,
|
experimentalEnabled: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
@@ -22,19 +18,15 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const data = await apiGet<{
|
const data = await apiGet<{
|
||||||
settings: Record<string, string>;
|
settings: Record<string, string>;
|
||||||
variant: "full" | "lite";
|
|
||||||
variantUnavailableTools: string[];
|
|
||||||
}>("/v1/settings");
|
}>("/v1/settings");
|
||||||
|
|
||||||
set({
|
set({
|
||||||
variant: data.variant ?? "full",
|
|
||||||
variantUnavailableTools: data.variantUnavailableTools ?? [],
|
|
||||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Settings fetch failed - default to full with no disabled tools
|
// Settings fetch failed - default to no disabled tools
|
||||||
set({ loaded: true });
|
set({ loaded: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -374,8 +374,7 @@ export const APP_VERSION = "1.11.0";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool IDs that require the Python sidecar (AI/ML tools).
|
* Tool IDs that require the Python sidecar (AI/ML tools).
|
||||||
* Used by the API to register 501 stubs in lite mode,
|
* Used by the frontend for progress/timeout behavior.
|
||||||
* and by the frontend for progress/timeout behavior.
|
|
||||||
*/
|
*/
|
||||||
export const PYTHON_SIDECAR_TOOLS = [
|
export const PYTHON_SIDECAR_TOOLS = [
|
||||||
"remove-background",
|
"remove-background",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { getTestImagePath } from "./helpers";
|
import { getTestImagePath } from "./helpers";
|
||||||
|
|
||||||
const API = "http://localhost:13490";
|
const API = process.env.API_URL || "http://localhost:13490";
|
||||||
|
|
||||||
async function getAuthToken(): Promise<string> {
|
async function getAuthToken(): Promise<string> {
|
||||||
const res = await fetch(`${API}/api/auth/login`, {
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test as base, expect } from "@playwright/test";
|
import { test as base, expect } from "@playwright/test";
|
||||||
import { test as uiTest } from "./helpers";
|
import { test as uiTest } from "./helpers";
|
||||||
|
|
||||||
const API = "http://localhost:13490";
|
const API = process.env.API_URL || "http://localhost:13490";
|
||||||
|
|
||||||
async function getAuthToken(): Promise<string> {
|
async function getAuthToken(): Promise<string> {
|
||||||
const res = await fetch(`${API}/api/auth/login`, {
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getTestImagePath } from "./helpers";
|
|||||||
// auth token handling, and unauthenticated access.
|
// auth token handling, and unauthenticated access.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const API = "http://localhost:13490";
|
const API = process.env.API_URL || "http://localhost:13490";
|
||||||
|
|
||||||
async function getAuthToken(): Promise<string> {
|
async function getAuthToken(): Promise<string> {
|
||||||
const res = await fetch(`${API}/api/auth/login`, {
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
@@ -38,8 +38,8 @@ test.describe("Security: Path traversal", () => {
|
|||||||
|
|
||||||
test("download rejects path traversal in jobId (..)", async () => {
|
test("download rejects path traversal in jobId (..)", async () => {
|
||||||
const res = await fetch(`${API}/api/v1/download/../../../etc/passwd/file.png`);
|
const res = await fetch(`${API}/api/v1/download/../../../etc/passwd/file.png`);
|
||||||
// Should return 400 (invalid path) or 404, never the actual file
|
// 400/404 in dev (Fastify only), 200 in production (SPA fallback for normalized path).
|
||||||
expect([400, 404]).toContain(res.status);
|
// Either way, the actual file must never be leaked.
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
expect(body).not.toContain("root:");
|
expect(body).not.toContain("root:");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Integration tests for the content-aware resize (seam carving) API endpoint.
|
* Integration tests for the content-aware resize (seam carving) API endpoint.
|
||||||
*
|
*
|
||||||
* This tool uses the Python sidecar, so in CI/test environments where Python
|
* This tool uses the Python sidecar, so in CI/test environments where Python
|
||||||
* is not available the route will return 501 (lite mode) or 422 (Python error).
|
* is not available the route will return 422 (Python error).
|
||||||
* Tests gracefully handle both scenarios while still verifying route existence
|
* Tests gracefully handle both scenarios while still verifying route existence
|
||||||
* and input validation.
|
* and input validation.
|
||||||
*/
|
*/
|
||||||
@@ -49,9 +49,9 @@ describe("Content-Aware Resize", () => {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 200 = Python available, 422 = Python error, 501 = lite mode stub
|
// 200 = Python available, 422 = Python error
|
||||||
// Any of these proves the route is registered and reachable
|
// Any of these proves the route is registered and reachable
|
||||||
expect([200, 422, 501]).toContain(res.statusCode);
|
expect([200, 422]).toContain(res.statusCode);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("rejects requests without a file", async () => {
|
it("rejects requests without a file", async () => {
|
||||||
@@ -88,8 +88,8 @@ describe("Content-Aware Resize", () => {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accept 200 (Python available) or 422/501 (Python not available)
|
// Accept 200 (Python available) or 422 (Python not available)
|
||||||
expect([200, 422, 501]).toContain(res.statusCode);
|
expect([200, 422]).toContain(res.statusCode);
|
||||||
|
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
const resBody = JSON.parse(res.body);
|
const resBody = JSON.parse(res.body);
|
||||||
@@ -114,7 +114,7 @@ describe("Content-Aware Resize", () => {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect([200, 422, 501]).toContain(res.statusCode);
|
expect([200, 422]).toContain(res.statusCode);
|
||||||
|
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
const resBody = JSON.parse(res.body);
|
const resBody = JSON.parse(res.body);
|
||||||
@@ -139,10 +139,10 @@ describe("Content-Aware Resize", () => {
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 422 = Python caught the enlargement error, 501 = lite mode
|
// 422 = Python caught the enlargement error
|
||||||
// Should never be 200 since 400 > 200px source width
|
// Should never be 200 since 400 > 200px source width
|
||||||
expect(res.statusCode).not.toBe(200);
|
expect(res.statusCode).not.toBe(200);
|
||||||
expect([422, 501]).toContain(res.statusCode);
|
expect(res.statusCode).toBe(422);
|
||||||
|
|
||||||
if (res.statusCode === 422) {
|
if (res.statusCode === 422) {
|
||||||
const resBody = JSON.parse(res.body);
|
const resBody = JSON.parse(res.body);
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
||||||
|
|
||||||
describe("Lite variant", () => {
|
|
||||||
let testApp: TestApp;
|
|
||||||
let app: TestApp["app"];
|
|
||||||
let adminToken: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env.STIRLING_VARIANT = "lite";
|
|
||||||
testApp = await buildTestApp();
|
|
||||||
app = testApp.app;
|
|
||||||
adminToken = await loginAsAdmin(app);
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
delete process.env.STIRLING_VARIANT;
|
|
||||||
await testApp.cleanup();
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
describe("GET /api/v1/settings", () => {
|
|
||||||
it("includes variant and variantUnavailableTools", async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/api/v1/settings",
|
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.variant).toBe("lite");
|
|
||||||
expect(body.variantUnavailableTools).toEqual([
|
|
||||||
"remove-background",
|
|
||||||
"upscale",
|
|
||||||
"blur-faces",
|
|
||||||
"erase-object",
|
|
||||||
"ocr",
|
|
||||||
"content-aware-resize",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AI tool routes return 501", () => {
|
|
||||||
const aiTools = [
|
|
||||||
"remove-background",
|
|
||||||
"upscale",
|
|
||||||
"blur-faces",
|
|
||||||
"erase-object",
|
|
||||||
"ocr",
|
|
||||||
"content-aware-resize",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const toolId of aiTools) {
|
|
||||||
it(`POST /api/v1/tools/${toolId} returns 501`, async () => {
|
|
||||||
const res = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/api/v1/tools/${toolId}`,
|
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(501);
|
|
||||||
const body = JSON.parse(res.body);
|
|
||||||
expect(body.error).toBe("Not Available");
|
|
||||||
expect(body.message).toContain("full image");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Sharp tools still work in lite mode", () => {
|
|
||||||
it("POST /api/v1/tools/info returns 200 with valid image", async () => {
|
|
||||||
const { readFileSync } = await import("node:fs");
|
|
||||||
const { join } = await import("node:path");
|
|
||||||
const { fileURLToPath } = await import("node:url");
|
|
||||||
const __dirname = join(fileURLToPath(import.meta.url), "..");
|
|
||||||
const png = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png"));
|
|
||||||
|
|
||||||
const boundary = "----TestBoundary";
|
|
||||||
const body = Buffer.concat([
|
|
||||||
Buffer.from(
|
|
||||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n`,
|
|
||||||
),
|
|
||||||
png,
|
|
||||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const res = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/v1/tools/info",
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${adminToken}`,
|
|
||||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
|
||||||
},
|
|
||||||
payload: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||