141 Commits

Author SHA1 Message Date
sjdonado 60ebac7150 chore: update API docs 2025-03-16 14:22:32 +01:00
sjdonado a066b5e5ab chore: upgrade version to 1.5.0 2025-03-16 13:41:54 +01:00
sjdonado cd8e2433a5 feat(cli): --update-data download UA Parser and GeoLite2 2025-03-16 13:40:33 +01:00
sjdonado b538a379d1 chore: update README 2025-03-16 11:54:48 +01:00
sjdonado 8cab7a51ad feat: country ip lookup 2025-03-16 11:42:01 +01:00
sjdonado ece74226d4 feat: add country to clicks 2025-03-16 11:26:20 +01:00
sjdonado d26aa2f18a feat: links redirect forward client ip 2025-03-16 10:20:01 +01:00
sjdonado ce2f73dfe3 fix(ci): release pipeline determine version 2025-03-16 10:18:51 +01:00
sjdonado 30fb539289 ci: speed up build with crystal arm64 binary 2025-02-07 19:37:54 +01:00
sjdonado 66fd6db3c2 ci: docker hub release tags 2025-02-07 19:05:31 +01:00
sjdonado 93a91cd76e ci: publish github workflow separate version tags from master 2025-02-07 18:53:55 +01:00
sjdonado 1d1444234b ci: publish workflow extract tag version 2025-02-07 18:44:49 +01:00
sjdonado 49ac63210e chore: bump version 2025-02-07 17:51:55 +01:00
sjdonado 702491cb39 chore: improve documentation local development guidelines 2025-02-07 17:50:10 +01:00
Juan Rodriguez d55dbe0471 Merge pull request #4 from sjdonado/chore/documentation
Chore/documentation
2025-02-07 17:34:25 +01:00
sjdonado 55969b03b5 chore: fix docs broken links 2025-02-07 17:31:05 +01:00
sjdonado 70a036e158 chore: cleanup README 2025-02-07 17:23:10 +01:00
sjdonado 6ade7d295b chore: CONTRIBUTING and CODE_OF_CONDUCT 2025-02-07 17:22:54 +01:00
sjdonado e6ae133449 chore: separate API reference from README 2025-02-07 17:22:41 +01:00
sjdonado d3706a8778 chore: separate SETUP docs from README 2025-02-07 17:22:22 +01:00
sjdonado a271e7c35d chore: bump version 2024-11-27 22:51:52 +01:00
sjdonado a46a50b429 chore: update README with new ENV variables 2024-11-27 22:51:20 +01:00
sjdonado dc8c359bfc test: admin env variables cases 2024-11-27 22:51:18 +01:00
sjdonado dfb6b10caf feat: setup admin user via env variables 2024-11-27 22:27:17 +01:00
Juan Rodriguez 3fa30b3a32 chore: Update README.md 2024-10-27 13:01:40 +01:00
Juan Rodriguez 80ed6033d1 chore: update README.md 2024-10-27 12:56:06 +01:00
Juan Rodriguez 4640522d5d chore: refactor create_links with curl parallel 2024-10-27 12:55:38 +01:00
Juan Rodriguez 848232cc11 fix: create links pipe ipc 2024-10-27 12:01:38 +01:00
Juan Rodriguez 98dedc4494 refactor: powered_by_header kemal config 2024-10-27 11:07:59 +01:00
Juan Rodriguez e6f64ea026 chore: update benchmark with bombardier 2024-10-27 11:07:23 +01:00
Juan Rodriguez ea71d3825e fix: generate slug by user + check existing link on update 2024-07-31 22:09:24 +02:00
Juan Rodriguez afa9b33568 tests: update error messages assertions 2024-07-31 21:50:35 +02:00
Juan Rodriguez a93189411b fix: test suite drop database before all 2024-07-31 21:39:24 +02:00
Juan Rodriguez 98f103f5cf fix: url validate format 2024-07-31 21:38:57 +02:00
Juan Rodriguez 6fc48dae83 refactor: replace slug generation with CRC32 + base62 2024-07-31 21:38:36 +02:00
Juan Rodriguez d039add340 chore: bump version 2024-07-31 08:08:38 +02:00
Juan Rodriguez 0214d6f46d ci: fix publish extract version step 2024-07-31 08:08:15 +02:00
Juan Rodriguez 37e14ec2f8 refactor: sha256 slug generation 2024-07-31 08:07:08 +02:00
Juan Rodriguez a85d5a8c73 chore: bump version 2024-07-14 22:30:15 +02:00
Juan Rodriguez 80cebe3357 fix: update slug size after 2nd attempt 2024-07-14 22:30:15 +02:00
Juan Rodriguez 451a5fbf0f fix: link email validate format regex 2024-07-14 14:33:34 +02:00
Juan Rodriguez aeb6d1164b fix: remove X-Powered-By header 2024-07-14 14:33:19 +02:00
Juan Rodriguez 2f14cd82dd fix: error handling override kemal default response 2024-07-14 11:19:59 +02:00
Juan Rodriguez faedd0bc7a fix: missing errors content type 2024-07-14 09:26:38 +02:00
Juan Rodriguez 1d207fae64 fix: log level error for production env 2024-07-14 09:26:25 +02:00
Juan Rodriguez a71f345f66 refactor: APP_URL + DATABASE_URL default values 2024-07-14 08:46:29 +02:00
Juan Rodriguez 7cc6c1197f refactor: auto run migrations on startup 2024-07-14 08:46:11 +02:00
Juan Rodriguez 115bbf7366 refactor: reduce docker image size 2024-07-12 23:18:06 +02:00
Juan Rodriguez 36a06ac670 chore: update README v1.1.0 2024-07-12 21:53:32 +02:00
Juan Rodriguez 772897cb27 ci: bump version to 1.1.0 2024-07-12 21:44:59 +02:00
Juan Rodriguez 1f42798d12 chore: update READMe with rest API response examples 2024-07-12 21:44:36 +02:00
Juan Rodriguez 08302e4457 ci: fix publish docker image extract_version 2024-07-12 21:29:15 +02:00
Juan Rodriguez cd9a44fcc9 fix: link controller preload + headers validation 2024-07-12 21:28:44 +02:00
Juan Rodriguez 6532ff466e test: add missing cases for link controller integration tests 2024-07-12 21:28:25 +02:00
Juan Rodriguez 8cbed80fd0 chore: update benchmark script 2024-07-12 20:52:57 +02:00
Juan Rodriguez 0758bf4cee fix: existing_links preload clicks 2024-07-12 20:26:43 +02:00
Juan Rodriguez f5c296bee3 Merge branch 'master' of https://github.com/sjdonado/bit 2024-07-12 19:00:55 +02:00
Juan Rodriguez 8f33375b5f fix: link clicks empty array validation 2024-07-12 19:00:44 +02:00
Juan Rodriguez cebbd48237 ci: update dockerfile to include data folder 2024-07-12 10:03:37 +02:00
Juan Rodriguez 88d81ecfe3 ci: update dockerfile to include data folder 2024-07-12 08:50:14 +02:00
Juan Rodriguez 3050c2b100 feat: get link by id endpoint 2024-07-12 08:47:08 +02:00
Juan Rodriguez a2aa586dae feat: get all links clicks join 2024-07-12 07:58:21 +02:00
Juan Rodriguez ebc9c6852e ci: download regexes script 2024-07-12 07:56:19 +02:00
Juan Rodriguez a1b67b8553 fix: avoid create links duplicates 2024-07-12 07:46:58 +02:00
Juan Rodriguez dbc81796d6 feat: store click data on redirect 2024-07-12 07:46:49 +02:00
Juan Rodriguez 2f796dbdab feat: create clicks model + migration 2024-07-12 07:28:27 +02:00
Juan Rodriguez ff06e10b8f ci: github workflow extract release tag from shard.yml 2024-07-11 22:45:46 +02:00
Juan Rodriguez 050fd6f1e3 refactor: rename url-shortener to bit 2024-07-10 22:29:34 +02:00
Juan Rodriguez b0160f8127 ci: build linux/amd64,linux/arm64 docker images 2024-07-10 21:51:12 +02:00
Juan Rodriguez bac73f2a47 fix: stream is closed before call_next 2024-07-10 21:41:48 +02:00
Juan Rodriguez d7a4ce25aa chore: remove bruno (deprecated) 2024-07-10 21:32:33 +02:00
Juan Rodriguez 72654ab9fb ci: fix Generate artifact attestation subject-path 2024-05-20 19:04:39 +02:00
Juan Rodriguez be10cb1c9a refactor: update benchmark to handle more links concurrently 2024-05-20 18:43:37 +02:00
Juan Rodriguez 7cbd469842 chore: benchmark script 2024-05-20 18:43:18 +02:00
Juan Rodriguez 8c209f0036 test: services unit tests 2024-05-20 13:14:37 +02:00
Juan Rodriguez f03e0092c8 refactor: move cli methods to app service 2024-05-20 13:13:49 +02:00
Juan Rodriguez 89fa00b80b ci: missing env variables publish workflow 2024-05-20 13:07:31 +02:00
Juan Rodriguez 2c3bb8deaf chore: rename bruno folder 2024-05-20 11:46:10 +02:00
Juan Rodriguez de12095d19 ci: fix generate artifact attestation 2024-05-14 07:03:40 +02:00
Juan Rodriguez 0d053a042a Merge pull request #3 from sjdonado/refactor/upgrade-to-crystal
Refactor/upgrade to crystal
2024-05-14 06:37:19 +02:00
Juan Rodriguez be0ebd1763 test: fix Closed stream (IO::Error) 2024-05-14 06:34:29 +02:00
Juan Rodriguez aa8a84af0e ci: run tests workflow 2024-05-14 06:28:13 +02:00
Juan Rodriguez f28dfdfd89 Merge branch 'master' of https://github.com/sjdonado/url-shortener into refactor/upgrade-to-crystal 2024-05-14 06:24:30 +02:00
Juan Rodriguez a7d52797d9 ci: publish to docker hub workflow 2024-05-14 06:21:55 +02:00
Juan Rodriguez 80a91583fe ci: move binaries to path 2024-05-14 06:21:13 +02:00
Juan Rodriguez abb35019f3 ci: Dockerfile + docker-compose 2024-05-14 05:50:28 +02:00
Juan Rodriguez adbc07e605 feat: migrate script 2024-05-14 05:50:13 +02:00
Juan Rodriguez 814fd83d32 test: ping spec 2024-05-13 22:59:22 +02:00
Juan Rodriguez a8e2e971d6 test: integration link spec
- spec_helper (spec-kemal setup)
- spec_helper (micreate migration)
- create_test_link + get_test_link + delete_test_link helpers
- fix test env variables
2024-05-13 22:57:13 +02:00
Juan Rodriguez 6b21bc0cd6 feat: cli create_user + list_users + delete_user 2024-05-13 22:56:06 +02:00
Juan Rodriguez a47722cd54 refactor: Link serializer - refer attr 2024-05-13 22:54:49 +02:00
Juan Rodriguez 7f2a27ec79 feat: auth middleware
- CORS headers
- Get all links that belong to user
2024-05-13 22:35:22 +02:00
Juan Rodriguez 0ad534065c feat: user migration + model
- link belongs to user
- user api_key index
2024-05-13 22:33:38 +02:00
Juan Rodriguez 80feadfbd2 feat: delete link 2024-05-13 22:30:44 +02:00
Juan Rodriguez ded84e7fa5 feat: update link 2024-05-13 10:15:08 +02:00
Juan Rodriguez 8400e5fe71 chore: bruno collection (create link) 2024-05-13 09:30:30 +02:00
Juan Rodriguez b20417d579 refactor: error handling return exception message 2024-05-13 09:22:36 +02:00
Juan Rodriguez 9564559610 refactor: rename src folder with app 2024-05-13 08:14:47 +02:00
Juan Rodriguez e4ae0c2ac4 feat: link uuid + increase counter concurrently 2024-05-13 08:08:20 +02:00
Juan Rodriguez b34a52f3e0 feat: redirect by slug 2024-05-12 23:21:14 +02:00
Juan Rodriguez c806953b9a feat: create links - route + controller + model + migration 2024-05-12 22:50:47 +02:00
Juan Rodriguez 3e86e761b6 chore: database setup crecto + micrate 2024-05-12 22:40:11 +02:00
Juan Rodriguez b7b47b133d feat: ping route + controller 2024-05-12 21:25:57 +02:00
Juan Rodriguez db42ed2b24 feat: kemal setup 2024-05-12 18:35:45 +02:00
Juan Rodriguez 9e1fcf2d48 chore: kemal + crystal setup 2024-05-12 15:08:09 +02:00
Juan Rodriguez 720b70c6a0 chore: remove rails app 2024-05-12 15:07:56 +02:00
Juan Rodriguez a77ef21d45 Update README.md 2024-01-31 08:26:53 +01:00
Juan Rodriguez 8eb27f2c8a chore: replace sjdonado.de with donado.co 2024-01-31 05:27:46 +01:00
Juan Rodriguez 7a095fb045 ci: deploy to sjdonado.de with dokku 2023-04-19 17:07:33 +02:00
Juan Rodriguez f74ec3af20 fix: validate domains, allow only google.com + sjdonado.de for demo purposes (to avoid phishing scam) 2023-04-19 17:07:05 +02:00
Juan Rodriguez cebdfb35d7 fix: parsed_url + stripped_url
- store and redirect urls without protocol
- update README
- increment counter with SQL COALESCE
- add linksHelper
- update tests
2023-03-27 09:53:09 +02:00
Juan Rodriguez af9c7b0024 feat: meta tags 2023-03-26 21:50:19 +02:00
Juan Rodriguez 211e4f40f4 refactor: improve url regex 2023-03-26 19:37:29 +02:00
Juan Rodriguez fee04cc26d chore: remove heroku from Github actions 2023-03-26 19:03:04 +02:00
Juan Rodriguez 63acab3cf7 Dokku deploy setup (#1)
* fix: remove config.key from Dockerfile

* refactor: remove redis

* chore: update README

* feat: precompile assets on build Dockerfile
2023-03-26 19:00:09 +02:00
Juan Rodriguez 38ce72618a fix: 🐛 save sessionUsername in localStorage 2021-06-16 10:24:33 -05:00
Juan Rodriguez e3ab670eac ci: 💚 enable rubocop-rails support 2021-06-15 15:39:44 -05:00
Juan Rodriguez 73b674b613 refactor: Username and slug unique indexes, before actions for set link and user
confirm_password_validation around action, unnecessary helpers removed, rubocop-rails suggestions applied

Add username index and unique slug index migrations
2021-06-15 15:24:34 -05:00
Juan Rodriguez 9204abf2e3 refactor: 🔥 Generate slug with attempts
Link short method removed, generate_slug test added, deleted unused files
2021-06-15 12:08:01 -05:00
Juan Rodriguez 391c62e99a ci: 💚 Rubocop step added to main workflow 2021-06-15 10:48:29 -05:00
Juan Rodriguez e9e7c22bfc refactor: 📱 Responsive modals and meta tags 2021-06-15 06:51:19 -05:00
Juan Rodriguez 06dfd59753 fix: 💚 Enable public file server in production 2021-06-14 19:38:18 -05:00
Juan Rodriguez d404cbf3b8 ci: 💚 Create master.key, Dockerfile precompile assets, uglifier harmony true 2021-06-14 18:50:51 -05:00
Juan Rodriguez 33eb56f686 ci: 👷 Github actions setup, heroku deployment
Redis cache store, Production Dockerfile, Github actions workflow
2021-06-14 17:14:48 -05:00
Juan Rodriguez 54bff064d1 feat: Fecth link click_counter on come back to site
LinksController counter route, tests updated, render link created_by field, order user links by desc
2021-06-14 17:03:11 -05:00
Juan Rodriguez d134be737a test: Tests updated with branch coverage
SimpleCov consolse formatter
2021-06-14 15:12:47 -05:00
Juan Rodriguez 3feaa5d88f refactor: Modal layout and turbolinks optimization
Reload with turbolinks, error messages, confirm password validation

Login and Signup modals
2021-06-14 15:10:56 -05:00
Juan Rodriguez 9c7146820c feat: Sessions controller
Sessions helper methods, login and signup modals, load partial views with stimulus

Situmuls usersController, Create and Destroy user sessions
2021-06-14 11:46:25 -05:00
Juan Rodriguez f63be42b4c feat: Create users
Users migration, model, controller. user_id to links. signup view
2021-06-14 08:24:34 -05:00
Juan Rodriguez 3e8bdee17a feat: Generate short url view
stimulus links controller, tailwindcss setup, links controller post route, tests updated

Generate short links view
2021-06-14 00:41:53 -05:00
Juan Rodriguez e50da9b3e2 chore: 🔧 docker entrypoint for init db
docker-compose.test removed, create testing and dev databases on init db
2021-06-13 20:36:39 -05:00
Juan Rodriguez d72ad2d43b feat: Links controller
Show method, Home page, redirection validation

LinksController show method
2021-06-13 20:34:41 -05:00
Juan Rodriguez 7e81f47473 build: 🔧 Stimulus setup
webpack service added to docker-compose, rails webpacker config, application js and css tags updated

rails webpacker support
2021-06-13 16:28:45 -05:00
Juan Rodriguez d27fb94095 test: Link unit test finished
100% coverage
2021-06-13 12:28:37 -05:00
Juan Rodriguez 38d0aff7f8 build: 🔧 default url_options host added
simplecov added, docker-compose updated, test_helper updated
2021-06-13 12:27:23 -05:00
Juan Rodriguez 587f9552c8 feat: Link model
create_links and slug index migrations. generate_slug and shorten model methods

- Create links migration - Add slug index migration
2021-06-13 10:55:05 -05:00
Juan Rodriguez 441bf0919d chore: 🔧 Rubocop config file 2021-06-13 10:53:34 -05:00
Juan Rodriguez a9d3cbe544 refactor: ♻️ Rubocop auto-correct
frozen_string_literal and single quote strings
2021-06-13 10:46:52 -05:00
Juan Rodriguez 019e2516d7 feat: 🏗️ Rails setup
postgresql database
2021-06-13 09:05:20 -05:00
Juan Rodriguez a17a42e792 build: 🔨 Docker setup
Dockerfile, docker-compose and entrypoint
2021-06-13 09:01:22 -05:00
Juan Rodriguez d02df35d86 feat: 🔧 Initial commit
vscode config and TODO
2021-06-13 08:01:12 -05:00
62 changed files with 8425 additions and 565 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
/bin/
/.shards/
/spec/
/sqlite/
+9
View File
@@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL=sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
APP_URL=http://localhost:4000
+3
View File
@@ -0,0 +1,3 @@
PORT=4001
DATABASE_URL=sqlite3://./sqlite/data.test.db?journal_mode=wal&synchronous=normal&foreign_keys=true
APP_URL=http://localhost:4001
+84
View File
@@ -0,0 +1,84 @@
name: Publish Docker images
on:
push:
branches:
- master
release:
types: [published]
jobs:
build-platforms:
name: Build Platforms
runs-on: ubuntu-latest
strategy:
matrix:
platform: [linux/amd64, linux/arm64]
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: 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
tags: |
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:
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}
+21
View File
@@ -0,0 +1,21 @@
name: Run tests
on:
pull_request:
branches: [master]
env:
ENV: test
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Download source
uses: actions/checkout@v3
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec
+10
View File
@@ -0,0 +1,10 @@
/lib/
/bin/
/.shards/
*.dwarf
.DS_Store
/sqlite/
.env.production
resource_usage.txt
+132
View File
@@ -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
+75
View File
@@ -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).
+47
View File
@@ -0,0 +1,47 @@
FROM debian:bookworm-slim AS build
ARG TARGETARCH
ENV ENV=production
WORKDIR /usr/src/app
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/*
COPY . .
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
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 ["bit"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Juan Rodriguez <sjdonado@icloud.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/r/sjdonado/bit)
[![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/r/sjdonado/bit)
[![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/r/sjdonado/bit)
Lightweight URL shortener service with minimal resource requirements. Average memory consumption is **20MB RAM** with container disk space under **50MB**.
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).
Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
## 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.
- 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).
## Minimum Requirements
- 50MB disk space
- 50MB RAM (20MB avg usage)
- x86_64 or ARM64 architecture
## Documentation
- [API Reference](docs/API.md)
- [Advanced Setup](docs/SETUP.md)
## Contributing
Found an issue or have a suggestion? Please follow our [contribution guidelines](CONTRIBUTING.md).
+15
View File
@@ -0,0 +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 %}
+7
View File
@@ -0,0 +1,7 @@
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
+187
View File
@@ -0,0 +1,187 @@
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 = 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
class Index < App::Lib::BaseController
include App::Models
include App::Lib
def call(env)
slug = env.params.url["slug"]
link = Database.get_by(Link, slug: slug)
raise App::NotFoundException.new(env) if !link
remote_address = env.request.remote_address.try &.to_s
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
env.response.status_code = 301
env.response.headers["Location"] = link.url!
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
class All < App::Lib::BaseController
include App::Models
include App::Lib
def call(env)
user = env.get("user").as(User)
query = Database::Query.where(user_id: user.id.as(String))
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"])
query = Database::Query.where(id: id).limit(1)
link = Database.all(Link, query, preload: [:clicks]).first?
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 = new_url
link.slug = SlugService.shorten_url(new_url, user.id.to_s)
changeset = Database.update(link)
if !changeset.valid?
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
end
response = {"data" => App::Serializers::Link.new(link)}
response.to_json
end
end
class Delete < App::Lib::BaseController
include App::Models
include App::Lib
def call(env)
user = env.get("user").as(User)
id = env.params.url["id"]
link = Database.get(Link, id)
raise App::NotFoundException.new(env) if !link
if link.user_id != user.id
raise App::ForbiddenException.new(env)
end
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id) # tempfix: Database.delete does not work
if result.rows_affected == 0
raise App::UnprocessableEntityException.new(env, { "id" => ["Row delete failed"] })
end
env.response.status_code = 204
end
end
end
+10
View File
@@ -0,0 +1,10 @@
require "../lib/controller.cr"
module App::Controllers::Ping
class Get < App::Lib::BaseController
def call(env)
response = {"data" => "pong"}
response.to_json
end
end
end
+29
View File
@@ -0,0 +1,29 @@
module App::Lib
abstract class BaseController
def map_changeset_errors(errors)
errors.reduce({} of String => Array(String)) do |memo, error|
memo[error[:field]] = memo[error[:field]]? || [] of String
memo[error[:field]] << error[:message]
memo
end
end
def parse_body(env, fields)
json_params = env.params.json.to_h
missing_fields = [] of String
fields.each do |field|
unless json_params.has_key?(field)
missing_fields << field
end
end
unless missing_fields.empty?
error_message = missing_fields.map { |field| "#{field}: Required field" }.join(", ")
raise App::BadRequestException.new(env, error_message)
end
json_params
end
end
end
+26
View File
@@ -0,0 +1,26 @@
require "sqlite3"
require "crecto"
require"micrate"
module App::Lib
class Database
extend Crecto::Repo
Query = Crecto::Repo::Query
config do |conf|
conf.uri = ENV["DATABASE_URL"]
end
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
+66
View File
@@ -0,0 +1,66 @@
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)
end
end
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)
end
end
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
+54
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
module App::Middlewares
class Auth < Kemal::Handler
include App::Models
include App::Lib
exclude ["/api/ping", "/:slug"]
def call(env)
return call_next(env) if exclude_match?(env)
begin
user = Database.get_by!(User, api_key: env.request.headers["X-Api-Key"])
env.set "user", user
rescue exception
raise App::UnauthorizedException.new(env)
end
call_next(env)
end
end
end
+18
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
require "sqlite3"
require "crecto"
require "./user.cr"
module App::Models
class Link < Crecto::Model
schema :links do
field :id, String, primary_key: true
field :slug, String
field :url, String
belongs_to :user, User
has_many :clicks, Click
end
unique_constraint :slug
validate_required [:slug, :url]
validate_format :url, /\A(?:(https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,})(?::\d+)?(?:[\/?#]\S*)?\z/i
end
end
+16
View File
@@ -0,0 +1,16 @@
require "sqlite3"
require "crecto"
module App::Models
class User < Crecto::Model
schema :users do
field :id, String, primary_key: true
field :name, String
field :api_key, String
end
validate_required [:name, :api_key]
has_many :links, Link, dependent: :destroy
end
end
+41
View File
@@ -0,0 +1,41 @@
require "./controllers/**"
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, Origin, X-Api-Key"
end
after_all do |env|
env.response.content_type = "application/json"
end
get "/api/ping" do |env|
Controllers::Ping::Get.new.call(env)
end
get "/:slug" do |env|
Controllers::Link::Index.new.call(env)
end
get "/api/links" do |env|
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
put "/api/links/:id" do |env|
Controllers::Link::Update.new.call(env)
end
delete "/api/links/:id" do |env|
Controllers::Link::Delete.new.call(env)
end
end
+22
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
require "json"
require "../models/link"
require "./click"
module App::Serializers
class Link
getter refer
def initialize(@link : App::Models::Link)
@refer = "#{ENV["APP_URL"]}/#{@link.slug}"
end
def to_json(builder : JSON::Builder)
builder.object do
builder.field("id", @link.id)
builder.field("refer", @refer)
builder.field("origin", @link.url)
builder.field("clicks", @link.clicks.map { |click| App::Serializers::Click.new(click) })
end
end
end
end
+141
View File
@@ -0,0 +1,141 @@
require "file_utils"
require "http/client"
require "../config/*"
require "../lib/*"
require "../models/*"
module App::Services::Cli
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 = api_key || Random::Secure.urlsafe_base64()
changeset = App::Lib::Database.insert(user)
return changeset.errors if !changeset.valid?
"New user created: Name: #{user.name}, X-Api-Key: #{user.api_key}"
end
def self.list_users
users = App::Lib::Database.all(App::Models::User)
return "No users found " if users.empty?
output = "Users:\n"
users.each do |user|
output += "ID: #{user.id}, Name: #{user.name}, X-Api-Key: #{user.api_key}\n"
end
output
end
def self.delete_user(user_id)
result = App::Lib::Database.raw_exec("DELETE FROM users WHERE id = (?)", user_id) # tempfix: Database.delete does not work
return "Failed to delete user: #{result}" if result.rows_affected == 0
"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
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module App
VERSION = "0.1.0"
end
Executable
+156
View File
@@ -0,0 +1,156 @@
#!/bin/bash
# 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"
check_dependencies() {
if ! command -v bombardier &> /dev/null; then
echo "Error: bombardier is not installed. Please install it to proceed."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed. Please install it to proceed."
exit 1
fi
}
setup_containers() {
echo "Setting up..."
docker compose up -d
if [ $? -ne 0 ]; then
echo "Failed to start Docker containers."
exit 1
fi
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"
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
}
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 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++))
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)
fi
echo "**** Results ****"
echo "Average CPU Usage: $avg_cpu%"
echo "Average Memory Usage: $avg_mem MiB"
}
cleanup() {
rm -f resource_usage.csv
docker compose down
}
main() {
check_dependencies
setup_containers
monitor_resource_usage & # Start monitoring in the background
monitor_pid=$!
trap 'kill $monitor_pid; cleanup; exit' INT
create_links
run_benchmark
kill $monitor_pid
analyze_resource_usage
cleanup
}
main
+17
View File
@@ -0,0 +1,17 @@
require "kemal"
require "./app/config/*"
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)
App::Services::Cli.setup_admin_user
Kemal.run
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE links (
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
);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE links;
@@ -0,0 +1,7 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE INDEX IF NOT EXISTS index_links_slug ON links (slug);
-- +micrate Down
-- SQL in section 'Down' is executed when this migration is rolled back
DROP INDEX IF EXISTS index_links_slug;
@@ -0,0 +1,13 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
name VARCHAR(100) NOT NULL,
api_key VARCHAR(64) UNIQUE NOT NULL,
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE users;
@@ -0,0 +1,7 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE INDEX IF NOT EXISTS index_users_api_key ON users (api_key);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP INDEX IF EXISTS index_users_api_key;
@@ -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;
+15
View File
@@ -0,0 +1,15 @@
services:
app:
container_name: bit
build: .
environment:
ENV: production
ADMIN_NAME: 'Tester'
ADMIN_API_KEY: '0p+mDvbpZGLPGVCXnV+EDduR9Blkv27Dhq9XSzSbdQY='
ports:
- 4000:4000
volumes:
- sqlite_data:/app/sqlite
volumes:
sqlite_data:
+125
View File
@@ -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
View File
@@ -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
```
+11
View File
@@ -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"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

-51
View File
@@ -1,51 +0,0 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
configUrl: "swagger-config.json",
dom_id: '#swagger-ui'
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>
-503
View File
@@ -1,503 +0,0 @@
openapi: 3.0.3
info:
title: Bit - URL Shortener API
description: |
Fast, lightweight, self-hosted URL shortener service with minimal click tracking.
## Getting Started
For setup instructions, please check the [README](https://github.com/sjdonado/bit/blob/master/README.md).
## Authentication
Multiple users are supported via `X-Api-Key` headers. Create, list and delete keys via the [CLI](https://github.com/sjdonado/bit/blob/master/SETUP.md#cli).
version: 1.6.0
contact:
name: sjdonado
url: https://sjdonado.com
servers:
- url: http://localhost:4000
description: Development server
security:
- ApiKeyAuth: []
paths:
/api/ping:
get:
summary: Ping the API
description: Health check endpoint to verify the API is running
operationId: ping
tags:
- Health
security: []
responses:
'200':
description: API is healthy
content:
application/json:
schema:
type: object
properties:
data:
type: string
example: pong
/{slug}:
get:
summary: Redirect by slug
description: Redirects to the original URL and tracks the click asynchronously
operationId: redirectBySlug
tags:
- Redirects
security: []
parameters:
- name: slug
in: path
required: true
description: The short URL slug
schema:
type: string
example: 3wP4BQ
- name: utm_source
in: query
required: false
description: UTM source parameter for tracking
schema:
type: string
example: email_campaign
responses:
'301':
description: Redirect to original URL
headers:
Location:
description: The original URL
schema:
type: string
example: https://example.com
X-Forwarded-For:
description: Client IP address
schema:
type: string
User-Agent:
description: User agent string
schema:
type: string
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/links:
get:
summary: List all links
description: Retrieve all links for the authenticated user with pagination support
operationId: listLinks
tags:
- Links
parameters:
- name: limit
in: query
description: Number of results per page
schema:
type: integer
default: 100
minimum: 1
maximum: 1000
- name: cursor
in: query
description: Pagination cursor from previous response
schema:
type: string
responses:
'200':
description: List of links
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/LinkSummary'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
summary: Create new link
description: Create a new shortened link
operationId: createLink
tags:
- Links
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- url
properties:
url:
type: string
format: uri
description: The URL to shorten
example: https://example.com
responses:
'201':
description: Link created successfully
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Link'
'400':
description: Bad request - invalid URL or missing field
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
missingField:
value:
error: "url: Required field"
invalidUrl:
value:
errors:
url:
- is invalid
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/links/{id}:
get:
summary: Get link by ID
description: Retrieve a specific link with up to 100 most recent clicks. For complete click history, use /api/links/{id}/clicks
operationId: getLink
tags:
- Links
parameters:
- name: id
in: path
required: true
description: Link ID
schema:
type: integer
format: int64
responses:
'200':
description: Link details
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Link'
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
put:
summary: Update link
description: Update the URL of an existing link
operationId: updateLink
tags:
- Links
parameters:
- name: id
in: path
required: true
description: Link ID
schema:
type: integer
format: int64
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- url
properties:
url:
type: string
format: uri
description: The new URL
example: https://newexample.com
responses:
'200':
description: Link updated successfully
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/Link'
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Forbidden - link belongs to another user
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
summary: Delete link
description: Delete a link and all its associated clicks
operationId: deleteLink
tags:
- Links
parameters:
- name: id
in: path
required: true
description: Link ID
schema:
type: integer
format: int64
responses:
'204':
description: Link deleted successfully
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Forbidden - link belongs to another user
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/links/{id}/clicks:
get:
summary: List clicks for a link
description: Retrieve all clicks for a specific link with pagination support
operationId: listClicks
tags:
- Clicks
parameters:
- name: id
in: path
required: true
description: Link ID
schema:
type: integer
format: int64
- name: limit
in: query
description: Number of results per page
schema:
type: integer
default: 100
minimum: 1
maximum: 1000
- name: cursor
in: query
description: Pagination cursor from previous response
schema:
type: string
responses:
'200':
description: List of clicks
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Click'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Link not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-Api-Key
description: API key for authentication
schemas:
LinkSummary:
type: object
properties:
id:
type: integer
format: int64
description: Unique link identifier
example: 1
refer:
type: string
format: uri
description: The shortened URL
example: http://localhost:4000/3wP4BQ
origin:
type: string
format: uri
description: The original URL
example: https://monocuco.donado.co
Link:
allOf:
- $ref: '#/components/schemas/LinkSummary'
- type: object
properties:
clicks:
type: array
description: Array of click records (up to 100 most recent)
items:
$ref: '#/components/schemas/Click'
Click:
type: object
properties:
id:
type: integer
format: int64
description: Unique click identifier
example: 1
user_agent:
type: string
description: User agent string
example: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0
country:
type: string
description: Country code (ISO 3166-1 alpha-2)
example: US
nullable: true
browser:
type: string
description: Browser name
example: Firefox
nullable: true
os:
type: string
description: Operating system
example: Mac OS X
nullable: true
referer:
type: string
description: Referer domain or utm_source
example: Direct
nullable: true
created_at:
type: string
format: date-time
description: Click timestamp
example: 2024-07-12T19:25:22Z
Pagination:
type: object
properties:
has_more:
type: boolean
description: Whether there are more results
example: true
next:
type: integer
format: int64
description: Cursor for next page (link/click ID)
example: 12
nullable: true
Error:
type: object
properties:
error:
type: string
description: Error message
example: Resource not found
required:
- error
ValidationErrors:
type: object
properties:
errors:
type: object
additionalProperties:
type: array
items:
type: string
description: Field-level validation errors
example:
url:
- is invalid
tags:
- name: Health
description: Health check endpoints
- name: Redirects
description: URL redirection and click tracking
- name: Links
description: Link management operations
- name: Clicks
description: Click analytics and tracking
+38
View File
@@ -0,0 +1,38 @@
require "uuid"
require "option_parser"
require "../app/services/cli"
OptionParser.parse do |parser|
parser.on("--create-user=NAME", "Create a new user with the given name") do |name|
puts App::Services::Cli.create_user(name)
exit
end
parser.on("--list-users", "List all users") do
puts App::Services::Cli.list_users
exit
end
parser.on("--delete-user=USER_ID", "Delete a user by ID") do |user_id|
puts App::Services::Cli.delete_user(user_id)
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
+62
View File
@@ -0,0 +1,62 @@
version: 2.0
shards:
backtracer:
git: https://github.com/sija/backtracer.cr.git
version: 1.2.2
crecto:
git: https://github.com/fridgerator/crecto.git
version: 0.12.1
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.11.0
dotenv:
git: https://github.com/gdotdesign/cr-dotenv.git
version: 1.0.0
exception_page:
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
mysql:
git: https://github.com/crystal-lang/crystal-mysql.git
version: 0.14.0
pg:
git: https://github.com/will/crystal-pg.git
version: 0.26.0
radix:
git: https://github.com/luislavena/radix.git
version: 0.4.1
spec-kemal:
git: https://github.com/kemalcr/spec-kemal.git
version: 1.0.0
sqlite3:
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
+36
View File
@@ -0,0 +1,36 @@
name: bit
version: 1.5.0
authors:
- Juan Rodriguez <sjdonado@icloud.com>
targets:
bit:
main: bit.cr
cli:
main: scripts/cli.cr
dependencies:
kemal:
github: kemalcr/kemal
sqlite3:
github: crystal-lang/crystal-sqlite3
crecto:
github: fridgerator/crecto
micrate:
github: amberframework/micrate
version: 0.15.1
user_agent_parser:
github: busyloop/user_agent_parser
maxminddb:
github: delef/maxminddb.cr
development_dependencies:
dotenv:
github: gdotdesign/cr-dotenv
spec-kemal:
github: kemalcr/spec-kemal
crystal: ">= 1.12.1"
license: MIT
+316
View File
@@ -0,0 +1,316 @@
require "../spec_helper"
require "../../app/models/*"
API_KEY = Random::Secure.urlsafe_base64
describe "App::Controllers::Link" do
describe "Create" do
it "should create link" do
test_user = create_test_user()
payload = {"url" => "https://kagi.com"}
post(
"/api/links",
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 | 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()
payload = {"test" => "https://kagi.com"}
post(
"/api/links",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
body: payload.to_json
)
expected = {"error" => "url: Required field"}.to_json
response.body.should eq(expected)
end
it "should return 400 - invalid url" do
test_user = create_test_user()
payload = {"url" => "test"}
post(
"/api/links",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
body: payload.to_json
)
expected = {"errors" => {"url" => ["is invalid"]}}.to_json
response.body.should eq(expected)
end
it "should return 401 - missing api key" do
payload = {"url" => "https://kagi.com"}
post("/api/links", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: payload.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 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)
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 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)
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.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
test_user = create_test_user()
get("https://localhost:4001/R4kj2")
expected = {"error" => "Resource not found"}.to_json
response.status_code.should eq(404)
response.body.should eq(expected)
end
end
describe "All" do
it "should return all links" do
links = ["https://google.com", "google.com", "google.com.co"]
test_user = create_test_user()
links.each do |link|
create_test_link(test_user, link)
end
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
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.de", "google.de", "google.edu.co", "x.com"]
test_user = create_test_user()
links[0..2].each do |link|
create_test_link(test_user, link)
end
test_other_user = create_test_user()
create_test_link(test_other_user, links[3])
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
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])
parsed_response["data"][2]["origin"].should eq(links[2])
end
it "should return 401 - missing api key" do
get "/api/links"
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
end
describe "Update" do
it "should update link url" do
link = "https://github.com"
test_user = create_test_user()
test_link = create_test_link(test_user, link)
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 | Array(Hash(String, String)))).from_json(response.body)
parsed_response["data"]["origin"].should eq(payload["url"])
end
it "should return 404 - link does not exist" do
test_user = create_test_user()
payload = {"url" => "https://kagi.com.co"}
put(
"/api/links/1",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
body: payload.to_json
)
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
payload = {"url" => "https://kagi.com.co"}
put(
"/api/links/1",
headers: HTTP::Headers{"Content-Type" => "application/json"},
body: payload.to_json
)
expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401)
response.body.should eq(expected)
end
end
describe "Delete" do
it "should delete link url" do
link = "https://news.ycombinator.com"
test_user = create_test_user()
test_link = create_test_link(test_user, link)
delete("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
response.status_code.should eq(204)
end
it "should return 404 - link does not exist" do
test_user = create_test_user()
delete("/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
delete "/api/links/1"
expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401)
response.body.should eq(expected)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
require "../spec_helper"
describe "App::Controllers::Ping" do
it "should return pong" do
get "/api/ping"
expected = {"pong" => "ok"}.to_json
response.body.should eq(expected)
end
end
+62
View File
@@ -0,0 +1,62 @@
require "../spec_helper"
require "../../app/services/cli"
describe "App::Services::Cli" do
it "creates a new user" do
name = "testuser"
output = App::Services::Cli.create_user(name)
output.should contain "New user created: Name: testuser"
end
it "lists all users" do
App::Services::Cli.create_user("user1")
App::Services::Cli.create_user("user2")
output = App::Services::Cli.list_users
output.should contain "Users:"
output.should contain "Name: user1"
output.should contain "Name: user2"
end
it "deletes a user by ID" do
App::Services::Cli.create_user("user_to_delete")
user = App::Lib::Database.all(App::Models::User).first
output = App::Services::Cli.delete_user(user.id)
output.should contain "User with ID #{user.id} deleted successfully"
end
it "handles deletion of non-existent user" do
output = App::Services::Cli.delete_user("non-existent-id")
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
+69
View File
@@ -0,0 +1,69 @@
require "uuid"
require "file_utils"
require "spec-kemal"
require "micrate"
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
Kemal.config.logging = false
end
def create_test_user
user = App::Models::User.new
user.id = UUID.v4.to_s
user.name = "Tester"
user.api_key = Random::Secure.urlsafe_base64()
changeset = App::Lib::Database.insert(user)
if !changeset.valid?
error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
raise "Test user creation failed #{error_messages}"
end
user
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.user = user
changeset = App::Lib::Database.insert(link)
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)
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)
App::Lib::Database.raw_exec("DELETE FROM links WHERE id = (?)", link_id) # tempfix: Database.delete does not work
end
View File
-1
View File
@@ -1 +0,0 @@
{"url":"openapi.yaml","deepLinking":true}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-4
View File
File diff suppressed because one or more lines are too long