Skip to content
MaizLink — a URL shortener built the way a service that has to stay up is built

MaizLink

Clean architecture, role-based access control, a two-level cache, and a test suite that fails when the documentation starts lying.

tests Python 3.12 tests: 5210 coverage: 98% mypy: strict license: Apache 2.0

Quick start · Architecture · Why it is built this way · All docs

English · Русский

A long address is shortened, the theme is switched to dark, and the dashboard opens on its links, a link's own page with its QR square, a month of visit statistics by device and browser, the audit journal, the users page and the role editor

$ curl -X POST localhost:5000/api/v1/shorten -H 'Content-Type: application/json' \
       -d '{"url": "https://example.com/a/very/long/address"}'

{ "short_url": "http://localhost:5000/kR3-9fA", "is_new": true,
  "expires_at": "2026-08-21T12:00:33Z", "deletion_token": "IjE3YzJ…" }

No account needed for that. The link works, expires in seven days, and the token in the answer is how its maker deletes it — because a link with no owner has nothing for ownership to match.

Why look at this one

URL shorteners are a weekend project. This one is a study in what the weekend project leaves out.

Anonymous callers are a role, not an exception A signed-out visitor acts as guest, a real RBAC role with real permissions. There is no if user is None branch deciding policy.
The document cannot drift from the code /api/openapi.json is generated from the same Pydantic models the endpoints validate against, and a test holds the route table against it. Publishing a rate limit the service does not enforce fails the suite.
Refusals are told apart 401 means "nobody is authenticated", 403 means "authenticated and not allowed". A guest link's own deletion token is a third answer again.
The interface offers what the caller may do The markup asks the same authorization service the route asks. A role cannot be shown a button that answers 403.
Failure is a state, not a crash The logger and the cache degrade to a fallback rather than taking the request down, and the health endpoint reports which one is live.

Quick start

Locally — SQLite, in-memory cache

uv sync
cp .env.example .env
uv run flask security \
    generate-secrets --write .env
uv run flask alembic upgrade head
uv run flask create-admin \
    --email admin@example.com \
    --password 'your-password'
uv run flask run

In Docker — PostgreSQL, Redis, Celery, Mailpit

cp .env.docker.example .env.docker
uv run flask security generate-secrets \
    --write .env.docker \
    --with-service-passwords
docker compose --env-file .env.docker \
    up -d --build

Tip

Two templates rather than one, because one file cannot answer both questions: .env.example describes the run on the left, SQLite and a cache in the process; .env.docker.example describes the one on the right, with every dependency in a container. Nine lines differ between them and a test holds the rest identical.

--write fills the secrets into the file in place; without them the deployed profiles refuse to start. --with-service-passwords adds the two the stack's own PostgreSQL and Redis are started with — the repository ships no default password, and both services refuse to start rather than come up open. Roles seed themselves on startup in development, which is why neither block runs db load-base-roles.

To watch somebody sign up, take the block on the right. It brings up Mailpit, and the confirmation message lands in its inbox at http://127.0.0.1:8025 rather than being delivered. The block on the left has no catcher: mail there goes to localhost:1025 and nothing is listening, so a visitor who registers is left with an address they cannot confirm and an account they cannot sign in to. The admin the block creates is confirmed already, so the rest of it works. To have both, run docker compose --env-file .env.docker up -d mailpit beside the local application.

Something else — the application on the host against containerised services, your own PostgreSQL, a different profile: the whole matrix, with the values for each combination, is in Getting started. Step-by-step, with the expected output of every command, is there too.

What it does

flowchart LR
    V([Visitor]) -->|POST /api/v1/shorten| APP
    V -->|GET /code| APP
    APP{{Flask · gunicorn}} --> PG[(PostgreSQL)]
    APP --> RC[(Redis · cache)]
    APP --> BR[(Redis · queue)]
    BR --> CEL[Celery: clicks and mail]
    CEL --> PG
    CEL --> SMTP[(SMTP)]
Loading
Guest links Shortened without an account, seven-day life, quota per address
Accounts Permanent links, personal statistics, a dashboard
Batch Several URLs per request; what fails comes back per item
QR codes Every link as an SVG square, on its page and at its own address. It encodes the short URL, so the counters, the expiry and the deletion still apply
Deduplication Within one owner: shortening your own URL again returns your own live link
RBAC guest, user, analyst, auditor, admin — roles seeded from YAML, editable through the panel
Email confirmation Registration never says whether an address is taken
Two-level cache Redirects and link objects, invalidated on delete
Asynchronous counters Clicks counted by Celery, off the request path
Rate limiting Every route, tighter on auth and link creation; /health and /static/ exempt
CLI Seven command groups for operating the service

API

Thirty-nine operations. Full description: /api/openapi.json, rendered at /api/docs.

