Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a68259a0f4 | |||
| 136e4d44c9 | |||
| 660d536618 | |||
| e1d3ec480d | |||
| 0180f36a62 | |||
| 4500c89904 | |||
| 3df4642c90 | |||
| e67ed7165b | |||
| 4ae6ef39d5 | |||
| f2b63c00a3 | |||
| 6a151301b8 | |||
| d1be283318 | |||
| bf717dc38f | |||
| 73ee4c4479 | |||
| 38f9cfd48e | |||
| e14fc266bb | |||
| 917a79c536 | |||
| 2c951fd834 | |||
| 3983102caa | |||
| fba2039efc | |||
| b22381cb7f | |||
| eb0db67358 | |||
| 1f41d13667 | |||
| 222e408a16 | |||
| 67c27d3056 | |||
| 001caffba6 | |||
| 006d99a9e7 | |||
| 6bd0d195bf | |||
| 68e00e7c85 | |||
| 4aefd3ff06 | |||
| bbc900cd05 |
@@ -3,3 +3,7 @@
|
|||||||
/.shards/
|
/.shards/
|
||||||
/spec/
|
/spec/
|
||||||
/sqlite/
|
/sqlite/
|
||||||
|
|
||||||
|
/docs/
|
||||||
|
benchmark.cr
|
||||||
|
.env*
|
||||||
|
|||||||
+1
-1
@@ -7,4 +7,4 @@
|
|||||||
/sqlite/
|
/sqlite/
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
resource_usage.txt
|
resource_usage.*
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ WORKDIR /usr/src/app
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
libyaml-0-2 \
|
libyaml-0-2 \
|
||||||
libsqlite3-0 \
|
sqlite3 \
|
||||||
libevent-2.1-7 \
|
libevent-2.1-7 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,29 @@
|
|||||||
[](https://hub.docker.com/r/sjdonado/bit)
|
[](https://hub.docker.com/r/sjdonado/bit)
|
||||||
[](https://hub.docker.com/r/sjdonado/bit)
|
[](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**.
|
Lightweight URL shortener (API-only) with minimal resource requirements. Avg memory consumption under pressure is around **60MiB**, CPU single core consumption 60%.
|
||||||
|
|
||||||
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).
|
Highly performant: 6K+ reqs/sec, latency 20ms (100000 requests using 125 connections, [benchmark](docs/SETUP.md#benchmark)).
|
||||||
|
|
||||||
|
Self-hosted: [Dokku](docs/SETUP.md#dokku), [Docker Compose](docs/SETUP.md#docker-compose).
|
||||||
|
|
||||||
Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
|
Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
|
||||||
|
|
||||||
## Why Bit?
|
## 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.
|
It is feature-complete by design: simple and reliable 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.
|
- 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 `User-Agent` headers, enabling advanced tracking and integration capabilities when needed.
|
- Provides standard `X-Forwarded-For` header support to enable extended capabilities.
|
||||||
- Multiple users are supported via API key authentication. Create, list and delete via the [CLI](docs/SETUP.md#cli).
|
- Multiple users are supported via API key authentication. Users can create, list and delete keys via the [CLI](docs/SETUP.md#cli).
|
||||||
|
|
||||||
## Minimum Requirements
|
## Minimum Requirements
|
||||||
- 50MB disk space
|
- 100MB disk space
|
||||||
- 50MB RAM (20MB avg usage)
|
- 70MiB RAM
|
||||||
- x86_64 or ARM64 architecture
|
- x86_64 or ARM64
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- [API Reference](docs/API.md)
|
- [API Reference](docs/API.md)
|
||||||
- [Advanced Setup](docs/SETUP.md)
|
- [Setup](docs/SETUP.md)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Found an issue or have a suggestion? Please follow our [contribution guidelines](CONTRIBUTING.md).
|
Found an issue or have a suggestion? Please follow our [contribution guidelines](CONTRIBUTING.md).
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
module App::Controllers
|
||||||
|
struct ClickController
|
||||||
|
include App::Models
|
||||||
|
include App::Lib
|
||||||
|
include App::Services
|
||||||
|
|
||||||
|
# Buffered channel to hold click data
|
||||||
|
@@click_channel = Channel(NamedTuple(
|
||||||
|
link_id: Int64,
|
||||||
|
remote_address: String,
|
||||||
|
user_agent: String?,
|
||||||
|
referer: String
|
||||||
|
)).new(1024)
|
||||||
|
|
||||||
|
@@processor_started = begin
|
||||||
|
spawn do
|
||||||
|
batch_size = 64
|
||||||
|
batch = [] of NamedTuple(
|
||||||
|
link_id: Int64,
|
||||||
|
remote_address: String,
|
||||||
|
user_agent: String?,
|
||||||
|
referer: String
|
||||||
|
)
|
||||||
|
|
||||||
|
loop do
|
||||||
|
select
|
||||||
|
when click_data = @@click_channel.receive
|
||||||
|
batch << click_data
|
||||||
|
|
||||||
|
# Collect clicks until we have a batch or a timeout
|
||||||
|
if batch.size >= batch_size
|
||||||
|
process_click_batch(batch)
|
||||||
|
batch.clear
|
||||||
|
end
|
||||||
|
when timeout(0.5.seconds)
|
||||||
|
# Process whatever we have after timeout
|
||||||
|
unless batch.empty?
|
||||||
|
process_click_batch(batch)
|
||||||
|
batch.clear
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.process_click_batch(batch)
|
||||||
|
clicks = [] of App::Models::Click
|
||||||
|
|
||||||
|
batch.each do |click_data|
|
||||||
|
begin
|
||||||
|
client_ip = IpLookup.ip_from_address(click_data[:remote_address])
|
||||||
|
family, _, _, os = UserAgent.parse(click_data[:user_agent] || "")
|
||||||
|
|
||||||
|
click = App::Models::Click.new
|
||||||
|
click.link_id = click_data[:link_id]
|
||||||
|
click.country = client_ip ? IpLookup.country(client_ip) : nil
|
||||||
|
click.user_agent = click_data[:user_agent]
|
||||||
|
click.browser = family
|
||||||
|
click.os = os.try &.[0] # OS family
|
||||||
|
click.referer = click_data[:referer]
|
||||||
|
|
||||||
|
clicks << click
|
||||||
|
rescue ex
|
||||||
|
Log.error { "Click data processing error: #{ex.message}" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Batch insert clicks if any were successfully processed
|
||||||
|
unless clicks.empty?
|
||||||
|
begin
|
||||||
|
multi = Crecto::Multi.new
|
||||||
|
clicks.each do |click|
|
||||||
|
multi.insert(click)
|
||||||
|
end
|
||||||
|
Database.transaction(multi)
|
||||||
|
rescue ex
|
||||||
|
Log.error { "Batch click insertion error: #{ex.message}" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.redirect_handler
|
||||||
|
->(env : HTTP::Server::Context) {
|
||||||
|
link_id, url = Database.raw_query("SELECT id, url FROM links WHERE slug = (?) LIMIT 1", env.params.url["slug"]) do |result|
|
||||||
|
result.move_next ? {result.read(Int64), result.read(String)} : nil
|
||||||
|
end || raise App::NotFoundException.new(env)
|
||||||
|
|
||||||
|
remote_address = env.request.headers["Cf-Connecting-Ip"]? || env.request.remote_address.to_s
|
||||||
|
|
||||||
|
env.response.status_code = 301
|
||||||
|
env.response.headers.add("Location", url)
|
||||||
|
env.response.headers.add("X-Forwarded-For", remote_address)
|
||||||
|
|
||||||
|
begin
|
||||||
|
@@click_channel.send({
|
||||||
|
link_id: link_id,
|
||||||
|
remote_address: remote_address,
|
||||||
|
user_agent: env.request.headers["User-Agent"]?,
|
||||||
|
referer: env.request.headers["Referer"]?.try { |r| URI.parse(r).host rescue r } || env.params.query["utm_source"]? || "Direct"
|
||||||
|
})
|
||||||
|
rescue Channel::ClosedError
|
||||||
|
Log.error { "Click channel closed" }
|
||||||
|
rescue ex
|
||||||
|
Log.error { "Error queuing click: #{ex.message}" }
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+87
-184
@@ -1,250 +1,153 @@
|
|||||||
require "uuid"
|
module App::Controllers
|
||||||
require "user_agent_parser"
|
class LinkController < App::Lib::BaseController
|
||||||
|
|
||||||
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::Models
|
||||||
include App::Lib
|
include App::Lib
|
||||||
include App::Services
|
include App::Services
|
||||||
|
|
||||||
def call(env)
|
def initialize(@env : HTTP::Server::Context)
|
||||||
user = env.get("user").as(User)
|
super(@env)
|
||||||
body = parse_body(env, ["url"])
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
body = parse_body(["url"])
|
||||||
url = body["url"].to_s
|
url = body["url"].to_s
|
||||||
|
|
||||||
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
|
query = Database::Query.where(url: url, user_id: current_user_id).limit(1)
|
||||||
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
||||||
if existing_link
|
if existing_link
|
||||||
response = {"data" => App::Serializers::Link.new(existing_link)}
|
return render_json({"data" => App::Serializers::Link.new(existing_link)})
|
||||||
return response.to_json
|
|
||||||
end
|
end
|
||||||
|
|
||||||
link = Link.new
|
link = Link.new
|
||||||
link.id = UUID.v4.to_s
|
|
||||||
link.url = url
|
link.url = url
|
||||||
link.user = user
|
link.user_id = current_user_id
|
||||||
link.slug = SlugService.shorten_url(url, user.id.to_s)
|
link.slug = SlugService.shorten_url(url, current_user_id)
|
||||||
|
|
||||||
changeset = Database.insert(link)
|
changeset = Database.insert(link)
|
||||||
if !changeset.valid?
|
if !changeset.valid?
|
||||||
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
||||||
end
|
end
|
||||||
|
|
||||||
link.clicks = [] of App::Models::Click
|
inserted_link = Database.get!(Link, changeset.instance.id)
|
||||||
response = {"data" => App::Serializers::Link.new(link)}
|
|
||||||
|
|
||||||
response.to_json
|
render_json({"data" => App::Serializers::Link.new(inserted_link)}, 201)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
class Index < App::Lib::BaseController
|
def list_all
|
||||||
include App::Models
|
limit, cursor = pagination_params
|
||||||
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.headers["Cf-Connecting-Ip"]?.try(&.presence) || 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["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)
|
|
||||||
|
|
||||||
limit = (env.params.query["limit"]? || "100").to_i
|
|
||||||
cursor = env.params.query["cursor"]?
|
|
||||||
|
|
||||||
query = Database::Query.where(user_id: user.id.as(String))
|
|
||||||
if cursor
|
|
||||||
query = query.where("id < ?", cursor)
|
|
||||||
end
|
|
||||||
|
|
||||||
|
query = Database::Query.where(user_id: current_user_id)
|
||||||
|
query = query.where("id < ?", cursor) if cursor
|
||||||
query = query.order_by("id DESC").limit(limit + 1)
|
query = query.order_by("id DESC").limit(limit + 1)
|
||||||
|
|
||||||
links = Database.all(Link, query)
|
links = Database.all(Link, query)
|
||||||
|
|
||||||
has_more = links.size > limit
|
paginated_response(links, limit) { |link| App::Serializers::Link.new(link) }
|
||||||
links = links[0...limit] if has_more
|
|
||||||
|
|
||||||
next_cursor = has_more ? links.last.id : nil
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"data" => links.map { |link| App::Serializers::Link.new(link) },
|
|
||||||
"pagination" => {
|
|
||||||
"has_more" => has_more,
|
|
||||||
"next" => next_cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response.to_json
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
class Get < App::Lib::BaseController
|
def get
|
||||||
include App::Models
|
link_id = @env.params.url["id"].to_i64
|
||||||
include App::Lib
|
|
||||||
|
|
||||||
def call(env)
|
query = Database::Query.where(id: link_id, user_id: current_user_id).limit(1)
|
||||||
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).first?
|
link = Database.all(Link, query).first?
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
|
|
||||||
clicks_query = Database::Query.where(link_id: link_id.as(String))
|
clicks_query = Database::Query.where(link_id: link_id)
|
||||||
.order_by("id DESC")
|
.order_by("id DESC")
|
||||||
.limit(100)
|
.limit(100)
|
||||||
link.clicks = Database.all(Click, clicks_query)
|
link.clicks = Database.all(Click, clicks_query)
|
||||||
|
|
||||||
response = {"data" => App::Serializers::Link.new(link)}
|
render_json({"data" => App::Serializers::Link.new(link)})
|
||||||
response.to_json
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
class Clicks < App::Lib::BaseController
|
def list_clicks
|
||||||
include App::Models
|
link_id = @env.params.url["id"].to_i64
|
||||||
include App::Lib
|
|
||||||
|
|
||||||
def call(env)
|
# Verify link exists and belongs to user
|
||||||
user = env.get("user").as(User)
|
link_query = Database::Query.where(id: link_id, user_id: current_user_id).limit(1)
|
||||||
link_id = env.params.url["id"]
|
|
||||||
|
|
||||||
link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
|
||||||
link = Database.all(Link, link_query).first?
|
link = Database.all(Link, link_query).first?
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
|
|
||||||
limit = (env.params.query["limit"]? || "100").to_i
|
limit, cursor = pagination_params
|
||||||
cursor = env.params.query["cursor"]?
|
|
||||||
|
|
||||||
query = Database::Query.where(link_id: link_id.as(String))
|
|
||||||
if cursor
|
|
||||||
query = query.where("id < ?", cursor)
|
|
||||||
end
|
|
||||||
|
|
||||||
|
query = Database::Query.where(link_id: link_id)
|
||||||
|
query = query.where("id < ?", cursor) if cursor
|
||||||
query = query.order_by("id DESC").limit(limit + 1)
|
query = query.order_by("id DESC").limit(limit + 1)
|
||||||
|
|
||||||
clicks = Database.all(Click, query)
|
clicks = Database.all(Click, query)
|
||||||
|
|
||||||
has_more = clicks.size > limit
|
paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) }
|
||||||
clicks = clicks[0...limit] if has_more
|
|
||||||
next_cursor = has_more ? clicks.last.id : nil
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"data" => clicks.map { |click| App::Serializers::Click.new(click) },
|
|
||||||
"pagination" => {
|
|
||||||
"has_more" => has_more,
|
|
||||||
"next" => next_cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response.to_json
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
class Update < App::Lib::BaseController
|
def update
|
||||||
include App::Models
|
id = @env.params.url["id"].to_i64
|
||||||
include App::Lib
|
body = parse_body(["url"])
|
||||||
include App::Services
|
new_url = body["url"].to_s
|
||||||
|
|
||||||
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)
|
query = Database::Query.where(id: id).limit(1)
|
||||||
link = Database.all(Link, query, preload: [:clicks]).first?
|
link = Database.all(Link, query, preload: [:clicks]).first?
|
||||||
|
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
raise App::ForbiddenException.new(env) if link.user_id != user.id
|
raise App::ForbiddenException.new(@env) if link.user_id != current_user_id
|
||||||
|
|
||||||
new_url = body["url"].to_s
|
# Check for existing URL
|
||||||
|
existing_query = Database::Query.where(url: new_url, user_id: current_user_id).limit(1)
|
||||||
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
|
if Database.all(Link, existing_query).first?
|
||||||
existing_link = Database.all(Link, existing_query).first?
|
raise App::UnprocessableEntityException.new(@env, { "url" => ["URL already exists"] })
|
||||||
|
|
||||||
if existing_link
|
|
||||||
raise App::UnprocessableEntityException.new(env, { "url" => ["URL already exists"] })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
link.url = new_url
|
link.url = new_url
|
||||||
link.slug = SlugService.shorten_url(new_url, user.id.to_s)
|
link.slug = SlugService.shorten_url(new_url, current_user_id)
|
||||||
|
|
||||||
changeset = Database.update(link)
|
changeset = Database.update(link)
|
||||||
if !changeset.valid?
|
if !changeset.valid?
|
||||||
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
||||||
end
|
end
|
||||||
|
|
||||||
response = {"data" => App::Serializers::Link.new(link)}
|
render_json({"data" => App::Serializers::Link.new(link)})
|
||||||
response.to_json
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
class Delete < App::Lib::BaseController
|
def delete
|
||||||
include App::Models
|
id = @env.params.url["id"].to_i64
|
||||||
include App::Lib
|
|
||||||
|
|
||||||
def call(env)
|
|
||||||
user = env.get("user").as(User)
|
|
||||||
id = env.params.url["id"]
|
|
||||||
|
|
||||||
link = Database.get(Link, id)
|
link = Database.get(Link, id)
|
||||||
raise App::NotFoundException.new(env) if !link
|
raise App::NotFoundException.new(@env) if !link
|
||||||
|
raise App::ForbiddenException.new(@env) if link.user_id != current_user_id
|
||||||
|
|
||||||
if link.user_id != user.id
|
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.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
|
if result.rows_affected == 0
|
||||||
raise App::UnprocessableEntityException.new(env, { "id" => ["Row delete failed"] })
|
raise App::UnprocessableEntityException.new(@env, { "id" => ["Row delete failed"] })
|
||||||
end
|
end
|
||||||
|
|
||||||
env.response.status_code = 204
|
@env.response.status_code = 204
|
||||||
|
end
|
||||||
|
|
||||||
|
private def current_user : User
|
||||||
|
@env.get("user").as(User)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def current_user_id : Int64
|
||||||
|
current_user.id.as(Int64)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def pagination_params
|
||||||
|
limit = (@env.params.query["limit"]? || "100").to_i32
|
||||||
|
cursor = @env.params.query["cursor"]?
|
||||||
|
{limit, cursor}
|
||||||
|
end
|
||||||
|
|
||||||
|
private def paginated_response(items, limit)
|
||||||
|
has_more = items.size > limit
|
||||||
|
items = items[0...limit] if has_more
|
||||||
|
next_cursor = has_more ? items.last.id : nil
|
||||||
|
|
||||||
|
render_json({
|
||||||
|
"data" => items.map { |item| yield item },
|
||||||
|
"pagination" => {
|
||||||
|
"has_more" => has_more,
|
||||||
|
"next_cursor" => next_cursor
|
||||||
|
}
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
require "../lib/controller.cr"
|
require "../lib/controller.cr"
|
||||||
|
|
||||||
module App::Controllers::Ping
|
module App::Controllers
|
||||||
class Get < App::Lib::BaseController
|
class PingController < App::Lib::BaseController
|
||||||
def call(env)
|
def initialize(@env : HTTP::Server::Context)
|
||||||
response = {"data" => "pong"}
|
super(@env)
|
||||||
response.to_json
|
end
|
||||||
|
|
||||||
|
def ping
|
||||||
|
render_json({data: "pong"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+29
-13
@@ -1,29 +1,45 @@
|
|||||||
module App::Lib
|
module App::Lib
|
||||||
abstract class BaseController
|
abstract class BaseController
|
||||||
def map_changeset_errors(errors)
|
protected getter env : HTTP::Server::Context
|
||||||
|
|
||||||
|
def initialize(@env : HTTP::Server::Context); end
|
||||||
|
|
||||||
|
# Convert changeset errors to API-friendly format
|
||||||
|
protected def map_changeset_errors(errors)
|
||||||
errors.reduce({} of String => Array(String)) do |memo, error|
|
errors.reduce({} of String => Array(String)) do |memo, error|
|
||||||
memo[error[:field]] = memo[error[:field]]? || [] of String
|
field = error[:field].to_s
|
||||||
memo[error[:field]] << error[:message]
|
message = error[:message].to_s
|
||||||
|
|
||||||
|
memo[field] ||= [] of String
|
||||||
|
memo[field] << message
|
||||||
memo
|
memo
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_body(env, fields)
|
protected def parse_body(required_fields : Array(String) = [] of String)
|
||||||
json_params = env.params.json.to_h
|
json_params = @env.params.json.try(&.to_h) || {} of String => JSON::Any
|
||||||
missing_fields = [] of String
|
json_params = json_params.transform_values(&.to_s) # Convert JSON::Any to String
|
||||||
|
|
||||||
fields.each do |field|
|
missing_fields = required_fields.reject { |field| json_params.has_key?(field) }
|
||||||
unless json_params.has_key?(field)
|
|
||||||
missing_fields << field
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless missing_fields.empty?
|
unless missing_fields.empty?
|
||||||
error_message = missing_fields.map { |field| "#{field}: Required field" }.join(", ")
|
error_message = missing_fields.join(", ") + " required"
|
||||||
raise App::BadRequestException.new(env, error_message)
|
raise App::BadRequestException.new(@env, error_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
json_params
|
json_params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
protected def render_json(data, status_code : Int32 = 200)
|
||||||
|
@env.response.status_code = status_code
|
||||||
|
@env.response.content_type = "application/json"
|
||||||
|
data.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
protected def param(key : String) : String
|
||||||
|
@env.params.url[key]
|
||||||
|
rescue KeyError
|
||||||
|
raise App::BadRequestException.new(@env, "Missing required parameter: #{key}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
require "sqlite3"
|
require "sqlite3"
|
||||||
require "crecto"
|
require "crecto"
|
||||||
require"micrate"
|
require "micrate"
|
||||||
|
|
||||||
module App::Lib
|
module App::Lib
|
||||||
class Database
|
class Database
|
||||||
@@ -9,7 +9,15 @@ module App::Lib
|
|||||||
Query = Crecto::Repo::Query
|
Query = Crecto::Repo::Query
|
||||||
|
|
||||||
config do |conf|
|
config do |conf|
|
||||||
conf.uri = ENV["DATABASE_URL"]
|
base_url = ENV["DATABASE_URL"]
|
||||||
|
separator = base_url.includes?("?") ? "&" : "?"
|
||||||
|
|
||||||
|
db_url = base_url + separator +
|
||||||
|
"&journal_mode=WAL" +
|
||||||
|
"&synchronous=NORMAL" + # Better performance with reasonable safety
|
||||||
|
"&foreign_keys=true"
|
||||||
|
|
||||||
|
conf.uri = db_url
|
||||||
end
|
end
|
||||||
|
|
||||||
if ENV["ENV"] == "development"
|
if ENV["ENV"] == "development"
|
||||||
|
|||||||
@@ -55,12 +55,3 @@ module App
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
error 500 do |env|
|
|
||||||
App::InternalServerErrorException.new(env)
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|
||||||
error 404 do |env|
|
|
||||||
""
|
|
||||||
end
|
|
||||||
|
|||||||
+33
-42
@@ -1,54 +1,45 @@
|
|||||||
require "maxminddb"
|
require "maxminddb"
|
||||||
|
require "log"
|
||||||
|
|
||||||
class IpLookup
|
module App::Lib
|
||||||
@@instance : MaxMindDB::Reader? = nil
|
struct IpLookup
|
||||||
|
MMDB_PATH = "data/GeoLite2-Country.mmdb"
|
||||||
|
|
||||||
record Country, code : String? = nil, name : String? = nil
|
@@reader : MaxMindDB::Reader? = nil
|
||||||
|
@@reader_mutex = Mutex.new
|
||||||
|
|
||||||
getter ip : String
|
private def self.get_reader : MaxMindDB::Reader
|
||||||
getter country : Country?
|
@@reader_mutex.synchronize do
|
||||||
|
@@reader ||= MaxMindDB.open(MMDB_PATH)
|
||||||
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
|
end
|
||||||
rescue ex
|
|
||||||
# Silently handle lookup errors
|
|
||||||
Log.error { "IP lookup failed: #{ex.message}" }
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def self.extract_ip(address_string : String?) : String?
|
def self.country(ip_address : String) : String?
|
||||||
return nil if address_string.nil?
|
return nil if ip_address == "Unknown" || ip_address.empty?
|
||||||
|
|
||||||
if address_string.includes?('[') # IPv6 with port: [2001:db8::1]:8080
|
begin
|
||||||
address_string.split(']').first.sub('[', '\'')
|
lookup = get_reader.get(ip_address)
|
||||||
elsif address_string.includes?(':')
|
lookup["country"]?.try &.["iso_code"]?.try &.as_s
|
||||||
if address_string.count(':') > 1 # IPv6 without port
|
rescue ex
|
||||||
address_string
|
Log.error { "IP lookup failed: #{ex.message}" }
|
||||||
else # IPv4 with port: 192.168.1.1:8080
|
nil
|
||||||
address_string.split(':').first
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ip_from_address(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
|
||||||
else # Address without port
|
|
||||||
address_string
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
require "yaml"
|
||||||
|
require "semantic_version"
|
||||||
|
|
||||||
|
module App::Lib
|
||||||
|
struct UserAgent
|
||||||
|
REGEXES_PATH = "data/uap_core_regexes.yaml"
|
||||||
|
|
||||||
|
@@regexes_cache : YAML::Any? = nil
|
||||||
|
@@compiled_regexes = {} of String => Array(Tuple(Regex, YAML::Any))
|
||||||
|
@@mutex = Mutex.new
|
||||||
|
|
||||||
|
private def self.load_regexes
|
||||||
|
@@mutex.synchronize do
|
||||||
|
if @@regexes_cache.nil?
|
||||||
|
begin
|
||||||
|
regexes_yaml = File.read(REGEXES_PATH)
|
||||||
|
@@regexes_cache = YAML.parse(regexes_yaml)
|
||||||
|
|
||||||
|
# Pre-compile all regexes for better performance
|
||||||
|
["user_agent_parsers", "os_parsers", "device_parsers"].each do |parser_type|
|
||||||
|
@@compiled_regexes[parser_type] = [] of Tuple(Regex, YAML::Any)
|
||||||
|
|
||||||
|
@@regexes_cache.not_nil![parser_type].as_a.each do |parser|
|
||||||
|
regex_str = parser["regex"].as_s
|
||||||
|
options = parser["regex_flag"]?.try(&.as_s) == "i" ?
|
||||||
|
Regex::Options::IGNORE_CASE : Regex::Options::None
|
||||||
|
|
||||||
|
begin
|
||||||
|
compiled_regex = Regex.new(regex_str, options)
|
||||||
|
@@compiled_regexes[parser_type] << {compiled_regex, parser}
|
||||||
|
rescue
|
||||||
|
# Skip invalid regexes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue ex
|
||||||
|
# If loading fails, set an empty cache to prevent repeated failures
|
||||||
|
@@regexes_cache = YAML.parse("{}")
|
||||||
|
@@compiled_regexes = {} of String => Array(Tuple(Regex, YAML::Any))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse(user_agent_string : String)
|
||||||
|
return {nil, nil, nil, nil} if user_agent_string.empty?
|
||||||
|
|
||||||
|
# Load regexes only once and cache them
|
||||||
|
load_regexes
|
||||||
|
|
||||||
|
family = nil
|
||||||
|
version = nil
|
||||||
|
device = nil
|
||||||
|
os = nil
|
||||||
|
|
||||||
|
@@compiled_regexes["user_agent_parsers"]?.try &.each do |regex_tuple|
|
||||||
|
regex, parser = regex_tuple
|
||||||
|
match = regex.match(user_agent_string)
|
||||||
|
next unless match
|
||||||
|
|
||||||
|
family = match[1]? || nil
|
||||||
|
v1 = (match[2]? || "0").to_i
|
||||||
|
v2 = (match[3]? || "0").to_i
|
||||||
|
v3 = (match[4]? || "0").to_i
|
||||||
|
|
||||||
|
# Apply replacements if defined
|
||||||
|
if replacement = parser["family_replacement"]?
|
||||||
|
family = replacement.as_s.gsub("$1", family.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
version = SemanticVersion.new(v1, v2, v3)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
@@compiled_regexes["os_parsers"]?.try &.each do |regex_tuple|
|
||||||
|
regex, parser = regex_tuple
|
||||||
|
match = regex.match(user_agent_string)
|
||||||
|
next unless match
|
||||||
|
|
||||||
|
os_family = match[1]? || nil
|
||||||
|
os_v1 = (match[2]? || "0").to_i
|
||||||
|
os_v2 = (match[3]? || "0").to_i
|
||||||
|
os_v3 = (match[4]? || "0").to_i
|
||||||
|
|
||||||
|
# Apply replacements if defined
|
||||||
|
if replacement = parser["os_replacement"]?
|
||||||
|
os_family = replacement.as_s.gsub("$1", os_family.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
os = {os_family, SemanticVersion.new(os_v1, os_v2, os_v3)}
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
@@compiled_regexes["device_parsers"]?.try &.each do |regex_tuple|
|
||||||
|
regex, parser = regex_tuple
|
||||||
|
match = regex.match(user_agent_string)
|
||||||
|
next unless match
|
||||||
|
|
||||||
|
model = match[1]? || nil
|
||||||
|
device_name = model
|
||||||
|
brand = nil
|
||||||
|
|
||||||
|
# Apply replacements if defined
|
||||||
|
if device_replacement = parser["device_replacement"]?
|
||||||
|
device_name = device_replacement.as_s.gsub("$1", device_name.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
if model_replacement = parser["model_replacement"]?
|
||||||
|
model = model_replacement.as_s.gsub("$1", model.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
if brand_replacement = parser["brand_replacement"]?
|
||||||
|
brand = brand_replacement.as_s
|
||||||
|
end
|
||||||
|
|
||||||
|
device = {model, brand, device_name}
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
{family, version, device, os}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module App::Middlewares
|
||||||
|
class CORSHandler < Kemal::Handler
|
||||||
|
exclude ["/api/ping", "/:slug"]
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
@allow_origin = "*",
|
||||||
|
@allow_methods = "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
@allow_headers = "Content-Type, Accept, Origin, X-Api-Key"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(env)
|
||||||
|
return call_next(env) if exclude_match?(env)
|
||||||
|
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = @allow_origin
|
||||||
|
env.response.headers["Access-Control-Allow-Methods"] = @allow_methods
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = @allow_headers
|
||||||
|
|
||||||
|
if env.request.method == "OPTIONS"
|
||||||
|
env.response.status_code = 200
|
||||||
|
env.response.content_type = "text/plain"
|
||||||
|
env.response.print("")
|
||||||
|
return env
|
||||||
|
end
|
||||||
|
|
||||||
|
call_next(env)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+1
-1
@@ -3,7 +3,7 @@ require "crecto"
|
|||||||
module App::Models
|
module App::Models
|
||||||
class Click < Crecto::Model
|
class Click < Crecto::Model
|
||||||
schema :clicks do
|
schema :clicks do
|
||||||
field :id, String, primary_key: true
|
field :id, Int64, primary_key: true
|
||||||
field :user_agent, String
|
field :user_agent, String
|
||||||
field :country, String
|
field :country, String
|
||||||
field :browser, String
|
field :browser, String
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ require "./user.cr"
|
|||||||
module App::Models
|
module App::Models
|
||||||
class Link < Crecto::Model
|
class Link < Crecto::Model
|
||||||
schema :links do
|
schema :links do
|
||||||
field :id, String, primary_key: true
|
field :id, Int64, primary_key: true
|
||||||
field :slug, String
|
field :slug, String
|
||||||
field :url, String
|
field :url, String
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ require "crecto"
|
|||||||
module App::Models
|
module App::Models
|
||||||
class User < Crecto::Model
|
class User < Crecto::Model
|
||||||
schema :users do
|
schema :users do
|
||||||
field :id, String, primary_key: true
|
field :id, Int64, primary_key: true
|
||||||
field :name, String
|
field :name, String
|
||||||
field :api_key, String
|
field :api_key, String
|
||||||
end
|
end
|
||||||
|
|||||||
+19
-20
@@ -1,45 +1,44 @@
|
|||||||
require "./controllers/**"
|
require "./controllers/**"
|
||||||
|
|
||||||
|
require "kemal"
|
||||||
|
|
||||||
|
add_handler App::Middlewares::CORSHandler.new
|
||||||
|
add_handler App::Middlewares::Auth.new
|
||||||
|
|
||||||
module App
|
module App
|
||||||
before_all do |env|
|
get "/:slug", &App::Controllers::ClickController.redirect_handler
|
||||||
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
|
|
||||||
|
|
||||||
|
# Namespace /api
|
||||||
get "/api/ping" do |env|
|
get "/api/ping" do |env|
|
||||||
Controllers::Ping::Get.new.call(env)
|
Controllers::PingController.new(env).ping
|
||||||
end
|
|
||||||
|
|
||||||
get "/:slug" do |env|
|
|
||||||
Controllers::Link::Index.new.call(env)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links" do |env|
|
get "/api/links" do |env|
|
||||||
Controllers::Link::All.new.call(env)
|
Controllers::LinkController.new(env).list_all
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links/:id" do |env|
|
get "/api/links/:id" do |env|
|
||||||
Controllers::Link::Get.new.call(env)
|
Controllers::LinkController.new(env).get
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links/:id/clicks" do |env|
|
get "/api/links/:id/clicks" do |env|
|
||||||
Controllers::Link::Clicks.new.call(env)
|
Controllers::LinkController.new(env).list_clicks
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/links" do |env|
|
post "/api/links" do |env|
|
||||||
Controllers::Link::Create.new.call(env)
|
Controllers::LinkController.new(env).create
|
||||||
end
|
end
|
||||||
|
|
||||||
put "/api/links/:id" do |env|
|
put "/api/links/:id" do |env|
|
||||||
Controllers::Link::Update.new.call(env)
|
Controllers::LinkController.new(env).update
|
||||||
end
|
end
|
||||||
|
|
||||||
delete "/api/links/:id" do |env|
|
delete "/api/links/:id" do |env|
|
||||||
Controllers::Link::Delete.new.call(env)
|
Controllers::LinkController.new(env).delete
|
||||||
|
end
|
||||||
|
|
||||||
|
error 500 do |env|
|
||||||
|
App::InternalServerErrorException.new(env)
|
||||||
|
""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-2
@@ -8,12 +8,11 @@ require "../models/*"
|
|||||||
module App::Services::Cli
|
module App::Services::Cli
|
||||||
def self.create_user(name, api_key = nil)
|
def self.create_user(name, api_key = nil)
|
||||||
user = App::Models::User.new
|
user = App::Models::User.new
|
||||||
user.id = UUID.v4.to_s
|
|
||||||
user.name = name
|
user.name = name
|
||||||
user.api_key = api_key || Random::Secure.urlsafe_base64()
|
user.api_key = api_key || Random::Secure.urlsafe_base64()
|
||||||
|
|
||||||
changeset = App::Lib::Database.insert(user)
|
changeset = App::Lib::Database.insert(user)
|
||||||
return changeset.errors if !changeset.valid?
|
return changeset.errors unless changeset.valid?
|
||||||
|
|
||||||
"New user created: Name: #{user.name}, X-Api-Key: #{user.api_key}"
|
"New user created: Name: #{user.name}, X-Api-Key: #{user.api_key}"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require "digest"
|
|||||||
require "base64"
|
require "base64"
|
||||||
|
|
||||||
module App::Services::SlugService
|
module App::Services::SlugService
|
||||||
def self.shorten_url(url : String, user_id : String) : String
|
def self.shorten_url(url : String, user_id : Int64) : String
|
||||||
combined = "#{user_id}-#{url}"
|
combined = "#{user_id}-#{url}"
|
||||||
crc32_hash = Digest::CRC32.digest(combined)
|
crc32_hash = Digest::CRC32.digest(combined)
|
||||||
base62_encoded = Base64.urlsafe_encode(crc32_hash).strip.tr("-_=", "")
|
base62_encoded = Base64.urlsafe_encode(crc32_hash).strip.tr("-_=", "")
|
||||||
|
|||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env crystal
|
||||||
|
|
||||||
|
require "http/client"
|
||||||
|
require "json"
|
||||||
|
require "file_utils"
|
||||||
|
|
||||||
|
# Configuration variables
|
||||||
|
SERVER_URL = "http://localhost:4000"
|
||||||
|
API_URL = "#{SERVER_URL}/api/links"
|
||||||
|
API_KEY = "secure_api_key_1"
|
||||||
|
NUMBER_OF_REQUESTS = 100000
|
||||||
|
|
||||||
|
CONTAINER_NAME = "bit"
|
||||||
|
STATS_FILE = "resource_usage.txt"
|
||||||
|
|
||||||
|
class ResourceMonitor
|
||||||
|
def initialize(@container_name : String)
|
||||||
|
@running = false
|
||||||
|
@stats = [] of {timestamp: Time, cpu: Float64, memory: Float64}
|
||||||
|
end
|
||||||
|
|
||||||
|
def start
|
||||||
|
@running = true
|
||||||
|
@stats.clear
|
||||||
|
|
||||||
|
# Initialize stats file with header
|
||||||
|
File.write(STATS_FILE, "Timestamp\tCPU(%)\tMemory(MiB)\n")
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
while @running
|
||||||
|
if stat = capture_stats
|
||||||
|
# Append each measurement directly to the file
|
||||||
|
File.open(STATS_FILE, "a") do |file|
|
||||||
|
file.puts "#{stat[:timestamp].to_unix}\t#{stat[:cpu]}\t#{stat[:memory]}"
|
||||||
|
end
|
||||||
|
@stats << stat
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def stop
|
||||||
|
@running = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def avg_stats
|
||||||
|
return {cpu: 0.0, memory: 0.0} if @stats.empty?
|
||||||
|
|
||||||
|
total_cpu = 0.0
|
||||||
|
total_memory = 0.0
|
||||||
|
@stats.each do |stat|
|
||||||
|
total_cpu += stat[:cpu]
|
||||||
|
total_memory += stat[:memory]
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
cpu: total_cpu / @stats.size,
|
||||||
|
memory: total_memory / @stats.size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private def capture_stats
|
||||||
|
output = IO::Memory.new
|
||||||
|
process = Process.run(
|
||||||
|
"docker", ["stats", "--no-stream", "--format", "{{.CPUPerc}},{{.MemUsage}}", @container_name],
|
||||||
|
output: output
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.success?
|
||||||
|
line = output.to_s.strip
|
||||||
|
parts = line.split(",")
|
||||||
|
if parts.size == 2
|
||||||
|
cpu_part = parts[0].gsub("%", "").to_f
|
||||||
|
|
||||||
|
# Extract the memory value properly by removing the "MiB" suffix
|
||||||
|
mem_string = parts[1].split.first
|
||||||
|
mem_part = mem_string.gsub(/[A-Za-z]+$/, "").to_f
|
||||||
|
|
||||||
|
return {timestamp: Time.utc, cpu: cpu_part, memory: mem_part}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_dependencies
|
||||||
|
{"docker", "jq", "bombardier"}.each do |cmd|
|
||||||
|
process = Process.run("which", [cmd], output: Process::Redirect::Close)
|
||||||
|
unless process.success?
|
||||||
|
puts "Error: #{cmd} is not installed. Please install it to proceed."
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_containers
|
||||||
|
puts "Setting up..."
|
||||||
|
|
||||||
|
process = Process.run("docker", ["compose", "up", "-d"])
|
||||||
|
unless process.success?
|
||||||
|
puts "Failed to start Docker containers."
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Waiting for the application to be ready..."
|
||||||
|
until begin
|
||||||
|
HTTP::Client.get("#{SERVER_URL}/api/ping").success?
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
sleep 1.seconds
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Seeding the database..."
|
||||||
|
process = Process.run("docker", ["compose", "exec", "app", "sh", "-c", "sqlite3 ./sqlite/data.db < ./db/seed.sql"])
|
||||||
|
|
||||||
|
unless process.success?
|
||||||
|
puts "Error on seeding database"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Checking seed results..."
|
||||||
|
until begin
|
||||||
|
HTTP::Client.get(
|
||||||
|
"#{API_URL}?limit=1",
|
||||||
|
headers: HTTP::Headers{"X-Api-Key" => API_KEY}
|
||||||
|
).success?
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
sleep 2.seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_benchmark
|
||||||
|
puts "Fetching all created links from /api/links..."
|
||||||
|
|
||||||
|
response = HTTP::Client.get(
|
||||||
|
"#{API_URL}?limit=10000",
|
||||||
|
headers: HTTP::Headers{"X-Api-Key" => API_KEY}
|
||||||
|
)
|
||||||
|
|
||||||
|
sleep 2.seconds
|
||||||
|
unless response.success?
|
||||||
|
puts "Failed to fetch links. Status: #{response.status_code}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
links = data["data"].as_a.map { |link| link["refer"].as_s }
|
||||||
|
|
||||||
|
random_link = links.sample
|
||||||
|
puts "Selected link for benchmarking: #{random_link}"
|
||||||
|
|
||||||
|
puts "Starting benchmark with Bombardier..."
|
||||||
|
|
||||||
|
sleep 2.seconds
|
||||||
|
process = Process.new(
|
||||||
|
"bombardier",
|
||||||
|
["-n", NUMBER_OF_REQUESTS.to_s, "-l", "--disableKeepAlives", random_link],
|
||||||
|
output: Process::Redirect::Inherit,
|
||||||
|
error: Process::Redirect::Inherit
|
||||||
|
)
|
||||||
|
|
||||||
|
status = process.wait
|
||||||
|
|
||||||
|
if status.success?
|
||||||
|
puts "Benchmark completed successfully."
|
||||||
|
else
|
||||||
|
puts "Bombardier failed with error code: #{status.exit_code}"
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def analyze_resource_usage
|
||||||
|
puts "Analyzing resource usage..."
|
||||||
|
|
||||||
|
sleep 2.seconds
|
||||||
|
# Read stats directly from file for more accurate results
|
||||||
|
if File.exists?(STATS_FILE)
|
||||||
|
lines = File.read_lines(STATS_FILE)
|
||||||
|
# Skip header
|
||||||
|
lines = lines[1..-1] if lines.size > 0
|
||||||
|
|
||||||
|
if lines.size > 0
|
||||||
|
total_cpu = 0.0
|
||||||
|
total_memory = 0.0
|
||||||
|
peak_cpu = 0.0
|
||||||
|
peak_memory = 0.0
|
||||||
|
|
||||||
|
lines.each do |line|
|
||||||
|
fields = line.split("\t")
|
||||||
|
if fields.size >= 3
|
||||||
|
begin
|
||||||
|
cpu = fields[1].to_f
|
||||||
|
memory = fields[2].to_f
|
||||||
|
|
||||||
|
total_cpu += cpu
|
||||||
|
total_memory += memory
|
||||||
|
|
||||||
|
# Track peaks in a single pass
|
||||||
|
peak_cpu = cpu if cpu > peak_cpu
|
||||||
|
peak_memory = memory if memory > peak_memory
|
||||||
|
rescue
|
||||||
|
# Skip invalid lines
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
avg_cpu = total_cpu / lines.size
|
||||||
|
avg_memory = total_memory / lines.size
|
||||||
|
|
||||||
|
stats_summary = <<-STATS
|
||||||
|
**** Resource Usage Statistics ****
|
||||||
|
Measurements: #{lines.size}
|
||||||
|
Average CPU Usage: #{avg_cpu.round(2)}%
|
||||||
|
Average Memory Usage: #{avg_memory.round(2)} MiB
|
||||||
|
Peak CPU Usage: #{peak_cpu.round(2)}%
|
||||||
|
Peak Memory Usage: #{peak_memory.round(2)} MiB
|
||||||
|
|
||||||
|
STATS
|
||||||
|
|
||||||
|
File.open(STATS_FILE, "a") do |file|
|
||||||
|
file.puts "\n" + stats_summary
|
||||||
|
end
|
||||||
|
|
||||||
|
puts File.read(STATS_FILE)
|
||||||
|
else
|
||||||
|
puts "No resource usage data collected."
|
||||||
|
end
|
||||||
|
else
|
||||||
|
puts "Resource usage file not found."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleanup
|
||||||
|
Process.run("docker", ["compose", "down"])
|
||||||
|
puts "Cleanup completed. Resource usage data saved in #{STATS_FILE}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def main
|
||||||
|
check_dependencies
|
||||||
|
setup_containers
|
||||||
|
|
||||||
|
monitor = ResourceMonitor.new(CONTAINER_NAME)
|
||||||
|
monitor.start
|
||||||
|
|
||||||
|
begin
|
||||||
|
run_benchmark
|
||||||
|
|
||||||
|
monitor.stop
|
||||||
|
analyze_resource_usage
|
||||||
|
ensure
|
||||||
|
cleanup
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
main
|
||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -10,8 +10,6 @@ require "./app/services/*"
|
|||||||
require "./app/routes"
|
require "./app/routes"
|
||||||
|
|
||||||
add_context_storage_type(App::Models::User)
|
add_context_storage_type(App::Models::User)
|
||||||
add_handler(App::Middlewares::Auth.new)
|
|
||||||
|
|
||||||
App::Services::Cli.setup_admin_user
|
App::Services::Cli.setup_admin_user
|
||||||
|
|
||||||
Kemal.run
|
Kemal.run
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +micrate Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
DROP INDEX IF EXISTS idx_links_slug; -- Remove old composite index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_slug_optimized ON links (slug, url);
|
||||||
|
|
||||||
|
-- +micrate Down
|
||||||
|
-- SQL in section 'Down' is executed when this migration is rolled back
|
||||||
|
DROP INDEX IF EXISTS idx_links_slug_optimized;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_links_slug ON links (id, slug, url);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- +micrate Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
-- 1. Create new users table with INTEGER PK
|
||||||
|
CREATE TABLE users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create a mapping table to track old and new user IDs
|
||||||
|
CREATE TEMPORARY TABLE user_id_map (
|
||||||
|
old_id TEXT,
|
||||||
|
new_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert users data and capture the mappings
|
||||||
|
INSERT INTO users_new (name, api_key, created_at, updated_at)
|
||||||
|
SELECT name, api_key, created_at, updated_at FROM users;
|
||||||
|
|
||||||
|
INSERT INTO user_id_map
|
||||||
|
SELECT u.id, u_new.id
|
||||||
|
FROM users u
|
||||||
|
JOIN users_new u_new ON u_new.api_key = u.api_key;
|
||||||
|
|
||||||
|
-- 2. Create new links table with INTEGER PK
|
||||||
|
CREATE TABLE links_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER 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_new(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create a mapping table for links
|
||||||
|
CREATE TEMPORARY TABLE link_id_map (
|
||||||
|
old_id TEXT,
|
||||||
|
new_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert links data with new user_id foreign keys
|
||||||
|
INSERT INTO links_new (user_id, slug, url, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
(SELECT new_id FROM user_id_map WHERE old_id = l.user_id),
|
||||||
|
l.slug,
|
||||||
|
l.url,
|
||||||
|
l.created_at,
|
||||||
|
l.updated_at
|
||||||
|
FROM links l;
|
||||||
|
|
||||||
|
-- Create the mapping for links
|
||||||
|
INSERT INTO link_id_map
|
||||||
|
SELECT l.id, l_new.id
|
||||||
|
FROM links l
|
||||||
|
JOIN links_new l_new ON l_new.slug = l.slug AND l_new.url = l.url;
|
||||||
|
|
||||||
|
-- 3. Create new clicks table with INTEGER PK
|
||||||
|
CREATE TABLE clicks_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
link_id INTEGER NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
browser TEXT,
|
||||||
|
os TEXT,
|
||||||
|
referer TEXT,
|
||||||
|
country TEXT,
|
||||||
|
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (link_id) REFERENCES links_new(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert clicks data with new link_id foreign keys
|
||||||
|
INSERT INTO clicks_new (link_id, user_agent, browser, os, referer, country, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
(SELECT new_id FROM link_id_map WHERE old_id = c.link_id),
|
||||||
|
c.user_agent,
|
||||||
|
c.browser,
|
||||||
|
c.os,
|
||||||
|
c.referer,
|
||||||
|
c.country,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clicks c;
|
||||||
|
|
||||||
|
-- 4. Drop old tables and rename new tables
|
||||||
|
DROP TABLE clicks;
|
||||||
|
DROP TABLE links;
|
||||||
|
DROP TABLE users;
|
||||||
|
|
||||||
|
ALTER TABLE clicks_new RENAME TO clicks;
|
||||||
|
ALTER TABLE links_new RENAME TO links;
|
||||||
|
ALTER TABLE users_new RENAME TO users;
|
||||||
|
|
||||||
|
-- 5. Drop unused indexes
|
||||||
|
DROP INDEX IF EXISTS index_users_api_key;
|
||||||
|
DROP INDEX IF EXISTS idx_links_slug;
|
||||||
|
DROP INDEX IF EXISTS idx_links_slug_optimized;
|
||||||
|
|
||||||
|
-- +micrate Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
INSERT INTO users (name, api_key)
|
||||||
|
VALUES
|
||||||
|
('User 1', 'secure_api_key_1'),
|
||||||
|
('User 2', 'secure_api_key_2');
|
||||||
|
|
||||||
|
-- Create 10,000 links (5,000 per user)
|
||||||
|
WITH RECURSIVE link_numbers(n) AS (
|
||||||
|
SELECT 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n+1 FROM link_numbers
|
||||||
|
LIMIT 10000
|
||||||
|
)
|
||||||
|
INSERT INTO links (user_id, slug, url)
|
||||||
|
SELECT
|
||||||
|
((n-1) % 2) + 1, -- User ID (1-2)
|
||||||
|
'slug' || n, -- Unique slug
|
||||||
|
'https://sjdonado.com/page/' || n
|
||||||
|
FROM link_numbers;
|
||||||
|
|
||||||
|
-- Create 1,000 clicks per link (10 million total)
|
||||||
|
WITH RECURSIVE counts(n) AS (
|
||||||
|
SELECT 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n+1 FROM counts
|
||||||
|
LIMIT 1000
|
||||||
|
)
|
||||||
|
INSERT INTO clicks (link_id, user_agent, browser, os, referer, country)
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
CASE (c.n % 5)
|
||||||
|
WHEN 0 THEN 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||||
|
WHEN 1 THEN 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)'
|
||||||
|
WHEN 2 THEN 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)'
|
||||||
|
WHEN 3 THEN 'Mozilla/5.0 (X11; Linux x86_64)'
|
||||||
|
ELSE 'Mozilla/5.0 (Android 11; Mobile)'
|
||||||
|
END,
|
||||||
|
CASE (c.n % 3)
|
||||||
|
WHEN 0 THEN 'Firefox'
|
||||||
|
WHEN 1 THEN 'Chrome'
|
||||||
|
ELSE 'Safari'
|
||||||
|
END,
|
||||||
|
CASE (c.n % 4)
|
||||||
|
WHEN 0 THEN 'macOS'
|
||||||
|
WHEN 1 THEN 'Windows'
|
||||||
|
WHEN 2 THEN 'iOS'
|
||||||
|
ELSE 'Android'
|
||||||
|
END,
|
||||||
|
CASE (c.n % 6)
|
||||||
|
WHEN 0 THEN 'https://sjdonado.com'
|
||||||
|
WHEN 1 THEN 'https://donado.co'
|
||||||
|
WHEN 2 THEN 'https://idonthavespotify.donado.co'
|
||||||
|
WHEN 3 THEN 'https://spookyplanning.com'
|
||||||
|
WHEN 4 THEN 'https://github.com/sjdonado'
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
CASE (c.n % 10)
|
||||||
|
WHEN 0 THEN 'Colombia'
|
||||||
|
WHEN 1 THEN 'Brazil'
|
||||||
|
WHEN 2 THEN 'Canada'
|
||||||
|
WHEN 3 THEN 'Germany'
|
||||||
|
WHEN 4 THEN 'France'
|
||||||
|
WHEN 5 THEN 'Japan'
|
||||||
|
WHEN 6 THEN 'Australia'
|
||||||
|
WHEN 7 THEN 'Brazil'
|
||||||
|
WHEN 8 THEN 'India'
|
||||||
|
ELSE 'China'
|
||||||
|
END
|
||||||
|
FROM links l
|
||||||
|
CROSS JOIN counts c;
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- Endpoint: `GET /api/ping`
|
- Endpoint: `GET /api/ping`
|
||||||
- Payload: None
|
- Payload: None
|
||||||
|
- Response: 200
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
- Query Parameters:
|
- Query Parameters:
|
||||||
- `limit` (optional): Number of results per page (default: 100)
|
- `limit` (optional): Number of results per page (default: 100)
|
||||||
- `cursor` (optional): Pagination cursor from previous response
|
- `cursor` (optional): Pagination cursor from previous response
|
||||||
|
- Response: 200
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
- Payload: None
|
- Payload: None
|
||||||
- Note: This endpoint returns up to 100 of the most recent clicks. For complete click history, use the `/api/links/:id/clicks` endpoint with pagination.
|
- Note: This endpoint returns up to 100 of the most recent clicks. For complete click history, use the `/api/links/:id/clicks` endpoint with pagination.
|
||||||
|
- Response: 200
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -73,6 +76,7 @@
|
|||||||
- Query Parameters:
|
- Query Parameters:
|
||||||
- `limit` (optional): Number of results per page (default: 100)
|
- `limit` (optional): Number of results per page (default: 100)
|
||||||
- `cursor` (optional): Pagination cursor from previous response
|
- `cursor` (optional): Pagination cursor from previous response
|
||||||
|
- Response: 200
|
||||||
- Response Example
|
- Response Example
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -103,6 +107,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
|
- Response: 201
|
||||||
- Response Example:
|
- Response Example:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -124,6 +129,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- Headers: `X-Api-Key`
|
- Headers: `X-Api-Key`
|
||||||
|
- Response: 200
|
||||||
- Response Example:
|
- Response Example:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+57
-43
@@ -3,9 +3,10 @@
|
|||||||
```
|
```
|
||||||
Usage: ./cli [options]
|
Usage: ./cli [options]
|
||||||
Options:
|
Options:
|
||||||
--create-user=NAME Create a new user with the given name
|
--create-user=NAME Create a new user with the given name
|
||||||
--list-users List all users
|
--list-users List all users
|
||||||
--delete-user=USER_ID Delete a user by ID
|
--delete-user=USER_ID Delete a user by ID
|
||||||
|
--update-data Download all required data files
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run It Anywhere
|
## Run It Anywhere
|
||||||
@@ -26,17 +27,17 @@ docker run \
|
|||||||
--name bit \
|
--name bit \
|
||||||
-p 4000:4000 \
|
-p 4000:4000 \
|
||||||
-e ENV="production" \
|
-e ENV="production" \
|
||||||
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
|
-e DATABASE_URL="sqlite3://./sqlite/data.db" \
|
||||||
-e APP_URL="http://localhost:4000" \
|
-e APP_URL="http://localhost:4000" \
|
||||||
-e ADMIN_NAME="Admin" \
|
-e ADMIN_NAME="Admin" \
|
||||||
-e ADMIN_API_KEY=$(openssl rand -base64 32) \
|
-e ADMIN_API_KEY=$(openssl rand -base64 32) \
|
||||||
sjdonado/bit
|
sjdonado/bit
|
||||||
|
|
||||||
# Optional: Generate an api key
|
# Create a new user
|
||||||
# docker exec -it bit cli --create-user=Admin
|
# docker exec -it bit cli --create-user=Admin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Self-Hosted with Dokku
|
### Dokku
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM sjdonado/bit
|
FROM sjdonado/bit
|
||||||
@@ -51,15 +52,24 @@ dokku letsencrypt:enable bit
|
|||||||
dokku storage:ensure-directory bit-sqlite
|
dokku storage:ensure-directory bit-sqlite
|
||||||
dokku storage:mount bit /var/lib/dokku/data/storage/bit-sqlite:/usr/src/app/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 config:set bit DATABASE_URL="sqlite3://./sqlite/data.db" 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 http:80:4000
|
||||||
dokku ports:add bit https:443:4000
|
dokku ports:add bit https:443:4000
|
||||||
|
|
||||||
# Optional: Generate an api key
|
# Create a new user
|
||||||
# dokku run bit cli --create-user=Admin
|
# dokku run bit cli --create-user=Admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dokku (same network)
|
||||||
|
Recommended for lower latency communication (no host network traversal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dokku network:create bit-net
|
||||||
|
dokku network:set bit attach-post-create bit-net
|
||||||
|
dokku network:set myapp attach-post-create bit-net
|
||||||
|
```
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -99,56 +109,60 @@ ENV=test crystal spec
|
|||||||
|
|
||||||
## Benchmark
|
## Benchmark
|
||||||
|
|
||||||
CPU: Apple M3 Pro
|
- Colima: cpu 1, mem 1
|
||||||
|
- SoC: Apple M3 Pro
|
||||||
|
|
||||||
```
|
```
|
||||||
> colima start --cpu 1 --memory 1
|
~/p/bit> colima start --cpu 1 --memory 1
|
||||||
INFO[0000] starting colima
|
INFO[0000] starting colima
|
||||||
INFO[0000] runtime: docker
|
INFO[0000] runtime: docker
|
||||||
INFO[0001] starting ... context=vm
|
INFO[0001] starting ... context=vm
|
||||||
INFO[0076] provisioning ... context=docker
|
INFO[0076] provisioning ... context=docker
|
||||||
INFO[0077] starting ... context=docker
|
INFO[0077] starting ... context=docker
|
||||||
INFO[0077] done
|
INFO[0077] done
|
||||||
|
~/p/bit> ./benchmark.cr
|
||||||
> ./benchmark.sh
|
|
||||||
Setting up...
|
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...
|
Waiting for the application to be ready...
|
||||||
HTTP/1.1 200 OK
|
Seeding the database...
|
||||||
Connection: keep-alive
|
Checking seed results...
|
||||||
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...
|
Fetching all created links from /api/links...
|
||||||
Selected link for benchmarking: http://localhost:4000/pKtTjA
|
Selected link for benchmarking: http://localhost:4000/slug4280
|
||||||
Starting benchmark with Bombardier...
|
Starting benchmark with Bombardier...
|
||||||
Bombarding http://localhost:4000/pKtTjA with 10000 request(s) using 100 connection(s)
|
Bombarding http://localhost:4000/slug4280 with 100000 request(s) using 125 connection(s)
|
||||||
10000 / 10000 [======================================================================================================================================================================] 100.00% 1424/s 7s
|
100000 / 100000 [==============================================================] 100.00% 6562/s 15s
|
||||||
Done!
|
Done!
|
||||||
Statistics Avg Stdev Max
|
Statistics Avg Stdev Max
|
||||||
Reqs/sec 1885.24 7686.34 140641.16
|
Reqs/sec 6609.73 1508.34 13145.76
|
||||||
Latency 68.00ms 6.43ms 89.56ms
|
Latency 18.92ms 2.34ms 74.58ms
|
||||||
|
Latency Distribution
|
||||||
|
50% 18.83ms
|
||||||
|
75% 20.19ms
|
||||||
|
90% 21.80ms
|
||||||
|
95% 23.10ms
|
||||||
|
99% 26.54ms
|
||||||
HTTP codes:
|
HTTP codes:
|
||||||
1xx - 0, 2xx - 0, 3xx - 10000, 4xx - 0, 5xx - 0
|
1xx - 0, 2xx - 0, 3xx - 100000, 4xx - 0, 5xx - 0
|
||||||
others - 0
|
others - 0
|
||||||
Throughput: 625.54KB/s
|
Throughput: 1.80MB/s
|
||||||
Benchmark completed.
|
Benchmark completed successfully.
|
||||||
Analyzing resource usage...
|
Analyzing resource usage...
|
||||||
**** Results ****
|
Timestamp CPU(%) Memory(MiB)
|
||||||
Average CPU Usage: 42.98%
|
1742732843 0.02 44.71
|
||||||
Average Memory Usage: 33.25 MiB
|
1742732845 0.02 44.71
|
||||||
./benchmark.sh: line 135: 1500 Terminated: 15 monitor_resource_usage
|
1742732847 85.34 69.55
|
||||||
[+] Running 2/2
|
1742732849 83.5 69.93
|
||||||
✔ Container bit Removed 10.1s
|
1742732851 84.26 69.97
|
||||||
✔ Network bit_default Removed 0.0s
|
1742732853 83.64 70.01
|
||||||
|
1742732855 84.23 70.04
|
||||||
|
1742732857 86.41 69.17
|
||||||
|
1742732859 85.77 69.2
|
||||||
|
1742732861 59.67 68.55
|
||||||
|
|
||||||
|
**** Resource Usage Statistics ****
|
||||||
|
Measurements: 10
|
||||||
|
Average CPU Usage: 65.29%
|
||||||
|
Average Memory Usage: 64.58 MiB
|
||||||
|
Peak CPU Usage: 86.41%
|
||||||
|
Peak Memory Usage: 70.04 MiB
|
||||||
|
Cleanup completed. Resource usage data saved in resource_usage.txt
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
require "uuid"
|
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
|
|
||||||
require "../app/services/cli"
|
require "../app/services/cli"
|
||||||
|
|||||||
@@ -56,7 +56,3 @@ shards:
|
|||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.19.0
|
version: 0.19.0
|
||||||
|
|
||||||
user_agent_parser:
|
|
||||||
git: https://github.com/busyloop/user_agent_parser.git
|
|
||||||
version: 2.0.1
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: bit
|
name: bit
|
||||||
version: 1.5.1
|
version: 1.5.2
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Juan Rodriguez <sjdonado@icloud.com>
|
- Juan Rodriguez <sjdonado@icloud.com>
|
||||||
@@ -20,8 +20,6 @@ dependencies:
|
|||||||
micrate:
|
micrate:
|
||||||
github: amberframework/micrate
|
github: amberframework/micrate
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
user_agent_parser:
|
|
||||||
github: busyloop/user_agent_parser
|
|
||||||
maxminddb:
|
maxminddb:
|
||||||
github: delef/maxminddb.cr
|
github: delef/maxminddb.cr
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -1,4 +1,3 @@
|
|||||||
require "uuid"
|
|
||||||
require "file_utils"
|
require "file_utils"
|
||||||
|
|
||||||
require "spec-kemal"
|
require "spec-kemal"
|
||||||
@@ -24,7 +23,6 @@ end
|
|||||||
|
|
||||||
def create_test_user
|
def create_test_user
|
||||||
user = App::Models::User.new
|
user = App::Models::User.new
|
||||||
user.id = UUID.v4.to_s
|
|
||||||
user.name = "Tester"
|
user.name = "Tester"
|
||||||
user.api_key = Random::Secure.urlsafe_base64()
|
user.api_key = Random::Secure.urlsafe_base64()
|
||||||
|
|
||||||
@@ -39,8 +37,7 @@ end
|
|||||||
|
|
||||||
def create_test_link(user, url)
|
def create_test_link(user, url)
|
||||||
link = App::Models::Link.new
|
link = App::Models::Link.new
|
||||||
link.id = UUID.v4.to_s
|
link.slug = App::Services::SlugService.shorten_url(url, user.id)
|
||||||
link.slug = App::Services::SlugService.shorten_url(url, user.id.to_s)
|
|
||||||
link.url = url
|
link.url = url
|
||||||
link.user = user
|
link.user = user
|
||||||
|
|
||||||
@@ -57,7 +54,6 @@ end
|
|||||||
|
|
||||||
def create_test_click(link)
|
def create_test_click(link)
|
||||||
click = App::Models::Click.new
|
click = App::Models::Click.new
|
||||||
click.id = UUID.v4.to_s
|
|
||||||
click.user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
click.user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"
|
||||||
click.browser = "Firefox"
|
click.browser = "Firefox"
|
||||||
click.os = "Mac OS X"
|
click.os = "Mac OS X"
|
||||||
@@ -75,8 +71,8 @@ def create_test_click(link)
|
|||||||
click
|
click
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_test_link(link_id)
|
def get_test_link(link_id: Int64)
|
||||||
query = App::Lib::Database::Query.where(id: link_id.as(String)).limit(1)
|
query = App::Lib::Database::Query.where(id: link_id).limit(1)
|
||||||
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
|
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
|
||||||
|
|
||||||
raise "Link not found" if link.nil?
|
raise "Link not found" if link.nil?
|
||||||
@@ -84,6 +80,6 @@ def get_test_link(link_id)
|
|||||||
link
|
link
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_test_link(link_id)
|
def delete_test_link(link_id: Int64)
|
||||||
App::Lib::Database.raw_exec("DELETE FROM links WHERE id = (?)", link_id) # tempfix: Database.delete does not work
|
App::Lib::Database.raw_exec("DELETE FROM links WHERE id = (?)", link_id) # tempfix: Database.delete does not work
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user