Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ebac7150 | |||
| a066b5e5ab | |||
| cd8e2433a5 | |||
| b538a379d1 | |||
| 8cab7a51ad | |||
| ece74226d4 | |||
| d26aa2f18a | |||
| ce2f73dfe3 | |||
| 30fb539289 | |||
| 66fd6db3c2 | |||
| 93a91cd76e | |||
| 1d1444234b | |||
| 49ac63210e | |||
| 702491cb39 | |||
| d55dbe0471 | |||
| 55969b03b5 | |||
| 70a036e158 | |||
| 6ade7d295b | |||
| e6ae133449 | |||
| d3706a8778 | |||
| a271e7c35d | |||
| a46a50b429 | |||
| dc8c359bfc | |||
| dfb6b10caf | |||
| 3fa30b3a32 | |||
| 80ed6033d1 | |||
| 4640522d5d | |||
| 848232cc11 | |||
| 98dedc4494 | |||
| e6f64ea026 | |||
| ea71d3825e | |||
| afa9b33568 | |||
| a93189411b | |||
| 98f103f5cf | |||
| 6fc48dae83 | |||
| d039add340 | |||
| 0214d6f46d | |||
| 37e14ec2f8 | |||
| a85d5a8c73 | |||
| 80cebe3357 | |||
| 451a5fbf0f | |||
| aeb6d1164b | |||
| 2f14cd82dd | |||
| faedd0bc7a | |||
| 1d207fae64 | |||
| a71f345f66 | |||
| 7cc6c1197f | |||
| 115bbf7366 | |||
| 36a06ac670 | |||
| 772897cb27 | |||
| 1f42798d12 | |||
| 08302e4457 | |||
| cd9a44fcc9 | |||
| 6532ff466e | |||
| 8cbed80fd0 | |||
| 0758bf4cee | |||
| f5c296bee3 | |||
| 8f33375b5f | |||
| cebbd48237 | |||
| 88d81ecfe3 | |||
| 3050c2b100 | |||
| a2aa586dae | |||
| ebc9c6852e | |||
| a1b67b8553 | |||
| dbc81796d6 | |||
| 2f796dbdab | |||
| ff06e10b8f | |||
| 050fd6f1e3 |
@@ -1,6 +1,5 @@
|
||||
.git
|
||||
/bin/
|
||||
/.shards/
|
||||
/bruno/
|
||||
/spec/
|
||||
/sqlite/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish Docker image
|
||||
name: Publish Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,43 +8,77 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
build-platforms:
|
||||
name: Build Platforms
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract version tag
|
||||
id: extract_tag
|
||||
if: github.event_name == 'release'
|
||||
run: echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
- name: Build and push image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push platform image
|
||||
uses: docker/build-push-action@v5
|
||||
env:
|
||||
CRYSTAL_WORKERS: ${{ matrix.platform == 'linux/amd64' && 4 || 2 }}
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
sjdonado/url-shortener:latest
|
||||
${{ github.event_name == 'release' && 'sjdonado/url-shortener:${{ env.RELEASE_TAG }}' || '' }}
|
||||
- name: Attest
|
||||
uses: actions/attest-build-provenance@v1
|
||||
id: attest
|
||||
sjdonado/bit:${{ steps.version.outputs.version }}-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
build-args: |
|
||||
TARGETARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
create-manifest:
|
||||
name: Create Manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-platforms
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
subject-name: sjdonado/url-shortener
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=latest" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create manifest
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t sjdonado/bit:${{ steps.version.outputs.version }} \
|
||||
sjdonado/bit:${{ steps.version.outputs.version }}-{amd64,arm64}
|
||||
|
||||
+2
-1
@@ -1,4 +1,3 @@
|
||||
/docs/
|
||||
/lib/
|
||||
/bin/
|
||||
/.shards/
|
||||
@@ -7,3 +6,5 @@
|
||||
|
||||
/sqlite/
|
||||
.env.production
|
||||
|
||||
resource_usage.txt
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[INSERT CONTACT METHOD].
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||
at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,75 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
We welcome contributions from the community! Please follow these guidelines to help maintain consistency and quality in the project.
|
||||
|
||||
## Code of Conduct
|
||||
This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you agree to uphold its terms.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### 1. Fork the Repository
|
||||
Click the "Fork" button at the top-right of the [repository page](https://github.com/sjdonado/bit).
|
||||
|
||||
### 2. Clone Your Fork
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/bit.git
|
||||
cd bit
|
||||
```
|
||||
|
||||
### 3. Create a Feature Branch
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
### or for bug fixes:
|
||||
|
||||
```bash
|
||||
git checkout -b fix/issue-description
|
||||
```
|
||||
|
||||
### 4. Develop Your Changes
|
||||
- Check [Local Development](docs/SETUP.md#local-development) guidelines
|
||||
- Ensure changes match the project scope
|
||||
- Write clear commit messages
|
||||
- Include tests for new functionality
|
||||
- Update documentation when applicable
|
||||
|
||||
### 5. Commit Changes
|
||||
```bash
|
||||
git commit -am 'Add descriptive commit message'
|
||||
```
|
||||
|
||||
### 6. Push to GitHub
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
### 7. Create a Pull Request
|
||||
1. Go to the [original repository](https://github.com/sjdonado/bit)
|
||||
2. Click "New Pull Request"
|
||||
3. Select your fork and branch
|
||||
4. Add a clear description including:
|
||||
- Purpose of changes
|
||||
- Related issues (if applicable)
|
||||
- Testing performed
|
||||
|
||||
## Pull Request Guidelines
|
||||
- Keep PRs focused on a single feature/bugfix
|
||||
- Ensure all tests pass
|
||||
- Update documentation in the same PR
|
||||
- Use descriptive titles (e.g., "Add URL validation" not "Update code")
|
||||
- Reference related issues using #issue-number
|
||||
|
||||
## Reporting Issues
|
||||
When opening an issue, please include:
|
||||
1. Description of the problem
|
||||
2. Steps to reproduce
|
||||
3. Expected vs actual behavior
|
||||
4. Environment details (OS, Crystal version, etc)
|
||||
|
||||
For feature requests:
|
||||
- Explain the problem you're trying to solve
|
||||
- Suggest potential implementations
|
||||
|
||||
## License
|
||||
By contributing, you agree that your contributions will be licensed under the [license](LICENSE).
|
||||
+37
-9
@@ -1,19 +1,47 @@
|
||||
FROM alpine:edge as base
|
||||
FROM debian:bookworm-slim AS build
|
||||
|
||||
ARG TARGETARCH
|
||||
ENV ENV=production
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apk add crystal shards sqlite-dev openssl-dev
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://packagecloud.io/84codes/crystal/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/84codes_crystal.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/trusted.gpg.d/84codes_crystal.gpg] https://packagecloud.io/84codes/crystal/debian/ bookworm main" > /etc/apt/sources.list.d/84codes_crystal.list
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
crystal \
|
||||
libssl-dev \
|
||||
libyaml-dev \
|
||||
libsqlite3-dev \
|
||||
libevent-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS build
|
||||
ENV ENV=production
|
||||
COPY . .
|
||||
|
||||
RUN shards install
|
||||
RUN shards build --progress
|
||||
RUN shards install --production
|
||||
RUN shards build --release --no-debug --progress --stats
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ENV ENV=production
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libssl3 \
|
||||
libyaml-0-2 \
|
||||
libsqlite3-0 \
|
||||
libevent-2.1-7 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p sqlite
|
||||
|
||||
FROM base AS release
|
||||
RUN mkdir -p /usr/src/app/sqlite
|
||||
COPY --from=build /usr/src/app/db db
|
||||
COPY --from=build /usr/src/app/data data
|
||||
COPY --from=build /usr/src/app/bin /usr/local/bin
|
||||
|
||||
EXPOSE 4000/tcp
|
||||
CMD ["url-shortener"]
|
||||
CMD ["bit"]
|
||||
|
||||
@@ -1,138 +1,28 @@
|
||||
# url-shortener
|
||||
> Lightning fast, lightweight and minimal self-hosted url shortener
|
||||
[](https://hub.docker.com/r/sjdonado/bit)
|
||||
[](https://hub.docker.com/r/sjdonado/bit)
|
||||
[](https://hub.docker.com/r/sjdonado/bit)
|
||||
|
||||
## Benchmark
|
||||
```shell
|
||||
./benchmark.sh
|
||||
Semaphore initialized with 2666 slots.
|
||||
Setup...
|
||||
[+] Running 1/0
|
||||
✔ Container url-shortener-app-1 Running 0.0s
|
||||
2024-05-20T16:39:53.818306Z INFO - micrate: No migrations to run. current version: 20240513130054
|
||||
Captured API Key: 4y2mblZDneZLcI-YywHGFA
|
||||
Waiting for database to be ready...
|
||||
Creating 1000 short links...
|
||||
Created short link 100/1000
|
||||
Created short link 200/1000
|
||||
Created short link 300/1000
|
||||
Created short link 400/1000
|
||||
Created short link 500/1000
|
||||
Created short link 600/1000
|
||||
Created short link 700/1000
|
||||
Created short link 800/1000
|
||||
Created short link 900/1000
|
||||
Created short link 1000/1000
|
||||
Accessing each link 10 times concurrently...
|
||||
****Results****
|
||||
Average Memory Usage: 11.00 MiB
|
||||
Average Response Time: 5.28 µs
|
||||
[+] Running 2/2
|
||||
✔ Container url-shortener-app-1 Removed 10.2s
|
||||
✔ Network url-shortener_default Removed
|
||||
```
|
||||
Lightweight URL shortener service with minimal resource requirements. Average memory consumption is **20MB RAM** with container disk space under **50MB**.
|
||||
|
||||
## Self-hosted
|
||||
Bit is highly performant, achieving over 1.8K requests per second with an average latency of 68ms. For detailed benchmark results, see [benchmark](docs/SETUP.md#benchmark).
|
||||
|
||||
- Run via docker-compose
|
||||
```bash
|
||||
docker-compose up
|
||||
Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
|
||||
|
||||
docker-compose exec -it app migrate
|
||||
docker-compose exec -it app cli --create-user=Admin
|
||||
```
|
||||
## Why Bit?
|
||||
It is feature-complete by design. Its strength lies in simplicity, a reliable URL shortener without unnecessary bloat. Bug fixes will continue, but new features aren't planned.
|
||||
|
||||
- Run via docker cli
|
||||
```bash
|
||||
docker run \
|
||||
--name url-shortener \
|
||||
-p 4000:4000 \
|
||||
-e ENV="production" \
|
||||
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
|
||||
-e APP_URL="http://localhost:4000" \
|
||||
sjdonado/url-shortener
|
||||
- Minimal tracking setup: Country, browser, os, referer. No cookies or persistent tracking mechanisms are used beyond what's available from a basic client's request.
|
||||
- Flexible request forwarding system passes client context (IP, user-agent) to destinations via standard X-Forwarded-For and X-Forwarded-User-Agent headers, enabling advanced tracking and integration capabilities when needed.
|
||||
- Multiple users are supported via API key authentication. Create, list and delete via the [CLI](docs/SETUP.md#cli).
|
||||
|
||||
docker exec -it url-shortener migrate
|
||||
docker exec -it url-shortener cli --create-user=Admin
|
||||
```
|
||||
## Minimum Requirements
|
||||
- 50MB disk space
|
||||
- 50MB RAM (20MB avg usage)
|
||||
- x86_64 or ARM64 architecture
|
||||
|
||||
- Dokku
|
||||
```dockerfile
|
||||
FROM sjdonado/url-shortener
|
||||
```
|
||||
|
||||
```bash
|
||||
dokku apps:create url-shortener
|
||||
|
||||
dokku domains:set url-shortener bit.donado.co
|
||||
dokku letsencrypt:enable url-shortener
|
||||
|
||||
dokku storage:ensure-directory url-shortener-sqlite
|
||||
dokku storage:mount url-shortener /var/lib/dokku/data/storage/url-shortener-sqlite:/usr/src/app/sqlite/
|
||||
|
||||
dokku config:set url-shortener DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" APP_URL=https://bit.donado.co
|
||||
|
||||
dokku ports:add url-shortener http:80:4000
|
||||
dokku ports:add url-shortener https:443:4000
|
||||
|
||||
dokku run url-shortener migrate
|
||||
dokku run url-shortener cli --create-user=Admin
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
**REST API**
|
||||
|
||||
| Endpoint | HTTP Method | Description | Payload |
|
||||
|----------|-------------|-------------|---------|
|
||||
| `/api/ping` | GET | Ping the API to check if it's running | - |
|
||||
| `/:slug` | GET | Retrieve a link by its slug | - |
|
||||
| `/api/links` | GET | Retrieve all links | - |
|
||||
| `/api/links` | POST | Create a new link | `{"url": "https://example.com"}` |
|
||||
| `/api/links/:id` | PUT | Update an existing link by its ID | `{"url": "https://newexample.com"}` |
|
||||
| `/api/links/:id` | DELETE | Delete a link by its ID | - |
|
||||
|
||||
**CLI**
|
||||
```
|
||||
Usage: ./cli [options]
|
||||
Options:
|
||||
--create-user=NAME Create a new user with the given name
|
||||
--list-users List all users
|
||||
--delete-user=USER_ID Delete a user by ID
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
**Installation**
|
||||
|
||||
```bash
|
||||
brew tap amberframework/micrate
|
||||
brew install micrate
|
||||
```
|
||||
|
||||
```bash
|
||||
shards run migrate
|
||||
shards run url-shortener
|
||||
```
|
||||
|
||||
**Generate the `X-Api-Key`**
|
||||
|
||||
```bash
|
||||
shards run cli -- --create-user=Admin
|
||||
```
|
||||
|
||||
## Run tests
|
||||
```bash
|
||||
ENV=test crystal spec
|
||||
```
|
||||
## Documentation
|
||||
- [API Reference](docs/API.md)
|
||||
- [Advanced Setup](docs/SETUP.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (<https://github.com/sjdonado/url-shortener/fork>)
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin my-new-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## Contributors
|
||||
|
||||
- [sjdonado](https://github.com/sjdonado) - creator and maintainer
|
||||
Found an issue or have a suggestion? Please follow our [contribution guidelines](CONTRIBUTING.md).
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
require "log"
|
||||
|
||||
ENV["ENV"] ||= "development"
|
||||
ENV["APP_URL"] ||= "http://localhost:4000"
|
||||
ENV["DATABASE_URL"] ||= "sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
|
||||
"
|
||||
|
||||
{% if env("ENV") != "production" %}
|
||||
require "dotenv"
|
||||
Dotenv.load ".env.#{ENV["ENV"]}" # File must exist in non-production!
|
||||
{% end %}
|
||||
|
||||
{% if env("ENV") == "production" %}
|
||||
Log.setup(:error)
|
||||
{% end %}
|
||||
|
||||
@@ -3,3 +3,5 @@ require "kemal"
|
||||
Kemal.config.env = ENV["ENV"]? || "development"
|
||||
Kemal.config.port = ENV["PORT"]?.try(&.to_i) || 4000
|
||||
Kemal.config.host_binding = ENV["HOST"]? || "0.0.0.0"
|
||||
|
||||
Kemal.config.powered_by_header = false
|
||||
|
||||
+82
-18
@@ -1,28 +1,44 @@
|
||||
require "uuid"
|
||||
require "user_agent_parser"
|
||||
|
||||
require "../lib/controller.cr"
|
||||
require "../lib/ip_lookup"
|
||||
|
||||
UserAgent.load_regexes(File.read("data/uap_core_regexes.yaml"))
|
||||
IpLookup.load_mmdb("data/GeoLite2-Country.mmdb")
|
||||
|
||||
module App::Controllers::Link
|
||||
class Create < App::Lib::BaseController
|
||||
include App::Models
|
||||
include App::Lib
|
||||
include App::Services
|
||||
|
||||
def call(env)
|
||||
user = env.get("user").as(User)
|
||||
body = parse_body(env, ["url"])
|
||||
url = body["url"].to_s
|
||||
|
||||
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
|
||||
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
||||
if existing_link
|
||||
response = {"data" => App::Serializers::Link.new(existing_link)}
|
||||
return response.to_json
|
||||
end
|
||||
|
||||
link = Link.new
|
||||
link.id = UUID.v4.to_s
|
||||
link.url = body["url"].to_s
|
||||
link.slug = Random::Secure.urlsafe_base64(4)
|
||||
link.url = url
|
||||
link.user = user
|
||||
link.slug = SlugService.shorten_url(url, user.id.to_s)
|
||||
|
||||
changeset = Database.insert(link)
|
||||
if !changeset.valid?
|
||||
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
|
||||
end
|
||||
|
||||
link.clicks = [] of App::Models::Click
|
||||
response = {"data" => App::Serializers::Link.new(link)}
|
||||
|
||||
response.to_json
|
||||
end
|
||||
end
|
||||
@@ -37,19 +53,40 @@ module App::Controllers::Link
|
||||
link = Database.get_by(Link, slug: slug)
|
||||
raise App::NotFoundException.new(env) if !link
|
||||
|
||||
spawn do
|
||||
link.click_counter = link.click_counter! + 1
|
||||
remote_address = env.request.remote_address.try &.to_s
|
||||
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
|
||||
|
||||
changeset = Database.update(link)
|
||||
if changeset.errors.any?
|
||||
Log.error { "Increase click counter failed: #{changeset.errors}" }
|
||||
end
|
||||
end
|
||||
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
|
||||
|
||||
env.response.status_code = 301
|
||||
env.response.headers["Location"] = link.url!
|
||||
env.response.headers["Content-Type"] = "text/html"
|
||||
env.response.print("Redirecting...")
|
||||
|
||||
env.response.headers["X-Forwarded-For"] = client_ip
|
||||
env.response.headers["X-Forwarded-User-Agent"] = user_agent_str
|
||||
|
||||
spawn do
|
||||
ip_lookup = client_ip != "Unknown" ? IpLookup.new(client_ip) : nil
|
||||
country = ip_lookup.try &.country.try &.code
|
||||
|
||||
user_agent = user_agent_str != "Unknown" ? UserAgent.new(user_agent_str) : nil
|
||||
|
||||
source = env.params.query["utm_source"]? || "Direct"
|
||||
referer_host = env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source
|
||||
|
||||
click = Click.new
|
||||
click.id = UUID.v4.to_s
|
||||
click.link = link
|
||||
click.country = country
|
||||
click.user_agent = user_agent_str
|
||||
click.browser = user_agent.try &.family
|
||||
click.os = user_agent.try &.os.try &.family
|
||||
click.referer = referer_host
|
||||
|
||||
changeset = Database.insert(click)
|
||||
if changeset.errors.any?
|
||||
Log.error { "Logging click event failed: #{changeset.errors}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,31 +98,58 @@ module App::Controllers::Link
|
||||
user = env.get("user").as(User)
|
||||
|
||||
query = Database::Query.where(user_id: user.id.as(String))
|
||||
links = Database.all(Link, query)
|
||||
links = Database.all(Link, query, preload: [:clicks])
|
||||
|
||||
response = {"data" => links.map { |link| App::Serializers::Link.new(link) }}
|
||||
response.to_json
|
||||
end
|
||||
end
|
||||
|
||||
class Get < App::Lib::BaseController
|
||||
include App::Models
|
||||
include App::Lib
|
||||
|
||||
def call(env)
|
||||
user = env.get("user").as(User)
|
||||
link_id = env.params.url["id"]
|
||||
|
||||
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
||||
link = Database.all(Link, query, preload: [:clicks]).first?
|
||||
|
||||
raise App::NotFoundException.new(env) if link.nil?
|
||||
|
||||
response = {"data" => App::Serializers::Link.new(link)}
|
||||
response.to_json
|
||||
end
|
||||
end
|
||||
|
||||
class Update < App::Lib::BaseController
|
||||
include App::Models
|
||||
include App::Lib
|
||||
include App::Services
|
||||
|
||||
def call(env)
|
||||
user = env.get("user").as(User)
|
||||
id = env.params.url["id"]
|
||||
body = parse_body(env, ["url"])
|
||||
|
||||
link = Database.get(Link, id)
|
||||
raise App::NotFoundException.new(env) if !link
|
||||
query = Database::Query.where(id: id).limit(1)
|
||||
link = Database.all(Link, query, preload: [:clicks]).first?
|
||||
|
||||
if link.user_id != user.id
|
||||
raise App::ForbiddenException.new(env)
|
||||
raise App::NotFoundException.new(env) if link.nil?
|
||||
raise App::ForbiddenException.new(env) if link.user_id != user.id
|
||||
|
||||
new_url = body["url"].to_s
|
||||
|
||||
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
|
||||
existing_link = Database.all(Link, existing_query).first?
|
||||
|
||||
if existing_link
|
||||
raise App::UnprocessableEntityException.new(env, { "url" => ["URL already exists"] })
|
||||
end
|
||||
|
||||
link.url = body["url"].to_s
|
||||
link.click_counter = 0
|
||||
link.url = new_url
|
||||
link.slug = SlugService.shorten_url(new_url, user.id.to_s)
|
||||
|
||||
changeset = Database.update(link)
|
||||
if !changeset.valid?
|
||||
|
||||
@@ -3,7 +3,7 @@ require "../lib/controller.cr"
|
||||
module App::Controllers::Ping
|
||||
class Get < App::Lib::BaseController
|
||||
def call(env)
|
||||
response = {"pong" => "ok"}
|
||||
response = {"data" => "pong"}
|
||||
response.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
require "sqlite3"
|
||||
require "crecto"
|
||||
require"micrate"
|
||||
|
||||
module App::Lib
|
||||
class Database
|
||||
@@ -14,5 +15,12 @@ module App::Lib
|
||||
if ENV["ENV"] == "development"
|
||||
Crecto::DbLogger.set_handler(STDOUT)
|
||||
end
|
||||
|
||||
def self.run_migrations
|
||||
Micrate::DB.connection_url = ENV["DATABASE_URL"]
|
||||
Micrate::Cli.run_up
|
||||
end
|
||||
|
||||
run_migrations
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
require "kemal"
|
||||
|
||||
module App
|
||||
class InternalServerErrorException < Kemal::Exceptions::CustomException
|
||||
def initialize(context)
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 500
|
||||
context.response.print({ "error" => "Internal Server Error" }.to_json)
|
||||
super(context)
|
||||
end
|
||||
end
|
||||
|
||||
class BadRequestException < Kemal::Exceptions::CustomException
|
||||
def initialize(context, message : String)
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 400
|
||||
context.response.print({ "error" => message }.to_json)
|
||||
super(context)
|
||||
@@ -11,13 +21,16 @@ module App
|
||||
|
||||
class UnauthorizedException < Kemal::Exceptions::CustomException
|
||||
def initialize(context)
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 401
|
||||
context.response.print({ "error" => "Unauthorized access" }.to_json)
|
||||
super(context)
|
||||
end
|
||||
end
|
||||
|
||||
class ForbiddenException < Kemal::Exceptions::CustomException
|
||||
def initialize(context)
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 403
|
||||
context.response.print({ "error" => "Access not allowed" }.to_json)
|
||||
super(context)
|
||||
@@ -26,16 +39,28 @@ module App
|
||||
|
||||
class NotFoundException < Kemal::Exceptions::CustomException
|
||||
def initialize(context)
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 404
|
||||
context.response.print({ "error" => "Resource not found" }.to_json)
|
||||
super(context)
|
||||
end
|
||||
end
|
||||
|
||||
class UnprocessableEntityException < Kemal::Exceptions::CustomException
|
||||
def initialize(context, message : Hash(String, Array(String)))
|
||||
context.response.content_type = "application/json"
|
||||
context.response.status_code = 422
|
||||
context.response.print({ "errors" => message }.to_json)
|
||||
super(context)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
error 500 do |env|
|
||||
App::InternalServerErrorException.new(env)
|
||||
""
|
||||
end
|
||||
|
||||
error 404 do |env|
|
||||
""
|
||||
end
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require "maxminddb"
|
||||
|
||||
class IpLookup
|
||||
@@instance : MaxMindDB::Reader? = nil
|
||||
|
||||
record Country, code : String? = nil, name : String? = nil
|
||||
|
||||
getter ip : String
|
||||
getter country : Country?
|
||||
|
||||
def self.load_mmdb(mmdb_file_path : String)
|
||||
@@instance = MaxMindDB.open(mmdb_file_path)
|
||||
end
|
||||
|
||||
def initialize(ip_address : String)
|
||||
@ip = ip_address
|
||||
@country = nil
|
||||
|
||||
return if @@instance.nil? || ip_address == "Unknown" || ip_address.empty?
|
||||
|
||||
begin
|
||||
lookup = @@instance.not_nil!.get(ip_address)
|
||||
|
||||
country_code = lookup["country"]?.try &.["iso_code"]?.try &.as_s
|
||||
country_name = lookup["country"]?.try &.["names"]?.try &.["en"]?.try &.as_s
|
||||
|
||||
if country_code || country_name
|
||||
@country = Country.new(
|
||||
code: country_code,
|
||||
name: country_name
|
||||
)
|
||||
end
|
||||
rescue ex
|
||||
# Silently handle lookup errors
|
||||
Log.error { "IP lookup failed: #{ex.message}" }
|
||||
end
|
||||
end
|
||||
|
||||
def self.extract_ip(address_string : String?) : String?
|
||||
return nil if address_string.nil?
|
||||
|
||||
if address_string.includes?('[') # IPv6 with port: [2001:db8::1]:8080
|
||||
address_string.split(']').first.sub('[', '\'')
|
||||
elsif address_string.includes?(':')
|
||||
if address_string.count(':') > 1 # IPv6 without port
|
||||
address_string
|
||||
else # IPv4 with port: 192.168.1.1:8080
|
||||
address_string.split(':').first
|
||||
end
|
||||
else # Address without port
|
||||
address_string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require "crecto"
|
||||
|
||||
module App::Models
|
||||
class Click < Crecto::Model
|
||||
schema :clicks do
|
||||
field :id, String, primary_key: true
|
||||
field :user_agent, String
|
||||
field :country, String
|
||||
field :browser, String
|
||||
field :os, String
|
||||
field :referer, String
|
||||
|
||||
belongs_to :link, Link
|
||||
end
|
||||
|
||||
validate_required [:user_agent, :referer]
|
||||
end
|
||||
end
|
||||
+2
-2
@@ -9,14 +9,14 @@ module App::Models
|
||||
field :id, String, primary_key: true
|
||||
field :slug, String
|
||||
field :url, String
|
||||
field :click_counter, Int64, default: 0
|
||||
|
||||
belongs_to :user, User
|
||||
has_many :clicks, Click
|
||||
end
|
||||
|
||||
unique_constraint :slug
|
||||
|
||||
validate_required [:slug, :url]
|
||||
validate_format :url, /\A(?:https?:\/\/)?(?:[\w-]+\.)+[\w-]+(?:\/\S*)?/
|
||||
validate_format :url, /\A(?:(https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,})(?::\d+)?(?:[\/?#]\S*)?\z/i
|
||||
end
|
||||
end
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ module App
|
||||
before_all do |env|
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, X-Api-Key"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
|
||||
end
|
||||
|
||||
after_all do |env|
|
||||
@@ -23,6 +23,10 @@ module App
|
||||
Controllers::Link::All.new.call(env)
|
||||
end
|
||||
|
||||
get "/api/links/:id" do |env|
|
||||
Controllers::Link::Get.new.call(env)
|
||||
end
|
||||
|
||||
post "/api/links" do |env|
|
||||
Controllers::Link::Create.new.call(env)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require "json"
|
||||
|
||||
require "../models/click"
|
||||
|
||||
module App::Serializers
|
||||
class Click
|
||||
def initialize(@click : App::Models::Click)
|
||||
end
|
||||
|
||||
def to_json(builder : JSON::Builder)
|
||||
builder.object do
|
||||
builder.field("id", @click.id)
|
||||
builder.field("user_agent", @click.user_agent)
|
||||
builder.field("country", @click.country)
|
||||
builder.field("browser", @click.browser)
|
||||
builder.field("os", @click.os)
|
||||
builder.field("referer", @click.referer)
|
||||
builder.field("created_at", @click.created_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
require "json"
|
||||
|
||||
require "../models/link"
|
||||
require "./click"
|
||||
|
||||
module App::Serializers
|
||||
class Link
|
||||
@@ -15,7 +16,7 @@ module App::Serializers
|
||||
builder.field("id", @link.id)
|
||||
builder.field("refer", @refer)
|
||||
builder.field("origin", @link.url)
|
||||
builder.field("clicks", @link.click_counter)
|
||||
builder.field("clicks", @link.clicks.map { |click| App::Serializers::Click.new(click) })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+105
-2
@@ -1,13 +1,16 @@
|
||||
require "file_utils"
|
||||
require "http/client"
|
||||
|
||||
require "../config/*"
|
||||
require "../lib/*"
|
||||
require "../models/*"
|
||||
|
||||
module App::Services::Cli
|
||||
def self.create_user(name)
|
||||
def self.create_user(name, api_key = nil)
|
||||
user = App::Models::User.new
|
||||
user.id = UUID.v4.to_s
|
||||
user.name = name
|
||||
user.api_key = Random::Secure.urlsafe_base64()
|
||||
user.api_key = api_key || Random::Secure.urlsafe_base64()
|
||||
|
||||
changeset = App::Lib::Database.insert(user)
|
||||
return changeset.errors if !changeset.valid?
|
||||
@@ -35,4 +38,104 @@ module App::Services::Cli
|
||||
|
||||
"User with ID #{user_id} deleted successfully"
|
||||
end
|
||||
|
||||
def self.setup_admin_user
|
||||
admin_name = ENV["ADMIN_NAME"]?
|
||||
admin_api_key = ENV["ADMIN_API_KEY"]?
|
||||
|
||||
if admin_name && admin_api_key
|
||||
query = App::Lib::Database::Query.where(name: admin_name, api_key: admin_api_key).limit(1)
|
||||
existing_user = App::Lib::Database.all(App::Models::User, query).first?
|
||||
|
||||
return if existing_user
|
||||
|
||||
puts "Admin user setup detected. Creating admin user..."
|
||||
result = create_user(admin_name, admin_api_key)
|
||||
puts result
|
||||
else
|
||||
puts "Admin setup skipped: Missing ADMIN_NAME or ADMIN_API_KEY environment variables."
|
||||
end
|
||||
end
|
||||
|
||||
def self.update_uap_regexes
|
||||
puts "Downloading User-Agent Parser core regexes..."
|
||||
|
||||
FileUtils.mkdir_p("data")
|
||||
url = "https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml"
|
||||
output_file = "data/uap_core_regexes.yaml"
|
||||
|
||||
begin
|
||||
http_get_with_redirect(url) do |response|
|
||||
File.write(output_file, response.body_io.gets_to_end)
|
||||
end
|
||||
puts "User-Agent regexes downloaded to #{output_file}"
|
||||
rescue e
|
||||
puts "Error: Failed to download UAP core regexes: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.download_geolite_db
|
||||
puts "Downloading GeoLite2 Country database..."
|
||||
|
||||
FileUtils.mkdir_p("data")
|
||||
url = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"
|
||||
output_file = "data/GeoLite2-Country.mmdb"
|
||||
|
||||
begin
|
||||
File.open(output_file, "wb") do |file|
|
||||
http_get_with_redirect(url) do |response|
|
||||
IO.copy(response.body_io, file)
|
||||
end
|
||||
end
|
||||
puts "GeoLite2 database downloaded to #{output_file}"
|
||||
rescue e
|
||||
puts "Error: Failed to download GeoLite2 database: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private def self.http_get_with_redirect(url : String, max_redirects = 5)
|
||||
redirects = 0
|
||||
|
||||
while redirects < max_redirects
|
||||
uri = URI.parse(url)
|
||||
client = HTTP::Client.new(uri)
|
||||
|
||||
success = false
|
||||
follow_redirect = false
|
||||
redirect_url = nil
|
||||
|
||||
begin
|
||||
client.get(uri.request_target) do |response|
|
||||
case response.status_code
|
||||
when 200
|
||||
yield response
|
||||
success = true
|
||||
when 301, 302
|
||||
if new_location = response.headers["Location"]?
|
||||
puts "Following redirect to: #{new_location}"
|
||||
redirect_url = new_location
|
||||
follow_redirect = true
|
||||
else
|
||||
raise "Received redirect status but no Location header"
|
||||
end
|
||||
else
|
||||
raise "Failed request with status code: #{response.status_code}"
|
||||
end
|
||||
end
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
return if success
|
||||
|
||||
if follow_redirect && redirect_url
|
||||
url = redirect_url
|
||||
redirects += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
raise "Too many redirects (#{max_redirects})"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
require "digest"
|
||||
require "base64"
|
||||
|
||||
module App::Services::SlugService
|
||||
def self.shorten_url(url : String, user_id : String) : String
|
||||
combined = "#{user_id}-#{url}"
|
||||
crc32_hash = Digest::CRC32.digest(combined)
|
||||
base62_encoded = Base64.urlsafe_encode(crc32_hash).strip.tr("-_=", "")
|
||||
|
||||
base62_encoded
|
||||
end
|
||||
end
|
||||
+127
-116
@@ -1,145 +1,156 @@
|
||||
#!/bin/bash
|
||||
|
||||
api_url="http://localhost:4001/api/links"
|
||||
num_links=1000
|
||||
num_requests=10
|
||||
resource_usage_interval=1 # Interval in seconds for resource usage logging
|
||||
# Configuration variables
|
||||
server_url="http://localhost:4000"
|
||||
api_url="${server_url}/api/links"
|
||||
num_links=10000
|
||||
num_requests=10000
|
||||
concurrency=100
|
||||
resource_usage_interval=1
|
||||
container_name="bit"
|
||||
|
||||
semaphore="/tmp/semaphore"
|
||||
max_concurrent_processes=$(ulimit -u) # Adjust this number based on your system's capability
|
||||
check_dependencies() {
|
||||
if ! command -v bombardier &> /dev/null; then
|
||||
echo "Error: bombardier is not installed. Please install it to proceed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Initialize semaphore
|
||||
mkfifo $semaphore
|
||||
exec 3<> $semaphore
|
||||
rm $semaphore
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Error: jq is not installed. Please install it to proceed."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for ((i=0; i<max_concurrent_processes; i++)); do
|
||||
echo >&3
|
||||
done
|
||||
setup_containers() {
|
||||
echo "Setting up..."
|
||||
docker compose up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to start Docker containers."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Semaphore initialized with $max_concurrent_processes slots."
|
||||
output=$(docker compose exec -T app cli --create-user=Admin)
|
||||
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
|
||||
echo "Captured API Key: $api_key"
|
||||
|
||||
function get_resource_usage {
|
||||
while true; do
|
||||
docker stats --no-stream --format "{{.MemUsage}} {{.CPUPerc}}" url-shortener >> resource_usage.txt
|
||||
if [[ -z "$api_key" ]]; then
|
||||
echo "Error: API key could not be retrieved."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for the application to be ready..."
|
||||
until curl --silent --head --fail --header "X-Api-Key: $api_key" "$server_url/api/ping"; do
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
monitor_resource_usage() {
|
||||
echo "Starting resource usage monitoring..."
|
||||
echo "Timestamp,CPU,Memory" > resource_usage.csv
|
||||
while :; do
|
||||
stats=$(docker stats --no-stream --format "{{.CPUPerc}},{{.MemUsage}}" $container_name)
|
||||
cpu=$(echo $stats | awk -F',' '{print $1}' | sed 's/%//')
|
||||
mem=$(echo $stats | awk -F',' '{print $2}' | awk '{print $1}')
|
||||
timestamp=$(date +%s)
|
||||
echo "$timestamp,$cpu,$mem" >> resource_usage.csv
|
||||
sleep $resource_usage_interval
|
||||
done
|
||||
}
|
||||
|
||||
function calculate_average_usage {
|
||||
create_links() {
|
||||
local temp_file=$(mktemp)
|
||||
|
||||
echo "Creating $num_links short links with $concurrency conrurrent requests..."
|
||||
|
||||
# Populate URLs into a file to feed into curl
|
||||
for ((i=1; i<=num_links; i++)); do
|
||||
url="https://example.com/${i}-${num_links}"
|
||||
echo "--next" >> "$temp_file"
|
||||
echo "--request POST" >> "$temp_file"
|
||||
echo "--url \"$api_url\"" >> "$temp_file"
|
||||
echo "--header \"X-Api-Key: $api_key\"" >> "$temp_file"
|
||||
echo "--header \"Content-Type: application/json\"" >> "$temp_file"
|
||||
echo "--data \"{ \\\"url\\\": \\\"$url\\\" }\"" >> "$temp_file"
|
||||
done
|
||||
|
||||
curl --parallel --parallel-immediate --parallel-max $concurrency --config "$temp_file" --silent --write-out "%{http_code}\n" > /dev/null
|
||||
|
||||
echo "Link creation complete: $num_links links created."
|
||||
|
||||
# Clean up
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
run_benchmark() {
|
||||
echo "Fetching all created links from /api/links..."
|
||||
all_links_response=$(curl --silent --request GET \
|
||||
--url "$api_url" \
|
||||
--header "X-Api-Key: $api_key" \
|
||||
--header "Content-Type: application/json")
|
||||
|
||||
links=($(echo "$all_links_response" | jq -r '.data[] | .refer'))
|
||||
if [[ ${#links[@]} -ne $num_links ]]; then
|
||||
echo "Error: Expected $num_links links but found ${#links[@]}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
random_link="${links[RANDOM % ${#links[@]}]}"
|
||||
echo "Selected link for benchmarking: $random_link"
|
||||
|
||||
echo "Starting benchmark with Bombardier..."
|
||||
bombardier -c $concurrency -n $num_requests "$random_link"
|
||||
echo "Benchmark completed."
|
||||
}
|
||||
|
||||
analyze_resource_usage() {
|
||||
echo "Analyzing resource usage..."
|
||||
total_cpu=0
|
||||
total_mem=0
|
||||
count=0
|
||||
|
||||
while read -r line; do
|
||||
mem=$(echo $line | awk '{print $1}')
|
||||
|
||||
# Convert memory to MiB if necessary
|
||||
if [[ $mem == *MiB ]]; then
|
||||
mem=$(echo $mem | sed 's/MiB//')
|
||||
elif [[ $mem == *GiB ]]; then
|
||||
mem=$(echo $mem | sed 's/GiB//')
|
||||
mem=$(echo "$mem * 1024" | bc)
|
||||
fi
|
||||
while IFS=',' read -r timestamp cpu mem; do
|
||||
# Skip header line and lines with empty cpu or mem values
|
||||
if [[ $timestamp != "Timestamp" && -n $cpu && -n $mem ]]; then
|
||||
mem=${mem%MiB}
|
||||
|
||||
total_cpu=$(echo "$total_cpu + $cpu" | bc)
|
||||
total_mem=$(echo "$total_mem + $mem" | bc)
|
||||
((count++))
|
||||
done < resource_usage.txt
|
||||
fi
|
||||
done < resource_usage.csv
|
||||
|
||||
avg_cpu=0.00
|
||||
avg_mem=0.00
|
||||
|
||||
if (( count > 0 )); then
|
||||
avg_cpu=$(echo "scale=2; $total_cpu / $count" | bc)
|
||||
avg_mem=$(echo "scale=2; $total_mem / $count" | bc)
|
||||
rm resource_usage.txt
|
||||
fi
|
||||
|
||||
echo "**** Results ****"
|
||||
echo "Average CPU Usage: $avg_cpu%"
|
||||
echo "Average Memory Usage: $avg_mem MiB"
|
||||
}
|
||||
|
||||
function measure {
|
||||
total_time=0
|
||||
declare -a refer_links
|
||||
|
||||
# Start resource usage logging in the background
|
||||
nohup bash -c "$(declare -f get_resource_usage); get_resource_usage" &> /dev/null &
|
||||
resource_usage_pid=$!
|
||||
disown
|
||||
|
||||
echo "Creating $num_links short links..."
|
||||
for ((i=1; i<=num_links; i++)); do
|
||||
response=$(curl --silent --request POST \
|
||||
--url $api_url \
|
||||
--header "X-Api-Key: $api_key" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{ \"url\": \"https://kagi.com\" }")
|
||||
|
||||
refer=$(echo $response | awk -F'"' '/"refer":/{print $(NF-1)}')
|
||||
if [[ -n $refer ]]; then
|
||||
refer_links+=("$refer")
|
||||
if (( i % 100 == 0 )); then
|
||||
echo "Created short link $i/$num_links"
|
||||
fi
|
||||
else
|
||||
echo "Failed to create short link $i"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Accessing each link $num_requests times concurrently..."
|
||||
> times.txt # Ensure times.txt is created and empty
|
||||
|
||||
total_accesses=$((num_links * num_requests))
|
||||
accesses_done=0
|
||||
|
||||
for refer in "${refer_links[@]}"; do
|
||||
for ((i=1; i<=num_requests; i++)); do
|
||||
# Wait for a slot
|
||||
read -u 3
|
||||
{
|
||||
start_time=$(date +%s%6N)
|
||||
curl -s "$refer" >> /dev/null
|
||||
end_time=$(date +%s%6N)
|
||||
elapsed_time=$(echo "$end_time - $start_time" | bc)
|
||||
echo $elapsed_time >> times.txt
|
||||
# Release the slot
|
||||
echo >&3
|
||||
|
||||
((accesses_done++))
|
||||
if (( accesses_done % 10 == 0 )); then
|
||||
echo "Accessed $accesses_done/$total_accesses"
|
||||
fi
|
||||
} &
|
||||
done
|
||||
done
|
||||
|
||||
wait
|
||||
|
||||
# Stop resource usage logging
|
||||
if kill -0 $resource_usage_pid 2>/dev/null; then
|
||||
kill $resource_usage_pid
|
||||
fi
|
||||
|
||||
# Read all elapsed times and calculate total
|
||||
while read -r time; do
|
||||
total_time=$(echo "$total_time + $time" | bc)
|
||||
done < times.txt
|
||||
rm times.txt
|
||||
|
||||
echo "****Results****"
|
||||
|
||||
calculate_average_usage
|
||||
echo "Average Response Time: $(echo "scale=2; $total_time / ($num_links * $num_requests)" | bc) µs"
|
||||
cleanup() {
|
||||
rm -f resource_usage.csv
|
||||
docker compose down
|
||||
}
|
||||
|
||||
echo "Setup..."
|
||||
main() {
|
||||
check_dependencies
|
||||
setup_containers
|
||||
|
||||
docker-compose up -d
|
||||
# Ensure migrations are done
|
||||
docker-compose exec -T app migrate
|
||||
monitor_resource_usage & # Start monitoring in the background
|
||||
monitor_pid=$!
|
||||
trap 'kill $monitor_pid; cleanup; exit' INT
|
||||
|
||||
# Create a new user and capture the API key
|
||||
output=$(docker-compose exec -T app cli --create-user=Admin)
|
||||
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
|
||||
echo "Captured API Key: $api_key"
|
||||
create_links
|
||||
run_benchmark
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
sleep 5
|
||||
kill $monitor_pid
|
||||
analyze_resource_usage
|
||||
cleanup
|
||||
}
|
||||
|
||||
measure
|
||||
|
||||
# Clean up
|
||||
docker-compose down
|
||||
main
|
||||
|
||||
@@ -5,14 +5,13 @@ require "./app/lib/*"
|
||||
require "./app/models/*"
|
||||
require "./app/serializers/*"
|
||||
require "./app/middlewares/*"
|
||||
require "./app/services/*"
|
||||
|
||||
require "./app/routes"
|
||||
|
||||
add_context_storage_type(App::Models::User)
|
||||
add_handler(App::Middlewares::Auth.new)
|
||||
|
||||
error 500 { |env| {"error" => "Internal Server Error" }.to_json}
|
||||
error 401 { |env| {"error" => "Unauthorized" }.to_json}
|
||||
error 404 { |env| {"error" => "Not Found" }.to_json}
|
||||
App::Services::Cli.setup_admin_user
|
||||
|
||||
Kemal.run
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ CREATE TABLE links (
|
||||
user_id TEXT NOT NULL,
|
||||
slug VARCHAR(4) UNIQUE NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
click_counter INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +micrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
CREATE TABLE clicks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
link_id TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
browser TEXT,
|
||||
os TEXT,
|
||||
source TEXT,
|
||||
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- +micrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
DROP TABLE clicks;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- +micrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
|
||||
-- Step 1: Create a new table with the desired column type
|
||||
CREATE TABLE links_new (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
slug VARCHAR(8) UNIQUE NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Step 2: Copy data from the old table to the new table
|
||||
INSERT INTO links_new (id, user_id, slug, url, created_at, updated_at)
|
||||
SELECT id, user_id, slug, url, created_at, updated_at FROM links;
|
||||
|
||||
-- Step 3: Drop the old table
|
||||
DROP TABLE links;
|
||||
|
||||
-- Step 4: Rename the new table to the old table's name
|
||||
ALTER TABLE links_new RENAME TO links;
|
||||
|
||||
-- +micrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
|
||||
-- Step 1: Create a new table with the original column type
|
||||
CREATE TABLE links_old (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
slug VARCHAR(4) UNIQUE NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Step 2: Copy data from the current table to the old table
|
||||
INSERT INTO links_old (id, user_id, slug, url, created_at, updated_at)
|
||||
SELECT id, user_id, substr(slug, 1, 4), url, created_at, updated_at FROM links;
|
||||
|
||||
-- Step 3: Drop the current table
|
||||
DROP TABLE links;
|
||||
|
||||
-- Step 4: Rename the old table to the current table's name
|
||||
ALTER TABLE links_old RENAME TO links;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- +micrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
ALTER TABLE clicks ADD COLUMN country TEXT;
|
||||
ALTER TABLE clicks RENAME COLUMN source TO referer;
|
||||
|
||||
-- +micrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
ALTER TABLE clicks RENAME COLUMN referer TO source;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +micrate Up
|
||||
-- SQL in section 'Up' is executed when this migration is applied
|
||||
UPDATE clicks SET user_agent = NULL WHERE user_agent = 'Unknown';
|
||||
UPDATE clicks SET browser = NULL WHERE browser = 'Unknown';
|
||||
UPDATE clicks SET os = NULL WHERE os = 'Unknown';
|
||||
UPDATE clicks SET referer = NULL WHERE referer = 'Unknown';
|
||||
|
||||
-- +micrate Down
|
||||
-- SQL section 'Down' is executed when this migration is rolled back
|
||||
UPDATE clicks SET user_agent = 'Unknown' WHERE user_agent IS NULL;
|
||||
UPDATE clicks SET browser = 'Unknown' WHERE browser IS NULL;
|
||||
UPDATE clicks SET os = 'Unknown' WHERE os IS NULL;
|
||||
UPDATE clicks SET referer = 'Unknown' WHERE referer IS NULL;
|
||||
+9
-3
@@ -1,9 +1,15 @@
|
||||
services:
|
||||
app:
|
||||
container_name: bit
|
||||
build: .
|
||||
environment:
|
||||
ENV: production
|
||||
DATABASE_URL: sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
|
||||
APP_URL: http://0.0.0.0:4001
|
||||
ADMIN_NAME: 'Tester'
|
||||
ADMIN_API_KEY: '0p+mDvbpZGLPGVCXnV+EDduR9Blkv27Dhq9XSzSbdQY='
|
||||
ports:
|
||||
- 4001:4000
|
||||
- 4000:4000
|
||||
volumes:
|
||||
- sqlite_data:/app/sqlite
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# API Reference
|
||||
|
||||
1. **Ping the API**
|
||||
|
||||
- Endpoint: `GET /api/ping`
|
||||
- Payload: None
|
||||
- Response Example
|
||||
```json
|
||||
{
|
||||
"data": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Redirect by Slug**
|
||||
|
||||
- Endpoint: `GET /:slug`
|
||||
- Payload: None
|
||||
- Response: 301
|
||||
|
||||
3. **List All Links**
|
||||
|
||||
- Endpoint: `GET /api/links`
|
||||
- Headers: `X-Api-Key`
|
||||
- Payload: None
|
||||
- Response Example
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
||||
"refer": "http://localhost:4000/3wP4BQ",
|
||||
"origin": "https://monocuco.donado.co",
|
||||
"clicks": [
|
||||
{
|
||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"country": "DE",
|
||||
"browser": "Firefox",
|
||||
"os": "Mac OS X",
|
||||
"referer": "Direct",
|
||||
"created_at": "2024-07-12T19:25:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. **List link by ID**
|
||||
|
||||
- Endpoint: `GET /api/links/:id`
|
||||
- Headers: `X-Api-Key`
|
||||
- Payload: None
|
||||
- Response Example
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
||||
"refer": "http://localhost:4000/3wP4BQ",
|
||||
"origin": "https://monocuco.donado.co",
|
||||
"clicks": [
|
||||
{
|
||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"country": "DE",
|
||||
"browser": "Firefox",
|
||||
"os": "Mac OS X",
|
||||
"referer": "Direct",
|
||||
"created_at": "2024-07-12T19:25:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Create new link**
|
||||
|
||||
- Endpoint\*\*: `POST /api/links`
|
||||
- Payload:
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com"
|
||||
}
|
||||
```
|
||||
- Headers: `X-Api-Key`
|
||||
- Response Example:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
||||
"refer": "http://localhost:4000/3wP4BQ",
|
||||
"origin": "https://example.com",
|
||||
"clicks": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **Update an existing link by ID**
|
||||
|
||||
- Endpoint: `PUT /api/links/:id`
|
||||
- Payload:
|
||||
```json
|
||||
{
|
||||
"url": "https://newexample.com"
|
||||
}
|
||||
```
|
||||
- Headers: `X-Api-Key`
|
||||
- Response Example:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
||||
"refer": "http://localhost:4000/3wP4BQ",
|
||||
"origin": "https://newexample.com",
|
||||
"clicks": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. **Delete a link by ID**
|
||||
|
||||
- Endpoint: `DELETE /api/links/:id`
|
||||
- Payload: None
|
||||
- Headers: `X-Api-Key`
|
||||
- Response: 204
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
## CLI
|
||||
|
||||
```
|
||||
Usage: ./cli [options]
|
||||
Options:
|
||||
--create-user=NAME Create a new user with the given name
|
||||
--list-users List all users
|
||||
--delete-user=USER_ID Delete a user by ID
|
||||
```
|
||||
|
||||
## Run It Anywhere
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
|
||||
# Optional: Generate an api key
|
||||
# docker-compose exec -it app cli --create-user=Admin
|
||||
```
|
||||
|
||||
### Docker CLI
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--name bit \
|
||||
-p 4000:4000 \
|
||||
-e ENV="production" \
|
||||
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
|
||||
-e APP_URL="http://localhost:4000" \
|
||||
-e ADMIN_NAME="Admin" \
|
||||
-e ADMIN_API_KEY=$(openssl rand -base64 32) \
|
||||
sjdonado/bit
|
||||
|
||||
# Optional: Generate an api key
|
||||
# docker exec -it bit cli --create-user=Admin
|
||||
```
|
||||
|
||||
### Self-Hosted with Dokku
|
||||
|
||||
```dockerfile
|
||||
FROM sjdonado/bit
|
||||
```
|
||||
|
||||
```bash
|
||||
dokku apps:create bit
|
||||
|
||||
dokku domains:set bit bit.donado.co
|
||||
dokku letsencrypt:enable bit
|
||||
|
||||
dokku storage:ensure-directory bit-sqlite
|
||||
dokku storage:mount bit /var/lib/dokku/data/storage/bit-sqlite:/usr/src/app/sqlite/
|
||||
|
||||
dokku config:set bit DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" APP_URL=https://bit.donado.co ADMIN_NAME=Admin ADMIN_API_KEY=$(openssl rand -base64 32)
|
||||
|
||||
dokku ports:add bit http:80:4000
|
||||
dokku ports:add bit https:443:4000
|
||||
|
||||
# Optional: Generate an api key
|
||||
# dokku run bit cli --create-user=Admin
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Requirements
|
||||
- Crystal 1.12+
|
||||
- Shards package manager
|
||||
- SQLite3
|
||||
|
||||
### Install Dependencies
|
||||
- linux
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y crystal libssl-dev libsqlite3-dev
|
||||
```
|
||||
|
||||
- macos
|
||||
```bash
|
||||
brew tap amberframework/micrate
|
||||
brew install micrate
|
||||
```
|
||||
|
||||
### Install Shards and Run
|
||||
|
||||
```bash
|
||||
shards run bit
|
||||
```
|
||||
|
||||
- Generate the `X-Api-Key`
|
||||
|
||||
```bash
|
||||
shards run cli -- --create-user=Admin
|
||||
```
|
||||
|
||||
- Run tests
|
||||
|
||||
```bash
|
||||
ENV=test crystal spec
|
||||
```
|
||||
|
||||
## Benchmark
|
||||
|
||||
CPU: Apple M3 Pro
|
||||
|
||||
```
|
||||
> colima start --cpu 1 --memory 1
|
||||
INFO[0000] starting colima
|
||||
INFO[0000] runtime: docker
|
||||
INFO[0001] starting ... context=vm
|
||||
INFO[0076] provisioning ... context=docker
|
||||
INFO[0077] starting ... context=docker
|
||||
INFO[0077] done
|
||||
|
||||
> ./benchmark.sh
|
||||
Setting up...
|
||||
[+] Running 2/2
|
||||
✔ Network bit_default Created 0.0s
|
||||
✔ Container bit Started 0.1s
|
||||
Captured API Key: v-8gljT0WjMhQECito3e5g
|
||||
Waiting for the application to be ready...
|
||||
HTTP/1.1 200 OK
|
||||
Connection: keep-alive
|
||||
Content-Type: application/json
|
||||
Date: Sun, 16 Mar 2025 10:51:22 GMT
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Accept, Origin, X-Api-Key
|
||||
Content-Length: 13
|
||||
|
||||
Starting resource usage monitoring...
|
||||
Creating 10000 short links with 100 conrurrent requests...
|
||||
Link creation complete: 10000 links created.
|
||||
Fetching all created links from /api/links...
|
||||
Selected link for benchmarking: http://localhost:4000/pKtTjA
|
||||
Starting benchmark with Bombardier...
|
||||
Bombarding http://localhost:4000/pKtTjA with 10000 request(s) using 100 connection(s)
|
||||
10000 / 10000 [======================================================================================================================================================================] 100.00% 1424/s 7s
|
||||
Done!
|
||||
Statistics Avg Stdev Max
|
||||
Reqs/sec 1885.24 7686.34 140641.16
|
||||
Latency 68.00ms 6.43ms 89.56ms
|
||||
HTTP codes:
|
||||
1xx - 0, 2xx - 0, 3xx - 10000, 4xx - 0, 5xx - 0
|
||||
others - 0
|
||||
Throughput: 625.54KB/s
|
||||
Benchmark completed.
|
||||
Analyzing resource usage...
|
||||
**** Results ****
|
||||
Average CPU Usage: 42.98%
|
||||
Average Memory Usage: 33.25 MiB
|
||||
./benchmark.sh: line 135: 1500 Terminated: 15 monitor_resource_usage
|
||||
[+] Running 2/2
|
||||
✔ Container bit Removed 10.1s
|
||||
✔ Network bit_default Removed 0.0s
|
||||
```
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
REGEXES_URL="https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml"
|
||||
DOWNLOAD_DIR="data"
|
||||
REGEXES_FILE="regexes.yaml"
|
||||
|
||||
mkdir -p $DOWNLOAD_DIR
|
||||
|
||||
curl -L -o $DOWNLOAD_DIR/$REGEXES_FILE $REGEXES_URL
|
||||
|
||||
echo "Regexes file downloaded to $DOWNLOAD_DIR/$REGEXES_FILE"
|
||||
@@ -19,11 +19,20 @@ OptionParser.parse do |parser|
|
||||
exit
|
||||
end
|
||||
|
||||
parser.on("--update-data", "Download all required data files (UA Parser and GeoLite2)") do
|
||||
puts "=== Starting data files update ==="
|
||||
App::Services::Cli.update_uap_regexes
|
||||
App::Services::Cli.download_geolite_db
|
||||
puts "=== All data files updated successfully ==="
|
||||
exit
|
||||
end
|
||||
|
||||
if ARGV.empty?
|
||||
puts "Usage: ./cli [options]"
|
||||
puts "Options:"
|
||||
puts " --create-user=NAME Create a new user with the given name"
|
||||
puts " --list-users List all users"
|
||||
puts " --delete-user=USER_ID Delete a user by ID"
|
||||
puts " --update-data Download all required data files"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
require "sqlite3"
|
||||
require"micrate"
|
||||
|
||||
require "../app/config/env"
|
||||
|
||||
Micrate::DB.connection_url = ENV["DATABASE_URL"]
|
||||
Micrate::Cli.run_up
|
||||
+12
@@ -20,10 +20,18 @@ shards:
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.4.1
|
||||
|
||||
ipaddress:
|
||||
git: https://github.com/sija/ipaddress.cr.git
|
||||
version: 0.2.3
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 1.5.0
|
||||
|
||||
maxminddb:
|
||||
git: https://github.com/delef/maxminddb.cr.git
|
||||
version: 1.5.0
|
||||
|
||||
micrate:
|
||||
git: https://github.com/amberframework/micrate.git
|
||||
version: 0.15.1
|
||||
@@ -48,3 +56,7 @@ shards:
|
||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||
version: 0.19.0
|
||||
|
||||
user_agent_parser:
|
||||
git: https://github.com/busyloop/user_agent_parser.git
|
||||
version: 2.0.1
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
name: url-shortener
|
||||
version: 0.1.0
|
||||
name: bit
|
||||
version: 1.5.0
|
||||
|
||||
authors:
|
||||
- Juan Rodriguez <sjdonado@icloud.com>
|
||||
|
||||
targets:
|
||||
url-shortener:
|
||||
main: url-shortener.cr
|
||||
bit:
|
||||
main: bit.cr
|
||||
cli:
|
||||
main: scripts/cli.cr
|
||||
migrate:
|
||||
main: scripts/migrate.cr
|
||||
|
||||
dependencies:
|
||||
kemal:
|
||||
@@ -22,6 +20,10 @@ dependencies:
|
||||
micrate:
|
||||
github: amberframework/micrate
|
||||
version: 0.15.1
|
||||
user_agent_parser:
|
||||
github: busyloop/user_agent_parser
|
||||
maxminddb:
|
||||
github: delef/maxminddb.cr
|
||||
|
||||
development_dependencies:
|
||||
dotenv:
|
||||
@@ -29,6 +31,6 @@ development_dependencies:
|
||||
spec-kemal:
|
||||
github: kemalcr/spec-kemal
|
||||
|
||||
crystal: '>= 1.12.1'
|
||||
crystal: ">= 1.12.1"
|
||||
|
||||
license: MIT
|
||||
|
||||
+119
-30
@@ -15,10 +15,34 @@ describe "App::Controllers::Link" do
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
parsed_response = Hash(String, Hash(String, String | Int64)).from_json(response.body)
|
||||
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||
parsed_response["data"]["origin"].should eq(payload["url"])
|
||||
end
|
||||
|
||||
it "should return existing link if url already exists" do
|
||||
test_user = create_test_user()
|
||||
|
||||
payload = {"url" => "http://idonthavespotify.donado.co"}
|
||||
post(
|
||||
"/api/links",
|
||||
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
first_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||
first_response["data"]["origin"].should eq(payload["url"])
|
||||
|
||||
post(
|
||||
"/api/links",
|
||||
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
second_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||
second_response["data"]["origin"].should eq(payload["url"])
|
||||
second_response["data"]["id"].should eq(first_response["data"]["id"])
|
||||
end
|
||||
|
||||
it "should return 400 - url required field" do
|
||||
test_user = create_test_user()
|
||||
|
||||
@@ -51,53 +75,86 @@ describe "App::Controllers::Link" do
|
||||
payload = {"url" => "https://kagi.com"}
|
||||
post("/api/links", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: payload.to_json)
|
||||
|
||||
expected = {"error" => "Unauthorized"}.to_json
|
||||
expected = {"error" => "Unauthorized access"}.to_json
|
||||
response.status_code.should eq(401)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Index" do
|
||||
it "should redirect to origin domain" do
|
||||
link = "https://kagi.com"
|
||||
it "should redirect to origin domain with forwarded headers" do
|
||||
link = "https://test.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{
|
||||
"X-Api-Key" => test_user.api_key.to_s,
|
||||
"User-Agent" => user_agent
|
||||
})
|
||||
|
||||
response.headers["Location"].should eq(link)
|
||||
response.headers["X-Forwarded-User-Agent"].should eq(user_agent)
|
||||
response.headers.has_key?("X-Forwarded-For").should be_true
|
||||
end
|
||||
|
||||
it "should increase click counter after redirect" do
|
||||
link = "https://kagi.com"
|
||||
it "should create a new click after redirect with proper information" do
|
||||
link = "https://sjdonado.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
Fiber.yield
|
||||
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
||||
referer = "https://example.com/page"
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{
|
||||
"User-Agent" => user_agent,
|
||||
"Referer" => referer
|
||||
})
|
||||
|
||||
Fiber.yield # replace yield with sleep 5 to debug errors
|
||||
|
||||
response.headers["Location"].should eq(link)
|
||||
|
||||
# Verify that the click was recorded
|
||||
updated_test_link = get_test_link(test_link.id)
|
||||
updated_test_link.click_counter.should eq(1)
|
||||
updated_test_link.clicks.size.should eq(test_link.clicks.size + 1)
|
||||
|
||||
# Verify click details
|
||||
latest_click = updated_test_link.clicks.last
|
||||
latest_click.user_agent.should eq(user_agent)
|
||||
latest_click.browser.should eq("Firefox")
|
||||
latest_click.os.should eq("Mac OS X")
|
||||
latest_click.referer.should eq("example.com") # Should extract host from the referer
|
||||
end
|
||||
|
||||
it "should create a click with utm_source when no referer is provided" do
|
||||
link = "https://sjdonado.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
|
||||
# Add utm_source parameter
|
||||
get("#{serialized_link.refer}?utm_source=email_campaign")
|
||||
|
||||
Fiber.yield
|
||||
|
||||
updated_test_link = get_test_link(test_link.id)
|
||||
latest_click = updated_test_link.clicks.last
|
||||
latest_click.referer.should eq("email_campaign")
|
||||
end
|
||||
|
||||
it "should return 404 - link does not exist" do
|
||||
link = "https://kagi.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
get("https://localhost:4001/R4kj2")
|
||||
|
||||
delete_test_link(test_link.id)
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
expected = {"error" => "Not Found"}.to_json
|
||||
expected = {"error" => "Resource not found"}.to_json
|
||||
response.status_code.should eq(404)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
@@ -114,14 +171,14 @@ describe "App::Controllers::Link" do
|
||||
|
||||
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
parsed_response = Hash(String, Array(Hash(String, String | Int64))).from_json(response.body)
|
||||
parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
|
||||
parsed_response["data"][0]["origin"].should eq(links[0])
|
||||
parsed_response["data"][1]["origin"].should eq(links[1])
|
||||
parsed_response["data"][2]["origin"].should eq(links[2])
|
||||
end
|
||||
|
||||
it "should return owned links only" do
|
||||
links = ["https://google.com", "google.com", "google.com.co", "kagi.com"]
|
||||
links = ["https://google.de", "google.de", "google.edu.co", "x.com"]
|
||||
test_user = create_test_user()
|
||||
|
||||
links[0..2].each do |link|
|
||||
@@ -133,7 +190,7 @@ describe "App::Controllers::Link" do
|
||||
|
||||
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
parsed_response = Hash(String, Array(Hash(String, String | Int64))).from_json(response.body)
|
||||
parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
|
||||
parsed_response["data"].size.should eq(3)
|
||||
parsed_response["data"][0]["origin"].should eq(links[0])
|
||||
parsed_response["data"][1]["origin"].should eq(links[1])
|
||||
@@ -143,7 +200,39 @@ describe "App::Controllers::Link" do
|
||||
it "should return 401 - missing api key" do
|
||||
get "/api/links"
|
||||
|
||||
expected = {"error" => "Unauthorized"}.to_json
|
||||
expected = {"error" => "Unauthorized access"}.to_json
|
||||
response.status_code.should eq(401)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Get" do
|
||||
it "should return the specified link with click details" do
|
||||
link = "https://bing.com"
|
||||
test_user = create_test_user()
|
||||
test_link = create_test_link(test_user, link)
|
||||
|
||||
get("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||
parsed_response["data"]["origin"].should eq(link)
|
||||
parsed_response["data"]["clicks"].should be_a(Array(Hash(String, String)))
|
||||
end
|
||||
|
||||
it "should return 404 - link does not exist" do
|
||||
test_user = create_test_user()
|
||||
|
||||
get("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
expected = {"error" => "Resource not found"}.to_json
|
||||
response.status_code.should eq(404)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
|
||||
it "should return 401 - missing api key" do
|
||||
get "/api/links/1"
|
||||
|
||||
expected = {"error" => "Unauthorized access"}.to_json
|
||||
response.status_code.should eq(401)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
@@ -151,18 +240,18 @@ describe "App::Controllers::Link" do
|
||||
|
||||
describe "Update" do
|
||||
it "should update link url" do
|
||||
link = "https://kagi.com"
|
||||
link = "https://github.com"
|
||||
test_user = create_test_user()
|
||||
test_link = create_test_link(test_user, link)
|
||||
|
||||
payload = {"url" => "https://kagi.com.co"}
|
||||
payload = {"url" => "https://github.com.co"}
|
||||
put(
|
||||
"/api/links/#{test_link.id}",
|
||||
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
parsed_response = Hash(String, Hash(String, String | Int64)).from_json(response.body)
|
||||
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
|
||||
parsed_response["data"]["origin"].should eq(payload["url"])
|
||||
end
|
||||
|
||||
@@ -176,7 +265,7 @@ describe "App::Controllers::Link" do
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
expected = {"error" => "Not Found"}.to_json
|
||||
expected = {"error" => "Resource not found"}.to_json
|
||||
response.status_code.should eq(404)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
@@ -189,7 +278,7 @@ describe "App::Controllers::Link" do
|
||||
body: payload.to_json
|
||||
)
|
||||
|
||||
expected = {"error" => "Unauthorized"}.to_json
|
||||
expected = {"error" => "Unauthorized access"}.to_json
|
||||
response.status_code.should eq(401)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
@@ -197,7 +286,7 @@ describe "App::Controllers::Link" do
|
||||
|
||||
describe "Delete" do
|
||||
it "should delete link url" do
|
||||
link = "https://kagi.com"
|
||||
link = "https://news.ycombinator.com"
|
||||
test_user = create_test_user()
|
||||
test_link = create_test_link(test_user, link)
|
||||
|
||||
@@ -211,7 +300,7 @@ describe "App::Controllers::Link" do
|
||||
|
||||
delete("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
expected = {"error" => "Not Found"}.to_json
|
||||
expected = {"error" => "Resource not found"}.to_json
|
||||
response.status_code.should eq(404)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
@@ -219,7 +308,7 @@ describe "App::Controllers::Link" do
|
||||
it "should return 401 - missing api key" do
|
||||
delete "/api/links/1"
|
||||
|
||||
expected = {"error" => "Unauthorized"}.to_json
|
||||
expected = {"error" => "Unauthorized access"}.to_json
|
||||
response.status_code.should eq(401)
|
||||
response.body.should eq(expected)
|
||||
end
|
||||
|
||||
@@ -34,4 +34,29 @@ describe "App::Services::Cli" do
|
||||
|
||||
output.should contain "Failed to delete user"
|
||||
end
|
||||
|
||||
it "sets up an admin user if environment variables are present" do
|
||||
ENV["ADMIN_NAME"] = "adminuser"
|
||||
ENV["ADMIN_API_KEY"] = "secure_admin_key"
|
||||
|
||||
App::Services::Cli.setup_admin_user
|
||||
|
||||
admin_user = App::Lib::Database.all(App::Models::User).find { |u| u.name == "adminuser" }
|
||||
admin_user.should_not be_nil
|
||||
admin_user = admin_user.not_nil!
|
||||
|
||||
admin_user.api_key.should eq "secure_admin_key"
|
||||
|
||||
App::Services::Cli.delete_user(admin_user.id)
|
||||
end
|
||||
|
||||
it "skips admin setup if environment variables are missing" do
|
||||
ENV.delete("ADMIN_NAME")
|
||||
ENV.delete("ADMIN_API_KEY")
|
||||
|
||||
App::Services::Cli.setup_admin_user
|
||||
|
||||
users = App::Lib::Database.all(App::Models::User)
|
||||
users.none? { |u| u.name == "adminuser" }.should be_true
|
||||
end
|
||||
end
|
||||
|
||||
+25
-6
@@ -1,11 +1,21 @@
|
||||
require "uuid"
|
||||
require "file_utils"
|
||||
|
||||
require "spec-kemal"
|
||||
require "micrate"
|
||||
|
||||
require "../url-shortener"
|
||||
require "dotenv"
|
||||
Dotenv.load ".env.#{ENV["ENV"]}"
|
||||
|
||||
require "../bit"
|
||||
|
||||
Spec.before_suite do
|
||||
# Delete the SQLite database file if it exists
|
||||
db_file_path = ENV["DATABASE_URL"].split("sqlite3://").last.split("?").first
|
||||
if File.exists?(db_file_path)
|
||||
File.delete(db_file_path)
|
||||
end
|
||||
|
||||
Micrate::DB.connection_url = ENV["DATABASE_URL"]
|
||||
Micrate::Cli.run_up
|
||||
|
||||
@@ -20,7 +30,8 @@ def create_test_user
|
||||
|
||||
changeset = App::Lib::Database.insert(user)
|
||||
if !changeset.valid?
|
||||
raise "Test user creation failed"
|
||||
error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
|
||||
raise "Test user creation failed #{error_messages}"
|
||||
end
|
||||
|
||||
user
|
||||
@@ -29,20 +40,28 @@ end
|
||||
def create_test_link(user, url)
|
||||
link = App::Models::Link.new
|
||||
link.id = UUID.v4.to_s
|
||||
link.slug = App::Services::SlugService.shorten_url(url, user.id.to_s)
|
||||
link.url = url
|
||||
link.slug = Random::Secure.urlsafe_base64(4)
|
||||
link.user = user
|
||||
|
||||
changeset = App::Lib::Database.insert(link)
|
||||
if !changeset.valid?
|
||||
raise "Test link creation failed"
|
||||
unless changeset.valid?
|
||||
error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
|
||||
raise "Test link creation failed: #{error_messages}"
|
||||
end
|
||||
|
||||
link.clicks = [] of App::Models::Click
|
||||
|
||||
link
|
||||
end
|
||||
|
||||
def get_test_link(link_id)
|
||||
App::Lib::Database.get!(App::Models::Link, link_id)
|
||||
query = App::Lib::Database::Query.where(id: link_id.as(String)).limit(1)
|
||||
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
|
||||
|
||||
raise "Link not found" if link.nil?
|
||||
|
||||
link
|
||||
end
|
||||
|
||||
def delete_test_link(link_id)
|
||||
|
||||
Reference in New Issue
Block a user