Skip to content

Repository files navigation

🐼 koochooloo 🌰

A self-hosted URL shortener written in Go — with user accounts, role-based access control, OIDC single sign-on, and an embedded admin panel.

I want to dedicate this project to my love ❤️

koochooloo — self-hosted URL shortener written in Go

GitHub Workflow Status Codecov Go version License GitHub repo size

a Persian word which means "small" or "li'l". It is often used to refer to a girl when flirting, (with the meaning, li'l girl)

Urban Dictionary

Contents

What is koochooloo?

koochooloo is a self-hosted URL shortener — a small link-shortening service you run yourself instead of handing your links to bit.ly or TinyURL. It turns a long URL into a short one, redirects visitors, and counts the clicks.

It ships as a single static Go binary (or a distroless container) with the admin dashboard compiled into it, so a full deployment is one process and one database.

$ curl -X POST -d '{"url": "https://elahe-dastan.github.io"}' \
       -H 'Content-Type: application/json' 127.0.0.1:1378/api/urls
"CKaniA"

$ curl -sL -o /dev/null -w '%{url_effective}\n' 127.0.0.1:1378/api/CKaniA
https://elahe-dastan.github.io

It is also, deliberately, a reference Go project — an opinionated, worked example of how to lay out a production Go service: cmd/ + internal/{cmd,domain,infra}, fx for dependency injection, koanf for typed configuration, echo for HTTP, GORM for storage, and OpenTelemetry for metrics and traces. If that is why you are here, skip to Why it's built this way, which is the long-form reasoning behind every one of those choices.

Features

Shortening

  • Random short keys (6 characters from [a-zA-Z0-9_]) or custom aliases ($myalias).
  • Expiring links — set an expiry timestamp and the link stops resolving.
  • Click counting per short URL.
  • 302 redirects, so ordinary browsers and curl -L just work.

Accounts & access control

  • User accounts with three roles: user < admin < superadmin.
  • Local login — username + password (bcrypt), issuing a session JWT.
  • OIDC / OAuth2 single sign-on — federated login against Keycloak or any OIDC provider, with just-in-time account provisioning and role mapping from a token claim.
  • Per-user link ownership; admins see everything, superadmins manage users.
  • No public sign-up — accounts are bootstrapped from the CLI.

Admin panel

  • A React SPA embedded into the binary with go:embed and served at /admin. No second service, no separate static host, no Node toolchain needed to build.

Operations

  • SQLite, PostgreSQL or MySQL via GORM — the default is a zero-config SQLite file.
  • Prometheus metrics and OpenTelemetry traces (OTLP), on a separate metrics port.
  • Typed, layered configuration: defaults → TOML file → environment variables.
  • Structured JSON logging with zap.
  • Graceful startup/shutdown ordering via fx lifecycle hooks.
  • Distroless container image, docker-compose and Kubernetes manifests included.

Quick start

koochooloo talks to its database through GORM, so it runs on SQLite, PostgreSQL, or MySQL — pick the engine with database.dialect (and the matching database.url DSN) in your config. The default is a zero-config SQLite file, so no external service is needed to get started:

cd cmd/koochooloo/ && go build && ./koochooloo migrate && ./koochooloo server

Create yourself an account and sign in at http://127.0.0.1:1378/admin:

./koochooloo user create --username root --superadmin   # prompts for a password

To run against the containerised PostgreSQL instead, bring up the provided docker-compose and point the config at it (see configs/config.example.toml):

docker compose -f deployments/docker-compose.yml up -d

Shorten something:

curl -X POST -d '{"url": "https://elahe-dastan.github.io"}' -H 'Content-Type: application/json' 127.0.0.1:1378/api/urls
curl -L 127.0.0.1:1378/api/CKaniA

HTTP API

The OpenAPI description lives in api/swagger.yml; there are ready-made requests in api.http.

Public

Method Path Description
POST /api/urls Create a short URL. Body: {"url": …, "name"?: …, "expire"?: …}. Returns the key.
GET /api/:key Resolve the key and 302 redirect to the target, incrementing its count.
GET /api/count/:key Visit count for a short URL.
GET /healthz Liveness probe — 204 No Content.

Passing name creates a custom alias, stored and returned prefixed with $ (so {"name": "google"} becomes $google). Omitting it generates a random 6-character key. Links created through the public endpoint are anonymous — they have no owner.

Admin

Everything under /admin/api other than the login endpoints requires a Authorization: Bearer <jwt> header.

Method Path Role Description
POST /admin/api/auth/login Local login; returns a session JWT.
GET /admin/api/auth/info Which login methods are enabled.
GET /admin/api/auth/oidc/login Start the OIDC authorization-code flow.
GET /admin/api/auth/oidc/callback OIDC redirect target.
GET /admin/api/auth/me user The signed-in user.
GET /admin/api/version user Running build (commit shown to admins).
GET /admin/api/urls user Own links; every link for admin+.
POST /admin/api/urls user Create a link owned by the caller.
DELETE /admin/api/urls/:key user Delete an own link; any link for admin+.
GET /admin/api/users admin List users.
POST /admin/api/users superadmin Create a user.
PUT /admin/api/users/:id/role superadmin Change a user's role.
DELETE /admin/api/users/:id superadmin Delete a user.

Prometheus metrics are served by a separate HTTP server, :8080/metrics by default, so you can keep it off the public interface.

Admin panel & users

koochooloo ships with an embedded admin panel (a React SPA, built into the binary via go:embed) served at /admin, backed by a JWT-guarded API under /admin/api.

Roles

Three tiers, increasing in privilege: user < admin < superadmin.

  • user — manages only their own short URLs.
  • admin — manages every short URL and can view users.
  • superadmin — additionally creates users, changes roles and deletes users.

Each short URL created through the panel is owned by its creator; anonymous shorts made via the public POST /api/urls have no owner.

Creating the first admin

There is no public sign-up. Bootstrap accounts with the CLI:

./koochooloo user create --username root --superadmin   # prompts for a password
./koochooloo user list
./koochooloo user set-role --id 2 --role admin

Then sign in at http://127.0.0.1:1378/admin.

Authentication

Two mechanisms coexist:

  • Local — username + password (bcrypt), issuing a session JWT.
  • OIDC — optional federated login (e.g. Keycloak). Enable it under [auth.oidc] in the config (see configs/config.example.toml). On first login an account is provisioned just-in-time, with its role mapped from a configurable token claim (e.g. Keycloak's realm_access.roles). Both paths end up with the same koochooloo JWT.

Set a strong auth.jwt_secret in production.

Build information

The panel footer shows which build is running, served by GET /admin/api/version. Everyone signed in sees the version tag (or devel for an untagged build); admins additionally see the commit hash, the commit time and whether the working tree was dirty at build time. The values come from the VCS stamp Go embeds at build time, so they are only populated for binaries built from the git checkout (not with -buildvcs=false).

Rebuilding the SPA

web/dist is committed so go build needs no Node toolchain. After changing anything under web/src, rebuild with:

just web   # cd web && pnpm install && pnpm run build

Configuration

Configuration is layered — hardcoded defaults, then config.toml, then environment variables — and the fully resolved tree is printed at startup so you can always see what the process actually applied. Start from configs/config.example.toml.

Every key is reachable as an environment variable with the koochooloo_ prefix, using __ for nesting:

export koochooloo_database__dialect="postgres"
export koochooloo_database__url="host=127.0.0.1 user=koochooloo password=secret dbname=koochooloo port=5432 sslmode=disable"

The reasoning behind this design is in Configuration below.


Why it's built this way

These are my notes on how this project is put together and why. They are as much of the point as the shortener itself — if you are here for a Go project layout to copy, this is the part to read.

Structure

Binaries

First of all, cmd package contains the binaries of this project with use of cobra. It is good to have a simple binaries for tasks like database migrations that can be run on initiation phase of project. Each binary has its main.go in its package and registers itself with a Register function. In the root.go of cmd these Register functions from sub-commands are called. Here is an example for register function:

// Register server command.
func Register(root *cobra.Command) {
 root.AddCommand(
  &cobra.Command{
   Use:   "server",
   Short: "Run server to serve the requests",
   Run: func(_ *cobra.Command, _ []string) {
    fx.New(
        fx.Provide(config.Provide),
        fx.Invoke(main),
    ).Run()
   },
  },
 )
}

Again each command registers its flag by itself, so we have separation from other commands. Sometimes we need to have shared flags between commands, then it is better to have them in config. For the later case, koanf can help us with the structure as below:

func Register(fs *pflag.FlagSet) {
 fs.StringP(
  "url", "u",
  nats.DefaultURL,
  fmt.Sprintf("nats server url(s) e.g. %s", nats.DefaultURL),
 )
}

This function register shared flags, and then we load configuration based on them with the following function:

k := koanf.New(".")

if err := k.Load(posflag.Provider(fs, ".", k), nil); err != nil {
 log.Errorf("error loading config.yml: %s", err)
}

if err := k.Unmarshal("", &instance); err != nil {
 log.Fatalf("error unmarshalling config: %s", err)
}

Configuration

The main part of each application is its configuration. There are many ways for having configuration in the project from configuration file to environment variables. Koanf has all of them in a one beautiful package. The main points here are:

  • Having a defined and typed structure for configuration
  • Don't use global configuration. each module has its configuration defined in config module and it will pass to it in its initiation.
  • Print loaded configuration at startup, so everyone can validate the applied configuration.

P.S. koanf is way better than viper for having typed configuration. By typed configuration I mean you have a defined structure for configuration and then load configuration from many sources into it.

For installing koanf you can use the following commands:

go get -u github.com/knadh/koanf/v2

go get -u github.com/knadh/koanf/providers/file
go get -u github.com/knadh/koanf/providers/env
go get -u github.com/knadh/koanf/providers/structs

go get -u github.com/knadh/koanf/parsers/toml

Infra vs Domain

Packages and services that are defined in domain package only uses other packages from domain without using any 3rd party packages. These packages and services specifies the core domain concepts.

Database

There is a db package that is responsible for connecting to the database. This package uses the database configuration that is defined in config module and create a database instance. It is a good idea to ping your database here to have fully confident to your database instance before going forward. Also for having an insight at database health you can call this ping function periodically and report its result with metrics (which I didn't do here).

Model

Project models are defined in model package. These models are used internally but the can be used in response or request package. There is no structure for communicating with database in this package.

Repository

Repositories are responsible for commnunicating with database to store or retrieve models. Repositories are interface and there is an concrete and mocked implementation for them. concrete implementation is used in main code and mocked one is used for tests. Please note that the tests for repositories are touchy and are done with actual database.

Handler

HTTP handler are defined in handler package. Echo is an awesome HTTP framework that has eveything you need. Each handler has its structure with a Register method that registers its route into a given route group. Route group is a concept from Echo framework for grouping routes under a specific parent path. Each handler has what it needs into its structure. Handler structure are created in main.go then register on their group.

type Healthz struct {}

// Handle shows server is up and running.
func (h Healthz) Handle(c *echo.Context) error {
 return c.NoContent(http.StatusNoContent)
}

// Register registers the routes of healthz handler on given echo group.
func (h Healthz) Register(g *echo.Group) {
 g.GET("/healthz", h.Handle)
}

Metrics

All metrics are gathered using Prometheus based on open-telemetry. Each package has its metric.go that defines a structure contains the metrics and have methods for changing them. For migrating from Prometheus to another service you just need to change telemetry. Metrics aren't global and they created for each instance seperately thanks to Open Telemetry design. For having better controller on metrics endpoint there is another HTTP server that is defined in telemetry package for monitoring.

Request/Response

It is good to have separated packages for requests and responses. These packages also contain validation logic. One of the good validation pakcages in Go is ozzo-validator. After providing validate method, after getting request you can validate it with its method with ease.

Logging

Logging one the most important part of application. At the beginning there is no need to have something more than simple stdout logs. But in the future you need to strcuture you logs and ship them into an aggregation system because when your system grows detecting issues from text logs will be inpossible.

zap is one the best logger for structure logging. zap forces you to pass it into your child module and you also name loggers with Named method. By using the named logger you can easily find you module logs in your log aggregator.

Technology

Leveraging fx as our dependency injection framework, koochooloo delivers:

  • No Code Generation: Utilize powerful dependency injection without any code bloat from code generation.
  • Test-Friendly: An environment that supports and simplifies the use of fx in testing with the same ease as in production.

Principles

  • Strong Typing: Unyielding commitment to strong typing, enhancing readability and maintainability.
  • No Globals or init Functions: Eschews globals and the complexities of init functions to keep things simple.
  • Standardized Naming: Adherence to the de-facto standard of singular package names, inspired by 'project-layout' principles.
  • Independent Packages: Each package is crafted to function independently, making the addition of new features seamless.
  • Intuitive Structure: Navigate with ease through a codebase that's designed for clarity.

Load testing

Driven by k6 — the script lives in api/k6/script.js and just k6 runs it against a locally built binary.

    checks.....................: 99.83% ✓ 2995  ✗ 5
    data_received..............: 2.0 MB 64 kB/s
    data_sent..................: 521 kB 17 kB/s
    group_duration.............: avg=649.18ms min=153.18µs med=265.45ms max=30.95s   p(90)=1.61s    p(95)=2.06s
    http_req_blocked...........: avg=14.12ms  min=0s       med=3µs      max=1.65s    p(90)=13µs     p(95)=147.04µs
    http_req_connecting........: avg=6.23ms   min=0s       med=0s       max=1.36s    p(90)=0s       p(95)=0s
    http_req_duration..........: avg=272.98ms min=0s       med=127.99ms max=4.81s    p(90)=830.93ms p(95)=1.29s
    http_req_receiving.........: avg=125.23µs min=0s       med=60µs     max=11.21ms  p(90)=228µs    p(95)=363µs
    http_req_sending...........: avg=50.78µs  min=0s       med=22µs     max=7.28ms   p(90)=86µs     p(95)=138µs
    http_req_tls_handshaking...: avg=7.86ms   min=0s       med=0s       max=653.63ms p(90)=0s       p(95)=0s
    http_req_waiting...........: avg=272.8ms  min=0s       med=127.71ms max=4.81s    p(90)=830.87ms p(95)=1.29s
    http_reqs..................: 4000   129.093962/s
    iteration_duration.........: avg=1.29s    min=142.34ms med=1.04s    max=30.97s   p(90)=2.18s    p(95)=2.64s
    iterations.................: 1000   32.273491/s
    vus........................: 100    min=100 max=100
    vus_max....................: 100    min=100 max=100

License

GNU General Public License v3.0.

About

Self-hosted URL shortener in Go — custom aliases, expiring links, click counts, user accounts, RBAC, OIDC SSO and an embedded admin panel

Topics

Resources

Stars

90 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages