feat: add placeholder api supporting svg and png
This commit is contained in:
parent
840cd6c02b
commit
7390cdd6a6
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
build
|
||||
dist
|
||||
.agents
|
||||
.codex
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
42
.pre-commit-config.yaml
Normal file
42
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
default_install_hook_types: [pre-commit, commit-msg]
|
||||
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
name: Ruff lint
|
||||
entry: uv run --locked ruff check --fix
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
stages: [pre-commit]
|
||||
require_serial: true
|
||||
|
||||
- id: ruff-format
|
||||
name: Ruff format
|
||||
entry: uv run --locked ruff format
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
stages: [pre-commit]
|
||||
require_serial: true
|
||||
|
||||
- id: pyright
|
||||
name: Pyright
|
||||
entry: uv run --locked pyright
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest
|
||||
name: Pytest
|
||||
entry: uv run --locked pytest -q
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: commitizen
|
||||
name: Check commit message
|
||||
entry: uv run --locked cz check --commit-msg-file
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.12
|
||||
24
Containerfile
Normal file
24
Containerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
FROM python:3.12-alpine
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /uvx /bin/
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Cairo renders PNGs; DejaVu supplies the placeholder's sans-serif font.
|
||||
RUN apk add --no-cache cairo font-dejavu \
|
||||
&& addgroup -S app \
|
||||
&& adduser -S -G app app
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
RUN apk add --no-cache --virtual .build-deps build-base libffi-dev \
|
||||
&& uv sync --locked --no-dev --no-editable \
|
||||
&& uv cache clean \
|
||||
&& apk del .build-deps
|
||||
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "app:create_app()"]
|
||||
100
README.md
Normal file
100
README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Placeholder API
|
||||
|
||||
Generate SVG and PNG placeholder images with configurable dimensions and font size.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Local development uses uv and Python 3.12 or newer. PNG rendering also needs
|
||||
Cairo, which you install with your system package manager before running `uv sync`.
|
||||
|
||||
Run the appropriate command for your distribution as root:
|
||||
|
||||
| Distribution | Install Cairo |
|
||||
| --- | --- |
|
||||
| [Alpine](https://pkgs.alpinelinux.org/packages?name=cairo) | `apk add cairo` |
|
||||
| [Arch](https://archlinux.org/packages/extra/x86_64/cairo/) | `pacman -S cairo` |
|
||||
| [Debian](https://packages.debian.org/en/trixie/libcairo2) | `apt install libcairo2` |
|
||||
| [Void](https://github.com/void-linux/void-packages/blob/master/srcpkgs/cairo/template) | `xbps-install -S cairo` |
|
||||
|
||||
For a container setup, you only need Podman on the host. The image includes
|
||||
Python, Cairo, and fonts.
|
||||
|
||||
## Running the app
|
||||
|
||||
Run the development server:
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
uv run flask --app app:create_app run --debug
|
||||
```
|
||||
|
||||
Alternatively, `uv run app` starts the development server without debug mode.
|
||||
|
||||
- `GET /placeholder.svg` returns an SVG placeholder.
|
||||
- `GET /placeholder.png` returns a PNG placeholder.
|
||||
- `GET /healthz` returns `{"status": "ok"}`.
|
||||
|
||||
Image endpoints accept `width`, `height`, and `font_size` query parameters.
|
||||
|
||||
For example, `/placeholder.png?width=320&height=180&font_size=20` returns a
|
||||
320 × 180 PNG with 20-pixel text.
|
||||
|
||||
Run the Gunicorn server locally:
|
||||
|
||||
```sh
|
||||
uv run gunicorn --bind 0.0.0.0:8000 --workers 2 --access-logfile - --error-logfile - 'app:create_app()'
|
||||
```
|
||||
|
||||
Build and run the container:
|
||||
|
||||
```sh
|
||||
podman build -t placeholder-api -f Containerfile .
|
||||
podman run --rm -p 8000:8000 placeholder-api
|
||||
```
|
||||
|
||||
The container runs as an unprivileged user and serves HTTP on port 8000.
|
||||
Check it with `curl http://localhost:8000/healthz`.
|
||||
Additional Gunicorn options can be passed through `GUNICORN_CMD_ARGS`.
|
||||
|
||||
The application factory is in `src/app/__init__.py`; API routes live in
|
||||
`src/app/routes/api.py`.
|
||||
|
||||
## Development checks
|
||||
|
||||
Install the Git hooks once after cloning and running `uv sync`:
|
||||
|
||||
```sh
|
||||
uv run pre-commit install
|
||||
```
|
||||
|
||||
Run all checks on tracked files:
|
||||
|
||||
```sh
|
||||
uv run pre-commit run --all-files
|
||||
```
|
||||
|
||||
The hooks run Ruff, Pyright, and pytest before commits, and validate commit
|
||||
messages with Commitizen. To run just the tests:
|
||||
|
||||
```sh
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
## Caching and rate limits
|
||||
|
||||
Successful image responses allow public caching for one day. Rendered images are
|
||||
also cached in memory on the server with a one-day expiration.
|
||||
|
||||
Rate limits apply per client IP, per Gunicorn worker:
|
||||
|
||||
| Format | Requests per minute | Uncached renders per minute |
|
||||
| --- | ---: | ---: |
|
||||
| SVG | 900 | 300 |
|
||||
| PNG | 900 | 30 |
|
||||
|
||||
Exceeding a limit returns HTTP 429. Server-cached images remain available after
|
||||
the render limit is reached, subject to the request limit. Responses served
|
||||
directly from a browser or CDN cache do not reach the app or consume its quotas.
|
||||
|
||||
Each Gunicorn worker maintains its own cache and rate-limit counters. Defaults
|
||||
are configured in `src/app/config.py`.
|
||||
39
pyproject.toml
Normal file
39
pyproject.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[project]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "Bradan J. Wolbeck", email = "bwolbeck@compaqdisc.com" }
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"cairosvg>=2.9.1",
|
||||
"flask>=3.1.3",
|
||||
"flask-caching>=2.5.1",
|
||||
"flask-limiter>=4.1.1",
|
||||
"gunicorn>=26.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
app = "app:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.9,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"commitizen>=4.18.0",
|
||||
"pre-commit>=4.6.2",
|
||||
"pyright[nodejs]>=1.1.414",
|
||||
"pytest>=9.1.1",
|
||||
"ruff>=0.16.7",
|
||||
]
|
||||
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
tag_format = "v$version"
|
||||
version_scheme = "pep440"
|
||||
version_provider = "uv"
|
||||
major_version_zero = true
|
||||
8
pyrightconfig.json
Normal file
8
pyrightconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"include": ["src", "tests"],
|
||||
"extraPaths": ["src"],
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.12",
|
||||
"typeCheckingMode": "standard"
|
||||
}
|
||||
37
ruff.toml
Normal file
37
ruff.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
target-version = "py312"
|
||||
line-length = 120
|
||||
src = [
|
||||
"src"
|
||||
]
|
||||
exclude = [
|
||||
"**/__pycache__",
|
||||
"**/node_modules",
|
||||
"**/.*",
|
||||
".venv",
|
||||
]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"A", # Builtin shadowing
|
||||
"B", # Common bug patterns
|
||||
"S", # Security checks
|
||||
"C4", # Comprehension cleanup
|
||||
"DTZ", # Timezone mistakes
|
||||
"PIE", # Redundant or unnecessary code
|
||||
"PLE", # Pylint error checks
|
||||
"I", # Import sorting
|
||||
"E4", "E7", "E9", # Core style and syntax errors
|
||||
"F", # Undefined names, unused imports, etc.
|
||||
"UP", # Modern Python syntax
|
||||
"RUF100", # Unused noqa comments
|
||||
]
|
||||
ignore = []
|
||||
|
||||
[lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101"] # Allow pytest assertions
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "native"
|
||||
skip-magic-trailing-comma = false
|
||||
22
src/app/__init__.py
Normal file
22
src/app/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from flask import Flask
|
||||
|
||||
from app.extensions import cache, limiter
|
||||
from app.routes import api
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_object("app.config")
|
||||
cache.init_app(app)
|
||||
limiter.init_app(app)
|
||||
app.register_blueprint(api, url_prefix="/")
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
create_app().run()
|
||||
19
src/app/config.py
Normal file
19
src/app/config.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
DEFAULT_IMAGE_WIDTH = 800
|
||||
DEFAULT_IMAGE_HEIGHT = 600
|
||||
DEFAULT_FONT_SIZE = 24
|
||||
|
||||
MAX_IMAGE_WIDTH = 4096
|
||||
MAX_IMAGE_HEIGHT = 4096
|
||||
MAX_FONT_SIZE = 288
|
||||
|
||||
# Cache timeout is in seconds (one day).
|
||||
CACHE_DEFAULT_TIMEOUT = 86400
|
||||
|
||||
# In-memory backends keep state separately in each Gunicorn worker.
|
||||
CACHE_TYPE = "SimpleCache"
|
||||
RATELIMIT_STORAGE_URI = "memory://"
|
||||
|
||||
SVG_REQUEST_LIMIT = "900/minute"
|
||||
SVG_RENDER_LIMIT = "300/minute"
|
||||
PNG_REQUEST_LIMIT = "900/minute"
|
||||
PNG_RENDER_LIMIT = "30/minute"
|
||||
6
src/app/extensions.py
Normal file
6
src/app/extensions.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from flask_caching import Cache
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
cache = Cache()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
3
src/app/routes/__init__.py
Normal file
3
src/app/routes/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.routes.api import api
|
||||
|
||||
__all__ = ["api"]
|
||||
81
src/app/routes/api.py
Normal file
81
src/app/routes/api.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from io import BytesIO
|
||||
from typing import cast
|
||||
|
||||
import cairosvg
|
||||
from flask import Blueprint, current_app, render_template, request, send_file
|
||||
|
||||
from app import config
|
||||
from app.extensions import cache, limiter
|
||||
|
||||
api = Blueprint("api", __name__)
|
||||
|
||||
|
||||
def _generate_svg(
|
||||
width: int = config.DEFAULT_IMAGE_WIDTH,
|
||||
height: int = config.DEFAULT_IMAGE_HEIGHT,
|
||||
font_size: int = config.DEFAULT_FONT_SIZE,
|
||||
) -> str:
|
||||
return render_template("svg.j2", w=width, h=height, font_size=font_size)
|
||||
|
||||
|
||||
def _parse_args() -> tuple[int, int, int]:
|
||||
width = request.args.get("width", config.DEFAULT_IMAGE_WIDTH, type=int)
|
||||
height = request.args.get("height", config.DEFAULT_IMAGE_HEIGHT, type=int)
|
||||
font_size = request.args.get("font_size", config.DEFAULT_FONT_SIZE, type=int)
|
||||
|
||||
if not 1 <= width <= config.MAX_IMAGE_WIDTH:
|
||||
raise ValueError("width outside of allowable range")
|
||||
elif not 1 <= height <= config.MAX_IMAGE_HEIGHT:
|
||||
raise ValueError("height outside of allowable range")
|
||||
elif not 1 <= font_size <= config.MAX_FONT_SIZE:
|
||||
raise ValueError("font_size outside of allowable range")
|
||||
|
||||
return width, height, font_size
|
||||
|
||||
|
||||
@cache.memoize()
|
||||
def _render_svg(width: int, height: int, font_size: int) -> str:
|
||||
with limiter.limit(config.SVG_RENDER_LIMIT):
|
||||
return _generate_svg(width, height, font_size)
|
||||
|
||||
|
||||
@cache.memoize()
|
||||
def _render_png(width: int, height: int, font_size: int) -> bytes:
|
||||
with limiter.limit(config.PNG_RENDER_LIMIT):
|
||||
# CairoSVG returns bytes when write_to is omitted.
|
||||
return cast(
|
||||
bytes,
|
||||
cairosvg.svg2png(bytestring=_generate_svg(width, height, font_size).encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
@api.get("/<name>.svg")
|
||||
@limiter.limit(config.SVG_REQUEST_LIMIT)
|
||||
def placeholder_svg(name: str):
|
||||
try:
|
||||
width, height, font_size = _parse_args()
|
||||
except ValueError as e:
|
||||
return {"status": "error", "message": str(e)}, 400
|
||||
|
||||
return send_file(
|
||||
BytesIO(_render_svg(width, height, font_size).encode("utf-8")),
|
||||
mimetype="image/svg+xml",
|
||||
download_name=f"{name}.svg",
|
||||
max_age=current_app.config["CACHE_DEFAULT_TIMEOUT"],
|
||||
)
|
||||
|
||||
|
||||
@api.get("/<name>.png")
|
||||
@limiter.limit(config.PNG_REQUEST_LIMIT)
|
||||
def placeholder_png(name: str):
|
||||
try:
|
||||
width, height, font_size = _parse_args()
|
||||
except ValueError as e:
|
||||
return {"status": "error", "message": str(e)}, 400
|
||||
|
||||
return send_file(
|
||||
BytesIO(_render_png(width, height, font_size)),
|
||||
mimetype="image/png",
|
||||
download_name=f"{name}.png",
|
||||
max_age=current_app.config["CACHE_DEFAULT_TIMEOUT"],
|
||||
)
|
||||
14
src/app/templates/svg.j2
Normal file
14
src/app/templates/svg.j2
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="{{ w }}" height="{{ h }}" viewBox="0 0 {{ w }} {{ h }}">
|
||||
<rect width="{{ w }}" height="{{ h }}" fill="#e5e5e7"/>
|
||||
<g stroke="#c7c7cc" stroke-width="1">
|
||||
<line x1="0" y1="0" x2="{{ w }}" y2="{{ h }}"/>
|
||||
<line x1="{{ w }}" y1="0" x2="0" y2="{{ h }}"/>
|
||||
</g>
|
||||
<text x="50%" y="50%"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-family="sans-serif"
|
||||
font-size="{{ font_size }}"
|
||||
font-weight="600"
|
||||
fill="#55555c">{{ w }} × {{ h }}</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 552 B |
23
tests/conftest.py
Normal file
23
tests/conftest.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import cache, limiter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
# Keep cached images and rate-limit counters isolated between tests.
|
||||
with application.app_context():
|
||||
cache.clear()
|
||||
limiter.reset()
|
||||
yield application
|
||||
with application.app_context():
|
||||
cache.clear()
|
||||
limiter.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
106
tests/test_api.py
Normal file
106
tests/test_api.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import struct
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
@pytest.fixture(params=["svg", "png"])
|
||||
def image_url(request):
|
||||
return f"/placeholder.{request.param}"
|
||||
|
||||
|
||||
def test_health(client):
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"status": "ok"}
|
||||
assert "Cache-Control" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_dimensions", [False, True])
|
||||
def test_image_output(client, image_url, custom_dimensions):
|
||||
parameters = {"width": 321, "height": 123, "font_size": 17} if custom_dimensions else {}
|
||||
width = parameters.get("width", config.DEFAULT_IMAGE_WIDTH)
|
||||
height = parameters.get("height", config.DEFAULT_IMAGE_HEIGHT)
|
||||
font_size = parameters.get("font_size", config.DEFAULT_FONT_SIZE)
|
||||
|
||||
response = client.get(image_url, query_string=parameters)
|
||||
|
||||
assert response.status_code == 200
|
||||
if image_url.endswith(".svg"):
|
||||
assert response.mimetype == "image/svg+xml"
|
||||
root = ElementTree.fromstring(response.data) # noqa: S314 - trusted SVG generated by the app
|
||||
assert root.tag == "{http://www.w3.org/2000/svg}svg"
|
||||
assert root.attrib["width"] == str(width)
|
||||
assert root.attrib["height"] == str(height)
|
||||
text = root.find("{http://www.w3.org/2000/svg}text")
|
||||
assert text is not None
|
||||
assert text.attrib["font-size"] == str(font_size)
|
||||
assert text.text == f"{width} × {height}"
|
||||
else:
|
||||
assert response.mimetype == "image/png"
|
||||
assert response.data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert response.data[12:16] == b"IHDR"
|
||||
# IHDR starts with width and height as big-endian unsigned 32-bit integers.
|
||||
assert struct.unpack(">II", response.data[16:24]) == (width, height)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parameter", "maximum"),
|
||||
[
|
||||
("width", config.MAX_IMAGE_WIDTH),
|
||||
("height", config.MAX_IMAGE_HEIGHT),
|
||||
("font_size", config.MAX_FONT_SIZE),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("boundary", ["below", "minimum", "maximum", "above"])
|
||||
def test_parameter_bounds(client, image_url, parameter, maximum, boundary):
|
||||
value = {"below": 0, "minimum": 1, "maximum": maximum, "above": maximum + 1}[boundary]
|
||||
# Vary one bound at a time, keeping the other dimensions small for cheap renders.
|
||||
parameters = {"width": 10, "height": 10, "font_size": 1, parameter: value}
|
||||
|
||||
response = client.get(image_url, query_string=parameters)
|
||||
|
||||
if boundary in {"minimum", "maximum"}:
|
||||
assert response.status_code == 200
|
||||
else:
|
||||
assert response.status_code == 400
|
||||
assert response.json == {
|
||||
"status": "error",
|
||||
"message": f"{parameter} outside of allowable range",
|
||||
}
|
||||
assert "Cache-Control" not in response.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", [config.CACHE_DEFAULT_TIMEOUT, 120])
|
||||
def test_image_cache_control(app, client, image_url, timeout):
|
||||
# An override catches headers that hard-code the default timeout.
|
||||
app.config["CACHE_DEFAULT_TIMEOUT"] = timeout
|
||||
|
||||
response = client.get(image_url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cache_control.public
|
||||
assert response.cache_control.max_age == timeout
|
||||
assert not response.cache_control.no_cache
|
||||
assert not response.cache_control.no_store
|
||||
|
||||
|
||||
def test_cached_image_survives_render_limit(client, image_url, monkeypatch):
|
||||
limit_name = "SVG_RENDER_LIMIT" if image_url.endswith(".svg") else "PNG_RENDER_LIMIT"
|
||||
# Exhaust the render quota with one request; the request quota remains available.
|
||||
monkeypatch.setattr(config, limit_name, "1/minute")
|
||||
|
||||
first = client.get(image_url, query_string={"width": 10, "height": 10})
|
||||
assert first.status_code == 200
|
||||
|
||||
# A new size needs a render, but the original size should still come from cache.
|
||||
uncached = client.get(image_url, query_string={"width": 11, "height": 10})
|
||||
assert uncached.status_code == 429
|
||||
assert "Cache-Control" not in uncached.headers
|
||||
|
||||
cached = client.get(image_url, query_string={"width": 10, "height": 10})
|
||||
assert cached.status_code == 200
|
||||
assert cached.data == first.data
|
||||
Loading…
Reference in a new issue