Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576671f0be |
@@ -1,36 +0,0 @@
|
|||||||
# Same-host native reverse proxies should use the loopback-only published port.
|
|
||||||
IMAGE_BIND_ADDRESS=127.0.0.1
|
|
||||||
IMAGE_PORT=8191
|
|
||||||
IMAGE_REPOSITORY_PATH=/home/letrhee/sam_rebuild/image
|
|
||||||
# Persistent service state and SQLite metadata, normally on SSD storage.
|
|
||||||
IMAGE_RUNTIME_PATH=./runtime-data
|
|
||||||
# Optional separate bind for uploaded images, for example a ZFS dataset.
|
|
||||||
IMAGE_UPLOAD_PATH=./runtime-data/uploads
|
|
||||||
IMAGE_UID=1000
|
|
||||||
IMAGE_GID=1000
|
|
||||||
# Source CIDRs observed inside image-web. A host-native proxy normally appears
|
|
||||||
# as this Compose network's Docker bridge gateway, not as 127.0.0.1.
|
|
||||||
# Confirm after startup with: docker compose logs image-web
|
|
||||||
TRUSTED_PROXY_CIDRS=172.24.0.1/32
|
|
||||||
|
|
||||||
IMAGE_REMOTE_URL=https://gitea.hided.net/devsam/image.git
|
|
||||||
IMAGE_REPOSITORY_FULL_NAME=devsam/image
|
|
||||||
IMAGE_DEFAULT_BRANCH=master
|
|
||||||
IMAGE_ALLOWED_BRANCHES=master
|
|
||||||
IMAGE_PUBLIC_BASES=https://sam.hided.net/image,https://sam-image.hided.net
|
|
||||||
|
|
||||||
GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret
|
|
||||||
IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_secret
|
|
||||||
IMAGE_ADMIN_PANEL_PASSWORD_FILE=./secrets/image_admin_panel_password
|
|
||||||
IMAGE_ADMIN_PANEL_SESSION_SECRET_FILE=./secrets/image_admin_panel_session_secret
|
|
||||||
IMAGE_SYNC_CORE_SECRET_FILE=./secrets/image_sync_core_secret
|
|
||||||
IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret
|
|
||||||
IMAGE_UPLOAD_CORE_SECRET_FILE=./secrets/image_upload_core_secret
|
|
||||||
IMAGE_UPLOAD_CORE2026_SECRET_FILE=./secrets/image_upload_core2026_secret
|
|
||||||
|
|
||||||
# Account icons are permanent. These values apply only to Tiptap content images.
|
|
||||||
IMAGE_CONTENT_RETENTION_DAYS=730
|
|
||||||
IMAGE_CONTENT_QUARANTINE_DAYS=30
|
|
||||||
IMAGE_ASSET_MAINTENANCE_SECONDS=21600
|
|
||||||
IMAGE_ASSET_TOUCH_FLUSH_SECONDS=60
|
|
||||||
IMAGE_ADMIN_PANEL_SESSION_HOURS=8
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
/hook/gogs_key.php
|
/hook/gogs_key.php
|
||||||
/hook/logs.txt
|
/hook/logs.txt
|
||||||
/hook/list.json
|
/hook/list.json
|
||||||
/hook/HashKey.php
|
|
||||||
/hook/legacy-enabled
|
|
||||||
/hook/inventory.v2.json
|
|
||||||
/.env
|
|
||||||
/secrets/*
|
|
||||||
!/secrets/.gitkeep
|
|
||||||
/runtime-data/
|
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
# Shared image repository
|
|
||||||
|
|
||||||
`icons/` is served as `/image/icons/`. General portraits follow these rules:
|
|
||||||
|
|
||||||
- `icons/장수/<장수명>.<확장자>` is the canonical shared path.
|
|
||||||
- Scenario-specific collections keep a separate directory such as
|
|
||||||
`icons/걸그룹/` or `icons/롤시나리오/`.
|
|
||||||
- Root-level numeric files such as `icons/1047.jpg` are compatibility aliases
|
|
||||||
for deployed legacy versions. Do not delete or replace them while those
|
|
||||||
versions are in service.
|
|
||||||
- A general name must map to one canonical file. If two records intentionally
|
|
||||||
need different portraits, disambiguate the scenario name or use an explicit
|
|
||||||
scenario-specific path.
|
|
||||||
|
|
||||||
The core2026 repository owns `resources/general-icons.json` and
|
|
||||||
`tools/manage-general-icons.mjs`. Use that tool with this repository as
|
|
||||||
`--image-root` to synchronize aliases, scenario paths, and verify that every
|
|
||||||
catalog source exists and has identical bytes.
|
|
||||||
|
|
||||||
## Public URLs
|
|
||||||
|
|
||||||
The same tracked files are exposed through both URL contracts:
|
|
||||||
|
|
||||||
- `https://sam.hided.net/image/game/...` and `/image/icons/...`
|
|
||||||
- `https://sam-image.hided.net/game/...` and `/icons/...`
|
|
||||||
- `https://sam-image.hided.net/image/...` is a compatibility alias.
|
|
||||||
|
|
||||||
Do not redirect the old `/image/*` URLs to the dedicated domain. Existing PHP
|
|
||||||
and core2026 clients use same-origin relative paths and can move independently.
|
|
||||||
The image server never exposes the repository root, `.git`, PHP sources, or
|
|
||||||
directory listings.
|
|
||||||
|
|
||||||
## Node webhook deployment service
|
|
||||||
|
|
||||||
`compose.yaml` runs a read-only Nginx static edge and an internal Node service.
|
|
||||||
Only the edge publishes port 8191. The Node service verifies the raw Gitea
|
|
||||||
`X-Gitea-Signature`, accepts push events for `devsam/image`, fetches the exact
|
|
||||||
remote branch tip, rejects dirty or non-fast-forward updates, and serializes all
|
|
||||||
Git changes. The initial and only allowed branch is `master` unless
|
|
||||||
`IMAGE_ALLOWED_BRANCHES` is explicitly expanded.
|
|
||||||
|
|
||||||
The service is reverse-proxy agnostic. Caddy, Nginx, HAProxy, or another proxy
|
|
||||||
can use the same port and path contract.
|
|
||||||
|
|
||||||
For a native reverse proxy running on the same host as Docker, publish port
|
|
||||||
8191 on loopback only and proxy to `127.0.0.1:8191`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
IMAGE_BIND_ADDRESS=127.0.0.1
|
|
||||||
IMAGE_PORT=8191
|
|
||||||
TRUSTED_PROXY_CIDRS=172.24.0.1/32
|
|
||||||
```
|
|
||||||
|
|
||||||
These two addresses intentionally describe different hops. The native proxy
|
|
||||||
connects to the host loopback address, while the Nginx process inside
|
|
||||||
`image-web` normally observes the connection as coming from that Compose
|
|
||||||
network's Docker bridge gateway (`172.24.0.1` in the example). Use the source
|
|
||||||
shown in the `image-web` access/error log, then recreate `image-web` whenever
|
|
||||||
`TRUSTED_PROXY_CIDRS` changes. Do not replace it with a Cloudflare CIDR: the
|
|
||||||
direct peer of `image-web` remains the host-native reverse proxy path.
|
|
||||||
|
|
||||||
If the reverse proxy is on another host, bind an explicit private interface
|
|
||||||
instead of loopback and apply the host `DOCKER-USER` (or equivalent) firewall
|
|
||||||
rule. Keep every source CIDR observed by `image-web` in the untracked
|
|
||||||
`TRUSTED_PROXY_CIDRS` value, separated by commas or spaces. Avoid `0.0.0.0`
|
|
||||||
unless the firewall has already been verified.
|
|
||||||
|
|
||||||
### Prepare
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp .env.example .env
|
|
||||||
./deploy/scripts/init-secrets.sh
|
|
||||||
docker compose config --quiet
|
|
||||||
docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
Only for a proxy on another host, apply and inspect the dedicated Docker
|
|
||||||
ingress chain with root privileges:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo env TRUSTED_PROXY_CIDRS=192.0.2.10/32 IMAGE_PORT=8191 \
|
|
||||||
./deploy/scripts/firewall-8191.sh apply
|
|
||||||
sudo ./deploy/scripts/firewall-8191.sh check
|
|
||||||
```
|
|
||||||
|
|
||||||
The script only owns the `SAM_IMAGE_INGRESS` chain and its port-8191 jump from
|
|
||||||
`DOCKER-USER`; it does not flush shared firewall chains.
|
|
||||||
|
|
||||||
For the same-host native-proxy case, point the proxy upstream at loopback. A
|
|
||||||
minimal Caddy site is:
|
|
||||||
|
|
||||||
```caddyfile
|
|
||||||
sam-image.hided.net {
|
|
||||||
reverse_proxy 127.0.0.1:8191
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The equivalent Nginx/HAProxy configuration should use the same upstream. The
|
|
||||||
application does not depend on Caddy-specific request behavior.
|
|
||||||
|
|
||||||
Secret values are generated under ignored `secrets/` files with mode 0600 and
|
|
||||||
are never printed. Put the contents of `secrets/gitea_webhook_secret` into the
|
|
||||||
Gitea webhook configuration. Configure a JSON push webhook targeting:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://sam-image.hided.net/v1/hooks/gitea
|
|
||||||
```
|
|
||||||
|
|
||||||
Use branch filter `master`. Disable the old PHP webhook before enabling the new
|
|
||||||
writer. The legacy PHP files remain in `hook/` for an explicit rollback, but
|
|
||||||
PHP and Node must never mutate the checkout concurrently.
|
|
||||||
|
|
||||||
### Fallback sync callers
|
|
||||||
|
|
||||||
If Gitea webhook delivery is missed, Core and Core2026 can request a restricted
|
|
||||||
reconciliation through `POST /v1/sync`. This endpoint cannot select or change a
|
|
||||||
branch: it only fetches the current active branch and applies the same clean
|
|
||||||
worktree and fast-forward checks as a webhook deployment.
|
|
||||||
|
|
||||||
Each caller has a separate secret:
|
|
||||||
|
|
||||||
- `secrets/image_sync_core_secret` for legacy Core
|
|
||||||
- `secrets/image_sync_core2026_secret` for Core2026
|
|
||||||
|
|
||||||
The caller sends its name, a timestamp, a unique request ID, and an HMAC-SHA256
|
|
||||||
signature over `timestamp.request-id.<exact JSON body>`. Requests expire after
|
|
||||||
five minutes and successful request IDs are persisted for replay protection.
|
|
||||||
The body is either `{}` or `{ "commit": "<full-image-commit-sha>" }`; all other
|
|
||||||
fields are rejected. The optional commit asserts the expected remote tip and
|
|
||||||
does not grant checkout selection.
|
|
||||||
|
|
||||||
Distribute only the matching caller secret through an ignored secret file.
|
|
||||||
Never give either caller `image_admin_secret`, which also authorizes explicit
|
|
||||||
branch changes. This fallback handles webhook delivery outages; if the image
|
|
||||||
service itself is stopped, restore it and run the caller command again.
|
|
||||||
|
|
||||||
### User uploads
|
|
||||||
|
|
||||||
Core and Core2026 can store validated account icons and editor attachments
|
|
||||||
through this service with
|
|
||||||
`PUT /v1/uploads/<user-icons|content>/<client>/<random-32-hex>.<extension>`. Each game
|
|
||||||
server validates the authenticated user and image first, then sends the raw
|
|
||||||
image body with `X-Image-Client`, `X-Image-Expires`, `X-Image-Request-Id`, and
|
|
||||||
`X-Image-Signature` headers.
|
|
||||||
|
|
||||||
The signature is HMAC-SHA256 over
|
|
||||||
`expires.requestId.pathname.contentType.sha256(body)`. Expiry may be at most
|
|
||||||
five minutes in the future, so a grant cannot be reused for another path,
|
|
||||||
content type, body, or later upload. The service also checks the image magic,
|
|
||||||
caller scope, and request replay before it writes one immutable file below the
|
|
||||||
host bind directory `runtime-data/uploads`. User uploads are deliberately not
|
|
||||||
added to Git; Nginx exposes that bind read-only at `/icons/users/` for account
|
|
||||||
icons and `/uploads/` for editor content. User icons retain the 50KB limit;
|
|
||||||
editor content retains the existing 1MB limit.
|
|
||||||
|
|
||||||
Create separate upload secrets with `deploy/scripts/init-secrets.sh`. Mount only
|
|
||||||
the matching `image_upload_core_secret` or `image_upload_core2026_secret` on the
|
|
||||||
game server. The shared secrets stay server-side in Docker secrets; they are
|
|
||||||
not returned to browsers or forwarded to Cloudflare.
|
|
||||||
|
|
||||||
Run `deploy/scripts/init-secrets.sh` before the first Compose start so the upload
|
|
||||||
directory exists with permissions that allow the hook container to write and
|
|
||||||
the Nginx container to read. `IMAGE_UPLOAD_PATH` can point only the uploaded
|
|
||||||
images at a separate ZFS dataset. Keep `IMAGE_RUNTIME_PATH` on SSD storage for
|
|
||||||
the small SQLite database and service state. Back up both paths independently
|
|
||||||
of the Git repository when moving servers.
|
|
||||||
|
|
||||||
### Retention and image administration
|
|
||||||
|
|
||||||
Account icons below `/icons/users/` are permanent. They are inventoried in the
|
|
||||||
metadata database but never become deletion candidates. Tiptap images below
|
|
||||||
`/uploads/` become candidates after 730 days without a request reaching the
|
|
||||||
origin Nginx. Nginx mirrors a small internal notification to Node for those
|
|
||||||
requests; Node deduplicates paths in memory and writes each path to SQLite at
|
|
||||||
most once per flush interval (60 seconds by default).
|
|
||||||
|
|
||||||
Cloudflare and browser cache hits do not necessarily reach the origin. The
|
|
||||||
two-year interval is therefore an operational expiry policy, not proof that an
|
|
||||||
old HTML document no longer contains the URL. Confirm that the Cloudflare edge
|
|
||||||
TTL does not exceed one year before enabling this policy.
|
|
||||||
|
|
||||||
Candidates remain publicly available until an administrator quarantines them.
|
|
||||||
Quarantine moves the file into a hidden directory on the same upload bind and
|
|
||||||
is reversible. Permanent deletion is disabled until the 30-day quarantine
|
|
||||||
period has elapsed. A request received while an image is only a candidate
|
|
||||||
returns it to active state; requests do not restore quarantined files.
|
|
||||||
|
|
||||||
Open the minimal administration panel at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://sam-image.hided.net/admin/
|
|
||||||
```
|
|
||||||
|
|
||||||
Log in with the value in the ignored `secrets/image_admin_panel_password` file.
|
|
||||||
The panel can filter and preview assets, show storage and retention timestamps,
|
|
||||||
quarantine candidates, restore quarantined files, and permanently delete files
|
|
||||||
whose grace period has elapsed. It uses a separate short-lived, HttpOnly,
|
|
||||||
SameSite session; the stronger `image_admin_secret` used for branch deployment
|
|
||||||
is never sent to the browser. Rotate the panel password by replacing its secret
|
|
||||||
file and recreating `image-hook`.
|
|
||||||
|
|
||||||
Retention settings are available in `.env`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
IMAGE_CONTENT_RETENTION_DAYS=730
|
|
||||||
IMAGE_CONTENT_QUARANTINE_DAYS=30
|
|
||||||
IMAGE_ASSET_TOUCH_FLUSH_SECONDS=60
|
|
||||||
IMAGE_ASSET_MAINTENANCE_SECONDS=21600
|
|
||||||
```
|
|
||||||
|
|
||||||
The SQLite database uses WAL and synchronous writes. Back it up with the service
|
|
||||||
stopped or with a SQLite-aware backup; do not copy only the main `.sqlite3` file
|
|
||||||
while the service is running because committed data can still be in `-wal`.
|
|
||||||
|
|
||||||
Legacy HTTP mutation is disabled by default. An emergency PHP rollback must
|
|
||||||
first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel
|
|
||||||
in the legacy checkout before restoring its Caddy/Gitea route. Remove the
|
|
||||||
sentinel before Node is started again. CLI execution of `hook/git_pull.php`
|
|
||||||
from the `hook/` directory remains available for local recovery without
|
|
||||||
exposing the HTTP endpoint.
|
|
||||||
|
|
||||||
### Start and verify
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose up -d --build
|
|
||||||
docker compose ps
|
|
||||||
curl -fsS https://sam-image.hided.net/healthz
|
|
||||||
curl -fsS https://sam-image.hided.net/v1/status
|
|
||||||
curl -fsS https://sam-image.hided.net/game/back.jpg -o /dev/null
|
|
||||||
curl -fsS https://sam-image.hided.net/image/icons/default.jpg -o /dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Then log in to `/admin/` and verify that an existing account icon is shown as
|
|
||||||
permanent (`user-icons`, `active`) and a Tiptap upload is shown as `content`.
|
|
||||||
|
|
||||||
Nginx serves the static `game/`, `icons/`, and `uploads/` paths, the two public
|
|
||||||
inventory files, and the authenticated `/admin/` panel. The API inventory
|
|
||||||
additionally reports the deployed branch, full commit, generation time, asset
|
|
||||||
list, and both public base URLs.
|
|
||||||
|
|
||||||
### Explicit branch deployment
|
|
||||||
|
|
||||||
Automatic pushes only update the active branch. Add a branch to
|
|
||||||
`IMAGE_ALLOWED_BRANCHES`, recreate `image-hook`, and switch it with the internal
|
|
||||||
signed administration command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./deploy/scripts/admin-deploy.sh <branch> [expected-commit]
|
|
||||||
```
|
|
||||||
|
|
||||||
The signed branch-deployment route under `/v1/admin/` is not exposed through
|
|
||||||
the public reverse proxy. It is separate from the password-authenticated image
|
|
||||||
management panel at `/admin/`.
|
|
||||||
|
|
||||||
### Tests and rollback
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker build -t sam-image-hook:test node-hook
|
|
||||||
docker run --rm sam-image-hook:test npm test
|
|
||||||
docker compose exec -T image-web nginx -t -c /tmp/nginx.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping `image-hook` does not remove the last checked-out files from the
|
|
||||||
static service. For a full rollback, stop Node mutation first, restore the old
|
|
||||||
Caddy/PHP webhook route, and only then enable the legacy writer. Preserve both
|
|
||||||
checkouts until public file hashes and representative browser requests have
|
|
||||||
been verified.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
name: sam-image
|
|
||||||
|
|
||||||
services:
|
|
||||||
image-hook:
|
|
||||||
build:
|
|
||||||
context: ./node-hook
|
|
||||||
image: sam-image-hook:1.3.0
|
|
||||||
restart: unless-stopped
|
|
||||||
user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}"
|
|
||||||
read_only: true
|
|
||||||
init: true
|
|
||||||
environment:
|
|
||||||
PORT: "8081"
|
|
||||||
IMAGE_REPOSITORY_PATH: /data/image
|
|
||||||
IMAGE_REMOTE_URL: ${IMAGE_REMOTE_URL:-https://gitea.hided.net/devsam/image.git}
|
|
||||||
IMAGE_REPOSITORY_FULL_NAME: ${IMAGE_REPOSITORY_FULL_NAME:-devsam/image}
|
|
||||||
IMAGE_DEFAULT_BRANCH: ${IMAGE_DEFAULT_BRANCH:-master}
|
|
||||||
IMAGE_ALLOWED_BRANCHES: ${IMAGE_ALLOWED_BRANCHES:-master}
|
|
||||||
IMAGE_PUBLIC_BASES: ${IMAGE_PUBLIC_BASES:-https://sam.hided.net/image,https://sam-image.hided.net}
|
|
||||||
IMAGE_STATE_PATH: /var/lib/image-hook/state.json
|
|
||||||
IMAGE_UPLOAD_ROOT: /var/lib/image-hook/uploads
|
|
||||||
IMAGE_UPLOAD_STATE_PATH: /var/lib/image-hook/upload-state.json
|
|
||||||
IMAGE_ASSET_DB_PATH: /var/lib/image-hook/image-assets.sqlite3
|
|
||||||
IMAGE_CONTENT_RETENTION_DAYS: ${IMAGE_CONTENT_RETENTION_DAYS:-730}
|
|
||||||
IMAGE_CONTENT_QUARANTINE_DAYS: ${IMAGE_CONTENT_QUARANTINE_DAYS:-30}
|
|
||||||
IMAGE_ASSET_MAINTENANCE_SECONDS: ${IMAGE_ASSET_MAINTENANCE_SECONDS:-21600}
|
|
||||||
IMAGE_ASSET_TOUCH_FLUSH_SECONDS: ${IMAGE_ASSET_TOUCH_FLUSH_SECONDS:-60}
|
|
||||||
IMAGE_ADMIN_PANEL_SESSION_HOURS: ${IMAGE_ADMIN_PANEL_SESSION_HOURS:-8}
|
|
||||||
GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret
|
|
||||||
IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_secret
|
|
||||||
IMAGE_ADMIN_PANEL_PASSWORD_FILE: /run/secrets/image_admin_panel_password
|
|
||||||
IMAGE_ADMIN_PANEL_SESSION_SECRET_FILE: /run/secrets/image_admin_panel_session_secret
|
|
||||||
IMAGE_SYNC_CLIENT_SECRET_FILES: core=/run/secrets/image_sync_core_secret,core2026=/run/secrets/image_sync_core2026_secret
|
|
||||||
IMAGE_UPLOAD_CLIENT_SECRET_FILES: core=/run/secrets/image_upload_core_secret,core2026=/run/secrets/image_upload_core2026_secret
|
|
||||||
MAX_UPLOAD_BYTES: "51200"
|
|
||||||
MAX_CONTENT_UPLOAD_BYTES: "1048576"
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ${IMAGE_REPOSITORY_PATH:-.}
|
|
||||||
target: /data/image
|
|
||||||
- ${IMAGE_RUNTIME_PATH:-./runtime-data}:/var/lib/image-hook
|
|
||||||
- type: bind
|
|
||||||
source: ${IMAGE_UPLOAD_PATH:-./runtime-data/uploads}
|
|
||||||
target: /var/lib/image-hook/uploads
|
|
||||||
secrets:
|
|
||||||
- gitea_webhook_secret
|
|
||||||
- image_admin_secret
|
|
||||||
- image_admin_panel_password
|
|
||||||
- image_admin_panel_session_secret
|
|
||||||
- image_sync_core_secret
|
|
||||||
- image_sync_core2026_secret
|
|
||||||
- image_upload_core_secret
|
|
||||||
- image_upload_core2026_secret
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=16m,mode=1777
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
pids_limit: 64
|
|
||||||
mem_limit: 256m
|
|
||||||
cpus: 1.0
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, node, -e, "fetch('http://127.0.0.1:8081/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 6
|
|
||||||
start_period: 10s
|
|
||||||
networks: [image-internal, image-egress]
|
|
||||||
|
|
||||||
image-web:
|
|
||||||
build:
|
|
||||||
context: ./deploy/nginx
|
|
||||||
image: sam-image-web:1.3.0
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
image-hook:
|
|
||||||
condition: service_healthy
|
|
||||||
read_only: true
|
|
||||||
environment:
|
|
||||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:?Set TRUSTED_PROXY_CIDRS to the direct reverse-proxy source CIDR list}
|
|
||||||
ports:
|
|
||||||
- "${IMAGE_BIND_ADDRESS:-0.0.0.0}:${IMAGE_PORT:-8191}:8080"
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ${IMAGE_REPOSITORY_PATH:-.}
|
|
||||||
target: /srv/image
|
|
||||||
read_only: true
|
|
||||||
- type: bind
|
|
||||||
source: ${IMAGE_UPLOAD_PATH:-./runtime-data/uploads}
|
|
||||||
target: /srv/uploads
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:size=8m,mode=1777
|
|
||||||
cap_drop: [ALL]
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
pids_limit: 32
|
|
||||||
mem_limit: 64m
|
|
||||||
cpus: 0.5
|
|
||||||
healthcheck:
|
|
||||||
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 6
|
|
||||||
networks: [image-internal, image-edge]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
image-internal:
|
|
||||||
internal: true
|
|
||||||
image-egress:
|
|
||||||
image-edge:
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
gitea_webhook_secret:
|
|
||||||
file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret}
|
|
||||||
image_admin_secret:
|
|
||||||
file: ${IMAGE_ADMIN_SECRET_FILE:-./secrets/image_admin_secret}
|
|
||||||
image_admin_panel_password:
|
|
||||||
file: ${IMAGE_ADMIN_PANEL_PASSWORD_FILE:-./secrets/image_admin_panel_password}
|
|
||||||
image_admin_panel_session_secret:
|
|
||||||
file: ${IMAGE_ADMIN_PANEL_SESSION_SECRET_FILE:-./secrets/image_admin_panel_session_secret}
|
|
||||||
image_sync_core_secret:
|
|
||||||
file: ${IMAGE_SYNC_CORE_SECRET_FILE:-./secrets/image_sync_core_secret}
|
|
||||||
image_sync_core2026_secret:
|
|
||||||
file: ${IMAGE_SYNC_CORE2026_SECRET_FILE:-./secrets/image_sync_core2026_secret}
|
|
||||||
image_upload_core_secret:
|
|
||||||
file: ${IMAGE_UPLOAD_CORE_SECRET_FILE:-./secrets/image_upload_core_secret}
|
|
||||||
image_upload_core2026_secret:
|
|
||||||
file: ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:-./secrets/image_upload_core2026_secret}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
FROM nginx:1.29.5-alpine@sha256:1eff5a5f3fcf8431a0abb7eddf5471fec24e5e1905a2581aeacdb07a4479b92b
|
|
||||||
|
|
||||||
COPY templates/default.conf.template /etc/image/default.conf.template
|
|
||||||
COPY entrypoint.sh /usr/local/bin/image-web-entrypoint
|
|
||||||
RUN chmod 0555 /usr/local/bin/image-web-entrypoint
|
|
||||||
|
|
||||||
USER nginx
|
|
||||||
EXPOSE 8080
|
|
||||||
ENTRYPOINT ["/usr/local/bin/image-web-entrypoint"]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
trusted_proxy_cidrs=${TRUSTED_PROXY_CIDRS:-${CADDY_SOURCE_CIDR:-}}
|
|
||||||
|
|
||||||
if [ -z "$trusted_proxy_cidrs" ]; then
|
|
||||||
echo "TRUSTED_PROXY_CIDRS is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
: > /tmp/trusted-proxy-allow.conf
|
|
||||||
for trusted_proxy_cidr in $(printf '%s' "$trusted_proxy_cidrs" | tr ',' ' '); do
|
|
||||||
case "$trusted_proxy_cidr" in
|
|
||||||
*[!0-9A-Fa-f:./]*)
|
|
||||||
echo "Invalid trusted proxy CIDR: $trusted_proxy_cidr" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
printf 'allow %s;\n' "$trusted_proxy_cidr" >> /tmp/trusted-proxy-allow.conf
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ! -s /tmp/trusted-proxy-allow.conf ]; then
|
|
||||||
echo "TRUSTED_PROXY_CIDRS must contain at least one CIDR" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
cp /etc/image/default.conf.template /tmp/nginx.conf
|
|
||||||
|
|
||||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
worker_processes auto;
|
|
||||||
pid /tmp/nginx.pid;
|
|
||||||
error_log /dev/stderr notice;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 256;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
access_log /dev/stdout combined;
|
|
||||||
client_body_temp_path /tmp/client_body;
|
|
||||||
proxy_temp_path /tmp/proxy;
|
|
||||||
fastcgi_temp_path /tmp/fastcgi;
|
|
||||||
uwsgi_temp_path /tmp/uwsgi;
|
|
||||||
scgi_temp_path /tmp/scgi;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name _;
|
|
||||||
server_tokens off;
|
|
||||||
root /srv/image;
|
|
||||||
disable_symlinks on;
|
|
||||||
|
|
||||||
allow 127.0.0.1;
|
|
||||||
allow ::1;
|
|
||||||
include /tmp/trusted-proxy-allow.conf;
|
|
||||||
deny all;
|
|
||||||
|
|
||||||
location = /healthz {
|
|
||||||
proxy_pass http://image-hook:8081/healthz;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /v1/status {
|
|
||||||
proxy_pass http://image-hook:8081/v1/status;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /v1/inventory {
|
|
||||||
proxy_pass http://image-hook:8081/v1/inventory;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_buffering off;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /v1/hooks/gitea {
|
|
||||||
limit_except POST { deny all; }
|
|
||||||
client_max_body_size 1m;
|
|
||||||
proxy_pass http://image-hook:8081/v1/hooks/gitea;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Gitea-Signature $http_x_gitea_signature;
|
|
||||||
proxy_set_header X-Gitea-Event $http_x_gitea_event;
|
|
||||||
proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery;
|
|
||||||
proxy_request_buffering on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /v1/sync {
|
|
||||||
limit_except POST { deny all; }
|
|
||||||
client_max_body_size 4k;
|
|
||||||
proxy_pass http://image-hook:8081/v1/sync;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Image-Client $http_x_image_client;
|
|
||||||
proxy_set_header X-Image-Timestamp $http_x_image_timestamp;
|
|
||||||
proxy_set_header X-Image-Request-Id $http_x_image_request_id;
|
|
||||||
proxy_set_header X-Image-Signature $http_x_image_signature;
|
|
||||||
proxy_request_buffering on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /v1/uploads/ {
|
|
||||||
limit_except PUT { deny all; }
|
|
||||||
client_max_body_size 1m;
|
|
||||||
proxy_pass http://image-hook:8081;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Image-Client $http_x_image_client;
|
|
||||||
proxy_set_header X-Image-Expires $http_x_image_expires;
|
|
||||||
proxy_set_header X-Image-Request-Id $http_x_image_request_id;
|
|
||||||
proxy_set_header X-Image-Signature $http_x_image_signature;
|
|
||||||
proxy_request_buffering on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /v1/admin/ { return 404; }
|
|
||||||
|
|
||||||
location = /admin {
|
|
||||||
proxy_pass http://image-hook:8081/admin;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /admin/ {
|
|
||||||
client_max_body_size 8k;
|
|
||||||
proxy_pass http://image-hook:8081;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_request_buffering on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /v1/internal/content-access {
|
|
||||||
internal;
|
|
||||||
proxy_pass http://image-hook:8081/v1/internal/content-access;
|
|
||||||
proxy_pass_request_body off;
|
|
||||||
proxy_set_header Content-Length "";
|
|
||||||
proxy_set_header X-Image-Path $request_uri;
|
|
||||||
access_log off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /image { return 404; }
|
|
||||||
location = /image/ { return 404; }
|
|
||||||
location ^~ /image/ { rewrite ^/image/(.*)$ /$1 last; }
|
|
||||||
|
|
||||||
location ^~ /game/ {
|
|
||||||
try_files $uri =404;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ "^/icons/users/([a-z0-9][a-z0-9_-]{1,31})/([a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif))$" {
|
|
||||||
alias /srv/uploads/user-icons/$1/$2;
|
|
||||||
etag on;
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable" always;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /icons/users/ { return 404; }
|
|
||||||
|
|
||||||
location ~ "^/uploads/([a-z0-9][a-z0-9_-]{1,31})/([a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif))$" {
|
|
||||||
alias /srv/uploads/content/$1/$2;
|
|
||||||
mirror /v1/internal/content-access;
|
|
||||||
mirror_request_body off;
|
|
||||||
etag on;
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable" always;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /uploads/ { return 404; }
|
|
||||||
|
|
||||||
location /icons/ {
|
|
||||||
try_files $uri =404;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /hook/list.json {
|
|
||||||
try_files $uri =404;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /hook/inventory.v2.json {
|
|
||||||
try_files $uri =404;
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ (^|/)\. { return 404; }
|
|
||||||
location ~ \.php$ { return 404; }
|
|
||||||
location / { return 404; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
|
|
||||||
echo "Usage: $0 <branch> [commit]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
|
|
||||||
branch=$1
|
|
||||||
commit=${2:-}
|
|
||||||
set -- deploy --branch "$branch"
|
|
||||||
if [ -n "$commit" ]; then
|
|
||||||
set -- "$@" --commit "$commit"
|
|
||||||
fi
|
|
||||||
exec docker compose --project-directory "$repository_dir" exec -T image-hook node src/admin-cli.mjs "$@"
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
action=${1:-check}
|
|
||||||
trusted_proxy_cidrs=${TRUSTED_PROXY_CIDRS:-${CADDY_SOURCE_CIDR:-}}
|
|
||||||
image_port=${IMAGE_PORT:-8191}
|
|
||||||
chain=SAM_IMAGE_INGRESS
|
|
||||||
|
|
||||||
case "$image_port" in
|
|
||||||
''|*[!0-9]*) echo "IMAGE_PORT must be numeric" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
require_root() {
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "Run this action as root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$action" in
|
|
||||||
check)
|
|
||||||
iptables -S DOCKER-USER 2>/dev/null | grep -F "$chain" || true
|
|
||||||
iptables -S "$chain" 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
apply)
|
|
||||||
require_root
|
|
||||||
if [ -z "$trusted_proxy_cidrs" ]; then
|
|
||||||
echo "TRUSTED_PROXY_CIDRS is required" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
rule_count=0
|
|
||||||
for trusted_proxy_cidr in $(printf '%s' "$trusted_proxy_cidrs" | tr ',' ' '); do
|
|
||||||
case "$trusted_proxy_cidr" in
|
|
||||||
*[!0-9A-Fa-f:./]*)
|
|
||||||
echo "Invalid trusted proxy CIDR: $trusted_proxy_cidr" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
rule_count=$((rule_count + 1))
|
|
||||||
done
|
|
||||||
if [ "$rule_count" -eq 0 ]; then
|
|
||||||
echo "TRUSTED_PROXY_CIDRS must contain at least one CIDR" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
iptables -n -L DOCKER-USER >/dev/null
|
|
||||||
iptables -n -L "$chain" >/dev/null 2>&1 || iptables -N "$chain"
|
|
||||||
iptables -F "$chain"
|
|
||||||
iptables -A "$chain" -j DROP
|
|
||||||
for trusted_proxy_cidr in $(printf '%s' "$trusted_proxy_cidrs" | tr ',' ' '); do
|
|
||||||
iptables -I "$chain" 1 -s "$trusted_proxy_cidr" -j ACCEPT
|
|
||||||
done
|
|
||||||
iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null \
|
|
||||||
|| iptables -I DOCKER-USER 1 -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain"
|
|
||||||
;;
|
|
||||||
remove)
|
|
||||||
require_root
|
|
||||||
while iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null; do
|
|
||||||
iptables -D DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain"
|
|
||||||
done
|
|
||||||
if iptables -n -L "$chain" >/dev/null 2>&1; then
|
|
||||||
iptables -F "$chain"
|
|
||||||
iptables -X "$chain"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 [check|apply|remove]" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
|
|
||||||
secret_dir="$repository_dir/secrets"
|
|
||||||
state_dir=${IMAGE_RUNTIME_PATH:-$repository_dir/runtime-data}
|
|
||||||
upload_dir=${IMAGE_UPLOAD_PATH:-$state_dir/uploads}
|
|
||||||
|
|
||||||
umask 077
|
|
||||||
mkdir -p "$secret_dir"
|
|
||||||
mkdir -p "$state_dir"
|
|
||||||
mkdir -p "$upload_dir"
|
|
||||||
for name in gitea_webhook_secret image_admin_secret image_admin_panel_password image_admin_panel_session_secret image_sync_core_secret image_sync_core2026_secret image_upload_core_secret image_upload_core2026_secret; do
|
|
||||||
path="$secret_dir/$name"
|
|
||||||
if [ ! -e "$path" ]; then
|
|
||||||
openssl rand -hex 32 > "$path"
|
|
||||||
fi
|
|
||||||
chmod 600 "$path"
|
|
||||||
done
|
|
||||||
chmod 700 "$state_dir"
|
|
||||||
chmod 755 "$upload_dir"
|
|
||||||
|
|
||||||
echo "Secret files are ready in $secret_dir (values not printed)."
|
|
||||||
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 25 KiB |