Method Endpoint Permission
POST /api/v1/shorten link:create — held by guest Create a link
POST /api/v1/batch/shorten link:create Several at once
GET /api/v1/links/{code} Where it points. Owner and traffic withheld from everyone else
GET /api/v1/links/{code}/extended ownership, admin:all or stats:view_any Derived analytics
GET /api/v1/links/{code}/qr The short link as an SVG QR code
DELETE /api/v1/links/{code} link:delete_own / link:delete_any / deletion token Remove it
GET /api/v1/stats stats:view_basic — held by guest Service totals
GET /api/v1/stats/visits stats:view_basic / link:view_own When links were opened, bucketed; scope=mine for your own; ?code= needs the link's owner or stats:view_any
GET /api/v1/stats/visits/daily stats:view_basic / link:view_own Visits per day, reaching past the retention window
GET /api/v1/stats/mine link:view_own Your own
Authentication, accounts and administration
Method Endpoint Permission
POST /api/v1/auth/register 202, and the same answer whether or not the address was free
GET, POST /api/v1/auth/verify Spend the confirmation token
POST /api/v1/auth/resend-verification Ask for another message
POST /api/v1/auth/login Tokens; a wrong password, a switched-off account and an unconfirmed address are all INVALID_CREDENTIALS
POST /api/v1/auth/refresh refresh token Rotate the pair
POST /api/v1/auth/logout session Revoke it server-side
POST /api/v1/auth/change-password session Replace your own password; every session goes, this one is reopened
POST /api/v1/auth/forgot-password 202, and the same answer whether or not the address has an account
POST /api/v1/auth/reset-password Spend a mailed token and set a new password; every session goes
GET, POST /api/v1/admin/users admin:view_users / admin:manage_users List, create
PUT /api/v1/admin/users/{id}/roles admin:manage_users Replace roles
POST /api/v1/admin/users/{id}/deactivate admin:manage_users Suspend; refused for the last administrator
POST /api/v1/admin/users/{id}/verify-email admin:manage_users Confirm an address the mailed link cannot reach; spends any outstanding token
POST /api/v1/admin/users/{id}/resend-verification admin:manage_users Send the confirmation message again, addressed by account id
GET, POST /api/v1/admin/roles admin:view_roles / admin:manage_roles List, create
PUT /api/v1/admin/roles/{name}/permissions admin:manage_roles Replace permissions; system roles are protected
GET /api/v1/admin/health admin:view_system_health What each dependency answered
GET /api/v1/journals/{journal} audit:view for audit, logs:view for application and error The end of one journal, oldest line first; admin:all does not carry the first of the two

The parts that usually go wrong

Note

These are the rules people trip over. Each one is enforced by a test that fails when the behaviour changes — the reasoning is in Decisions.

  • An expired link answers 410 everywhere — on the redirect and on both information endpoints.
  • What is public: the short address, the destination, when it was made, when it expires. What is not: the owner and the traffic. The counters are closed together with the identifier, because /extended is computed entirely from them.
  • Registration does not say whether an address is taken. Same answer, same timing, a message either way. The administrative path says so outright — there the caller is entitled to know.
  • Cookies pass CSRF, bearer tokens do not need to. A client that can set Authorization has already shown it can set headers.
  • Logging out revokes the session rather than merely dropping the cookie, and a spent refresh token revokes the chain it belonged to.

Testing

uv run pytest tests/                      # 5210 tests
uv run python tests/live/smoke_test.py    # 159 checks over HTTP
uv run python tests/live/browser_test.py  # 71 checks in a real browser

Four levels — unit, integration on SQLite, integration on real PostgreSQL and Redis, end-to-end — plus two live runs that pytest does not collect. CI runs the suite twice, in a clean environment and in a hostile one, to catch tests that read configuration nobody gave them.

Full breakdown: Testing.

Documentation

Getting started From an empty directory to a working service, with the expected output of each step
Architecture Layers, data flows, caching, authorization
Configuration Profiles, precedence, and the settings that bite; the exhaustive list of what an operator sets is .env.example
Operations Migrations, CLI, backups, upgrades, health
Testing The four levels, the live runs, and what CI enforces
Development Patterns, the frontend, the load profile
Decisions One hundred and one write-ups of why something is the way it is
Roadmap What was considered and never built, and what each idea would cost

Requirements

Python 3.12, uv. Docker Compose v2 for the full stack. PostgreSQL and Redis are required in the deployed profiles and optional locally.

Contributing

Issues and pull requests are welcome — CONTRIBUTING.md covers the setup, what a change has to pass, and the one-flag sign-off (git commit -s) the project uses instead of a CLA. Taking part means keeping to the Code of Conduct, and the address in it reaches the maintainer and nobody else.

Security

Found a hole? Please report it privately through the Security tab rather than a public issue — SECURITY.md says what to expect, what is already known, and what I will not pursue you for.

License

Apache License 2.0. Use it, fork it, ship it — commercially too. Keep the copyright notice, say what you changed, and note that the licence carries an explicit patent grant: a contributor cannot later sue users over patents in what they contributed.

The licence covers the code and not the name: section 6 grants no trademark rights, so a fork travels under a name of its own.

About

Self-hosted URL shortener on Flask: clean architecture, RBAC where an anonymous visitor is a role, a two-level cache, and a test suite that fails when the documentation starts lying.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages