Compare commits

...

2 Commits

79 changed files with 179 additions and 161 deletions

View File

@@ -3,5 +3,8 @@
node_modules node_modules
.DS_Store .DS_Store
*.log *.log
.offline-server.log administration/.offline-server.log
.offline-server.pid administration/.offline-server.pid
website/content/.editor-credentials.json
website/content/.editor-reset.json
website/content/.editor-rate-limit.json

12
.gitignore vendored
View File

@@ -2,17 +2,17 @@
.DS_Store .DS_Store
# Local runtime files # Local runtime files
.offline-server.log administration/.offline-server.log
.offline-server.pid administration/.offline-server.pid
# Editor auth + reset + rate limit state (never commit) # Editor auth + reset + rate limit state (never commit)
content/.editor-credentials.json website/content/.editor-credentials.json
content/.editor-reset.json website/content/.editor-reset.json
content/.editor-rate-limit.json website/content/.editor-rate-limit.json
# Generated backups # Generated backups
*.bak *.bak
content/*.bak website/content/*.bak
# Optional local tooling # Optional local tooling
node_modules/ node_modules/

View File

@@ -1,87 +1,14 @@
# ikfreunde WYSIWYG Multi-Route Deploy # interkollektives micro website
This project runs a local-content WYSIWYG editor behind Traefik and supports multiple route instances on one domain. Diese Repository-Struktur ist in zwei Zielgruppen getrennt:
Examples: - `administration/`
- `https://mydomain.de/webpage1/` Für Deployment, Docker, Traefik, Server-Skripte und technische Wartung.
- `https://mydomain.de/webpage2/`
- `https://mydomain.de/webpage3/`
## Files - `website/`
- `docker-compose.traefik-routes.yml`: Traefik-ready multi-service compose file Für Website-Inhalte (HTML, JSON, Bilder, Editor-Frontend) und redaktionelle Arbeit.
- `scripts/add-webpage.sh`: auto-generate new `webpageN` route + compose service
- `scripts/editor_server.php`: local API + static server (`/api/content`, `/api/save`)
- See [Brute-Force Protection](#brute-force-protection) for auth hardening details
## Requirements ## Einstieg
- Docker + Docker Compose
- Traefik with external network named `proxy`
## First deploy - Technik/DevOps: siehe `administration/README.md`
```bash - Redaktion/Content: siehe `website/README.md`
docker compose -f docker-compose.traefik-routes.yml up -d --build
```
## Add a new route (autogenerator)
```bash
./scripts/add-webpage.sh webpage4 mydomain.de
```
What it does:
1. Creates route data folder: `/srv/ikfreunde/webpage4/`
2. Seeds files if missing:
- `/srv/ikfreunde/webpage4/ikfreunde.com.html`
- `/srv/ikfreunde/webpage4/site-content.de.json`
3. Injects `webpage4` service into `docker-compose.traefik-routes.yml`
Then redeploy:
```bash
docker compose -f docker-compose.traefik-routes.yml up -d --build
```
Open:
- `https://mydomain.de/webpage4/`
## Notes
- Edit mode is only active with `?edit=1`.
- Saves write both HTML and JSON and create `.bak` backups.
- Route names can include letters, numbers, `_`, `-`.
## Editor claim, login, reset (v1)
- New deployment starts as **unclaimed** (viewer-only by default).
- Open `https://mydomain.de/webpageN/?edit=1` to run first-time onboarding.
- First onboarding claim uses `email + password` and creates:
- `content/.editor-credentials.json`
- Afterwards, editing requires login. Without auth, users remain viewer.
### Password reset (without SMTP)
- On failed login, trigger reset request.
- Server writes reset data to:
- `content/.editor-reset.json`
- The file contains `reset_url` with token.
- Open that URL, set new password, then login again.
Security note:
- `content/.editor-credentials.json` and `content/.editor-reset.json` are blocked from HTTP access by the server router.
- Access to these files requires container/filesystem access.
- Simple brute-force protection is enabled in-app for login/reset (`content/.editor-rate-limit.json`) with account-based + global per-site thresholds (IP-independent).
- L3/L4 DDoS and global rate limiting should be handled at Traefik/network level.
## Brute-Force Protection
- Login/Reset limits are enforced in `scripts/editor_server.php`.
- Limiting is account-based + global per site (not IP-bound), so IP hopping is less effective.
- Buckets currently used:
- `login_account`, `login_global`
- `reset_request_account`, `reset_request_global`
- `reset_confirm_account`, `reset_confirm_global`
- Rate-limit state is stored in:
- `content/.editor-rate-limit.json`
## Optional env overrides
- `ROOT_BASE` (default: `/srv/ikfreunde`)
- `COMPOSE_FILE` (default: `docker-compose.traefik-routes.yml`)
Example:
```bash
ROOT_BASE=/data/pages COMPOSE_FILE=docker-compose.traefik-routes.yml ./scripts/add-webpage.sh webpage5 mydomain.de
```

View File

@@ -6,4 +6,4 @@ COPY . /app
EXPOSE 4173 EXPOSE 4173
CMD ["php", "-d", "opcache.enable_cli=0", "-S", "0.0.0.0:4173", "scripts/editor_server.php"] CMD ["php", "-d", "opcache.enable_cli=0", "-S", "0.0.0.0:4173", "administration/scripts/editor_server.php"]

60
administration/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Administration (Technik)
Dieser Bereich ist für Deployment, Betrieb und technische Wartung.
## Struktur
- `Dockerfile`
- `docker-compose.yml`
- `docker-compose.traefik-routes.yml`
- `scripts/` (Server, Extraktion, Route-Generator)
- `docs/` (Planungs-/Brainstorm-Dokumente)
## Voraussetzungen
- Docker + Docker Compose
- Traefik mit externem Netzwerk `proxy`
## Lokaler Editor-Server
```bash
./administration/scripts/run_editor_server.sh
```
Aufruf: `http://127.0.0.1:4173/`
## Traefik Deploy
```bash
docker compose -f administration/docker-compose.traefik-routes.yml up -d --build
```
## Neue Route erzeugen
```bash
./administration/scripts/add-webpage.sh webpage4 mydomain.de
```
Danach:
```bash
docker compose -f administration/docker-compose.traefik-routes.yml up -d --build
```
## Security / Editor Auth
- Unclaimed by default (Viewer-Rolle)
- Claim/Login/Reset über API im `editor_server.php`
- Sensible Dateien liegen unter `website/content/` und sind via HTTP blockiert:
- `.editor-credentials.json`
- `.editor-reset.json`
- `.editor-rate-limit.json`
## Brute-Force Schutz
Buckets:
- `login_account`, `login_global`
- `reset_request_account`, `reset_request_global`
- `reset_confirm_account`, `reset_confirm_global`
Implementierung: `administration/scripts/editor_server.php`

View File

@@ -1,12 +1,12 @@
services: services:
webpage1: webpage1:
build: build:
context: . context: ..
dockerfile: Dockerfile dockerfile: administration/Dockerfile
container_name: ikfreunde-webpage1 container_name: ikfreunde-webpage1
volumes: volumes:
- /srv/ikfreunde/webpage1/ikfreunde.com.html:/app/ikfreunde.com.html - /srv/ikfreunde/webpage1/ikfreunde.com.html:/app/website/ikfreunde.com.html
- /srv/ikfreunde/webpage1/site-content.de.json:/app/content/site-content.de.json - /srv/ikfreunde/webpage1/site-content.de.json:/app/website/content/site-content.de.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- proxy - proxy
@@ -24,12 +24,12 @@ services:
webpage2: webpage2:
build: build:
context: . context: ..
dockerfile: Dockerfile dockerfile: administration/Dockerfile
container_name: ikfreunde-webpage2 container_name: ikfreunde-webpage2
volumes: volumes:
- /srv/ikfreunde/webpage2/ikfreunde.com.html:/app/ikfreunde.com.html - /srv/ikfreunde/webpage2/ikfreunde.com.html:/app/website/ikfreunde.com.html
- /srv/ikfreunde/webpage2/site-content.de.json:/app/content/site-content.de.json - /srv/ikfreunde/webpage2/site-content.de.json:/app/website/content/site-content.de.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- proxy - proxy
@@ -47,12 +47,12 @@ services:
webpage3: webpage3:
build: build:
context: . context: ..
dockerfile: Dockerfile dockerfile: administration/Dockerfile
container_name: ikfreunde-webpage3 container_name: ikfreunde-webpage3
volumes: volumes:
- /srv/ikfreunde/webpage3/ikfreunde.com.html:/app/ikfreunde.com.html - /srv/ikfreunde/webpage3/ikfreunde.com.html:/app/website/ikfreunde.com.html
- /srv/ikfreunde/webpage3/site-content.de.json:/app/content/site-content.de.json - /srv/ikfreunde/webpage3/site-content.de.json:/app/website/content/site-content.de.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- proxy - proxy

View File

@@ -1,13 +1,13 @@
services: services:
editor: editor:
build: build:
context: . context: ..
dockerfile: Dockerfile dockerfile: administration/Dockerfile
container_name: ikfreunde-editor container_name: ikfreunde-editor
ports: ports:
- "4173:4173" - "4173:4173"
volumes: volumes:
- .:/app - ..:/app
working_dir: /app working_dir: /app
command: ["php", "-d", "opcache.enable_cli=0", "-S", "0.0.0.0:4173", "scripts/editor_server.php"] command: ["php", "-d", "opcache.enable_cli=0", "-S", "0.0.0.0:4173", "administration/scripts/editor_server.php"]
restart: unless-stopped restart: unless-stopped

View File

@@ -16,8 +16,9 @@ if [[ ! "$NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then
exit 1 exit 1
fi fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ROOT_BASE="${ROOT_BASE:-/srv/ikfreunde}" ROOT_BASE="${ROOT_BASE:-/srv/ikfreunde}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.traefik-routes.yml}" COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/administration/docker-compose.traefik-routes.yml}"
ROOT="${ROOT_BASE}/${NAME}" ROOT="${ROOT_BASE}/${NAME}"
if [[ ! -f "$COMPOSE_FILE" ]]; then if [[ ! -f "$COMPOSE_FILE" ]]; then
@@ -28,12 +29,12 @@ fi
mkdir -p "$ROOT" mkdir -p "$ROOT"
if [[ ! -f "$ROOT/ikfreunde.com.html" ]]; then if [[ ! -f "$ROOT/ikfreunde.com.html" ]]; then
cp ikfreunde.com.html "$ROOT/ikfreunde.com.html" cp "$ROOT_DIR/website/ikfreunde.com.html" "$ROOT/ikfreunde.com.html"
echo "Created: $ROOT/ikfreunde.com.html" echo "Created: $ROOT/ikfreunde.com.html"
fi fi
if [[ ! -f "$ROOT/site-content.de.json" ]]; then if [[ ! -f "$ROOT/site-content.de.json" ]]; then
cp content/site-content.de.json "$ROOT/site-content.de.json" cp "$ROOT_DIR/website/content/site-content.de.json" "$ROOT/site-content.de.json"
echo "Created: $ROOT/site-content.de.json" echo "Created: $ROOT/site-content.de.json"
fi fi
@@ -47,12 +48,12 @@ else
${NAME}: ${NAME}:
build: build:
context: . context: ..
dockerfile: Dockerfile dockerfile: administration/Dockerfile
container_name: ikfreunde-${NAME} container_name: ikfreunde-${NAME}
volumes: volumes:
- ${ROOT}/ikfreunde.com.html:/app/ikfreunde.com.html - ${ROOT}/ikfreunde.com.html:/app/website/ikfreunde.com.html
- ${ROOT}/site-content.de.json:/app/content/site-content.de.json - ${ROOT}/site-content.de.json:/app/website/content/site-content.de.json
restart: unless-stopped restart: unless-stopped
networks: networks:
- proxy - proxy

View File

@@ -1,12 +1,13 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
const PROJECT_ROOT = __DIR__ . '/..'; const PROJECT_ROOT = __DIR__ . '/../..';
const HTML_PATH = PROJECT_ROOT . '/ikfreunde.com.html'; const WEBSITE_ROOT = PROJECT_ROOT . '/website';
const JSON_PATH = PROJECT_ROOT . '/content/site-content.de.json'; const HTML_PATH = WEBSITE_ROOT . '/ikfreunde.com.html';
const CREDENTIALS_PATH = PROJECT_ROOT . '/content/.editor-credentials.json'; const JSON_PATH = WEBSITE_ROOT . '/content/site-content.de.json';
const RESET_PATH = PROJECT_ROOT . '/content/.editor-reset.json'; const CREDENTIALS_PATH = WEBSITE_ROOT . '/content/.editor-credentials.json';
const RATE_LIMIT_PATH = PROJECT_ROOT . '/content/.editor-rate-limit.json'; const RESET_PATH = WEBSITE_ROOT . '/content/.editor-reset.json';
const RATE_LIMIT_PATH = WEBSITE_ROOT . '/content/.editor-rate-limit.json';
const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h
const RESET_TTL_SECONDS = 60 * 30; // 30m const RESET_TTL_SECONDS = 60 * 30; // 30m
const LOGIN_ACCOUNT_MAX_ATTEMPTS = 6; const LOGIN_ACCOUNT_MAX_ATTEMPTS = 6;
@@ -970,8 +971,8 @@ function serveStatic(string $uri): void
exit; exit;
} }
$resolved = realpath(PROJECT_ROOT . '/' . $cleanPath); $resolved = realpath(WEBSITE_ROOT . '/' . $cleanPath);
$root = realpath(PROJECT_ROOT); $root = realpath(WEBSITE_ROOT);
if ($resolved === false || $root === false || !str_starts_with($resolved, $root) || !is_file($resolved)) { if ($resolved === false || $root === false || !str_starts_with($resolved, $root) || !is_file($resolved)) {
http_response_code(404); http_response_code(404);

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
INPUT_HTML="${1:-website/ikfreunde.com.html}"
OUTPUT_JSON="${2:-website/content/site-content.de.json}"
mkdir -p "$(dirname "$OUTPUT_JSON")"
php -d opcache.enable_cli=0 administration/scripts/extract_dom_content.php "$INPUT_HTML" "$OUTPUT_JSON"

View File

@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
if ($argc < 3) { if ($argc < 3) {
fwrite(STDERR, "Usage: php scripts/extract_dom_content.php <input_html> <output_json>\n"); fwrite(STDERR, "Usage: php administration/scripts/extract_dom_content.php <input_html> <output_json>\n");
exit(1); exit(1);
} }

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
PORT="${1:-4173}"
ln -sf ikfreunde.com.html website/index.html
php -d opcache.enable_cli=0 -S 127.0.0.1:"$PORT" administration/scripts/editor_server.php

View File

@@ -1,12 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" WEB_DIR="$ROOT_DIR/website"
PORT="${1:-4173}" PORT="${1:-4173}"
PID_FILE=".offline-server.pid" PID_FILE="$ROOT_DIR/administration/.offline-server.pid"
LOG_FILE=".offline-server.log" LOG_FILE="$ROOT_DIR/administration/.offline-server.log"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "Offline server already running on PID $(cat "$PID_FILE")." echo "Offline server already running on PID $(cat "$PID_FILE")."
@@ -16,19 +15,19 @@ fi
rm -f "$PID_FILE" rm -f "$PID_FILE"
ln -sf ikfreunde.com.html index.html ln -sf ikfreunde.com.html "$WEB_DIR/index.html"
nohup python3 -m http.server "$PORT" --bind 127.0.0.1 >"$LOG_FILE" 2>&1 & nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$WEB_DIR" >"$LOG_FILE" 2>&1 &
SERVER_PID=$! SERVER_PID=$!
echo "$SERVER_PID" > "$PID_FILE" echo "$SERVER_PID" > "$PID_FILE"
sleep 0.3 sleep 0.3
if ! kill -0 "$SERVER_PID" 2>/dev/null; then if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Failed to start offline server on port $PORT." echo "Failed to start offline server on port $PORT."
echo "Check log: $ROOT_DIR/$LOG_FILE" echo "Check log: $LOG_FILE"
rm -f "$PID_FILE" rm -f "$PID_FILE"
exit 1 exit 1
fi fi
echo "Offline server started (PID $SERVER_PID)." echo "Offline server started (PID $SERVER_PID)."
echo "Open: http://127.0.0.1:${PORT}/" echo "Open: http://127.0.0.1:${PORT}/"
echo "Log: $ROOT_DIR/$LOG_FILE" echo "Log: $LOG_FILE"

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" PID_FILE="$ROOT_DIR/administration/.offline-server.pid"
PID_FILE=".offline-server.pid"
if [[ ! -f "$PID_FILE" ]]; then if [[ ! -f "$PID_FILE" ]]; then
echo "No PID file found. Server may already be stopped." echo "No PID file found. Server may already be stopped."

View File

@@ -1 +0,0 @@
ikfreunde.com.html

View File

@@ -1 +0,0 @@
ikfreunde.com_files

View File

@@ -1 +1 @@
ikfreunde.com.html website/ikfreunde.com.html

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
INPUT_HTML="${1:-ikfreunde.com.html}"
OUTPUT_JSON="${2:-content/site-content.de.json}"
mkdir -p "$(dirname "$OUTPUT_JSON")"
php -d opcache.enable_cli=0 scripts/extract_dom_content.php "$INPUT_HTML" "$OUTPUT_JSON"

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PORT="${1:-4173}"
ln -sf ikfreunde.com.html index.html
php -d opcache.enable_cli=0 -S 127.0.0.1:"$PORT" scripts/editor_server.php

View File

@@ -1 +0,0 @@
ikfreunde.com.html

View File

@@ -1 +0,0 @@
ikfreunde.com_files

33
website/README.md Normal file
View File

@@ -0,0 +1,33 @@
# Website (Content)
Dieser Bereich ist für Menschen gedacht, die Inhalte bearbeiten möchten.
## Was liegt hier?
- `ikfreunde.com.html` (die Seite)
- `content/site-content.de.json` (Text- und Bilddaten)
- `ikfreunde.com_files/` (Assets)
- `editor/` (WYSIWYG im Browser)
## Editor nutzen
1. Seite öffnen: `https://<domain>/<route>/?edit=1`
2. Beim ersten Mal: Claim mit `E-Mail + Passwort`
3. Danach: Login erforderlich, sonst Viewer-Modus
## Bearbeiten
- Text: Doppelklick auf Text
- Bilder: Klick aufs Bild, dann URL/Alt im Overlay ändern
- Speichern: über Editor-Steuerung
## Passwort-Reset
- Bei Login-Fehlern kann ein Reset angefordert werden
- Reset-Link wird in `content/.editor-reset.json` abgelegt
- Mit dem `reset_url` Link neues Passwort setzen
## Wichtiger Hinweis
Nicht direkt in versteckten `.editor-*` Dateien arbeiten.
Diese Dateien gehören zum Auth-System.

View File

@@ -197,7 +197,7 @@
if (!response.ok) { if (!response.ok) {
const wantsReset = window.confirm( const wantsReset = window.confirm(
"Login fehlgeschlagen. Passwort-Reset anfordern (Token wird in content/.editor-reset.json erzeugt)?" "Login fehlgeschlagen. Passwort-Reset anfordern (Token wird in website/content/.editor-reset.json erzeugt)?"
); );
if (wantsReset) { if (wantsReset) {
await triggerResetRequest(email); await triggerResetRequest(email);
@@ -229,7 +229,7 @@
} }
alert( alert(
"Reset angefordert. Prüfe im Container die Datei content/.editor-reset.json und nutze den reset_url Link." "Reset angefordert. Prüfe im Container die Datei website/content/.editor-reset.json und nutze den reset_url Link."
); );
} }

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB