Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a271e7c35d | |||
| a46a50b429 | |||
| dc8c359bfc | |||
| dfb6b10caf | |||
| 3fa30b3a32 | |||
| 80ed6033d1 | |||
| 4640522d5d | |||
| 848232cc11 | |||
| 98dedc4494 | |||
| e6f64ea026 | |||
| ea71d3825e | |||
| afa9b33568 | |||
| a93189411b | |||
| 98f103f5cf | |||
| 6fc48dae83 | |||
| d039add340 | |||
| 0214d6f46d | |||
| 37e14ec2f8 | |||
| a85d5a8c73 | |||
| 80cebe3357 | |||
| 451a5fbf0f | |||
| aeb6d1164b | |||
| 2f14cd82dd | |||
| faedd0bc7a | |||
| 1d207fae64 | |||
| a71f345f66 | |||
| 7cc6c1197f | |||
| 115bbf7366 | |||
| 36a06ac670 | |||
| 772897cb27 | |||
| 1f42798d12 | |||
| 08302e4457 | |||
| cd9a44fcc9 | |||
| 6532ff466e | |||
| 8cbed80fd0 | |||
| 0758bf4cee | |||
| f5c296bee3 | |||
| 8f33375b5f | |||
| cebbd48237 | |||
| 88d81ecfe3 | |||
| 3050c2b100 | |||
| a2aa586dae | |||
| ebc9c6852e | |||
| a1b67b8553 | |||
| dbc81796d6 | |||
| 2f796dbdab | |||
| ff06e10b8f | |||
| 050fd6f1e3 | |||
| b0160f8127 | |||
| bac73f2a47 | |||
| d7a4ce25aa | |||
| 72654ab9fb | |||
| be10cb1c9a | |||
| 7cbd469842 | |||
| 8c209f0036 | |||
| f03e0092c8 | |||
| 89fa00b80b | |||
| 2c3bb8deaf | |||
| de12095d19 | |||
| 0d053a042a | |||
| be0ebd1763 | |||
| aa8a84af0e | |||
| f28dfdfd89 | |||
| a7d52797d9 | |||
| 80a91583fe | |||
| abb35019f3 | |||
| adbc07e605 | |||
| 814fd83d32 | |||
| a8e2e971d6 | |||
| 6b21bc0cd6 | |||
| a47722cd54 | |||
| 7f2a27ec79 | |||
| 0ad534065c | |||
| 80feadfbd2 | |||
| ded84e7fa5 | |||
| 8400e5fe71 | |||
| b20417d579 | |||
| 9564559610 | |||
| e4ae0c2ac4 | |||
| b34a52f3e0 | |||
| c806953b9a | |||
| 3e86e761b6 | |||
| b7b47b133d | |||
| db42ed2b24 | |||
| 9e1fcf2d48 | |||
| 720b70c6a0 | |||
| a77ef21d45 | |||
| 8eb27f2c8a | |||
| 7a095fb045 | |||
| f74ec3af20 | |||
| cebdfb35d7 | |||
| af9c7b0024 | |||
| 211e4f40f4 | |||
| fee04cc26d | |||
| 63acab3cf7 | |||
| 38ce72618a | |||
| e3ab670eac | |||
| 73b674b613 | |||
| 9204abf2e3 | |||
| 391c62e99a | |||
| e9e7c22bfc | |||
| 06dfd59753 | |||
| d404cbf3b8 | |||
| 33eb56f686 | |||
| 54bff064d1 | |||
| d134be737a | |||
| 3feaa5d88f | |||
| 9c7146820c | |||
| f63be42b4c | |||
| 3e8bdee17a | |||
| e50da9b3e2 | |||
| d72ad2d43b | |||
| 7e81f47473 | |||
| d27fb94095 | |||
| 38d0aff7f8 | |||
| 587f9552c8 | |||
| 441bf0919d | |||
| a9d3cbe544 | |||
| 019e2516d7 | |||
| a17a42e792 | |||
| d02df35d86 |
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
/bin/
|
||||
/.shards/
|
||||
/spec/
|
||||
/sqlite/
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
|
||||
APP_URL=http://localhost:4000
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from shard.yml
|
||||
id: extract_version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' shard.yml | cut -d ' ' -f 2)
|
||||
echo "RELEASE_TAG=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Set tags
|
||||
id: set_tags
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
echo "TAGS=latest,${{ env.RELEASE_TAG }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TAGS=latest" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: sjdonado/bit:${{ env.TAGS }}
|
||||
|
||||
- name: Attest
|
||||
uses: actions/attest-build-provenance@v1
|
||||
id: attest
|
||||
with:
|
||||
subject-name: sjdonado/bit
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
@@ -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
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/docs/
|
||||
/lib/
|
||||
/bin/
|
||||
/.shards/
|
||||
*.dwarf
|
||||
.DS_Store
|
||||
|
||||
/sqlite/
|
||||
.env.production
|
||||
|
||||
resource_usage.txt
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
FROM alpine:edge AS build
|
||||
|
||||
ENV ENV=production
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
crystal \
|
||||
shards \
|
||||
yaml-dev \
|
||||
sqlite-dev \
|
||||
openssl-dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN shards install
|
||||
RUN shards build --release --no-debug
|
||||
|
||||
FROM alpine:edge AS runtime
|
||||
|
||||
ENV ENV=production
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apk add --no-cache \
|
||||
gc \
|
||||
pcre2 \
|
||||
libevent \
|
||||
yaml \
|
||||
sqlite-libs \
|
||||
openssl
|
||||
|
||||
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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,296 @@
|
||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
1. **Ping the API**
|
||||
|
||||
- Endpoint: `GET /api/ping`
|
||||
- Payload: None
|
||||
- Response Example
|
||||
```json
|
||||
{
|
||||
"message": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Retrieve a link by its slug**
|
||||
|
||||
- Endpoint: `GET /:slug`
|
||||
- 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",
|
||||
"language": "en-US",
|
||||
"browser": "Firefox",
|
||||
"os": "Mac OS X",
|
||||
"source": "Unknown",
|
||||
"created_at": "2024-07-12T19:25:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Retrieve 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",
|
||||
"language": "en-US",
|
||||
"browser": "Firefox",
|
||||
"os": "Mac OS X",
|
||||
"source": "Unknown",
|
||||
"created_at": "2024-07-12T19:25:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. **Retrieve a link by its 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",
|
||||
"language": "en-US",
|
||||
"browser": "Firefox",
|
||||
"os": "Mac OS X",
|
||||
"source": "Unknown",
|
||||
"created_at": "2024-07-12T19:25:22Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Create a 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://monocuco.donado.co/test",
|
||||
"clicks": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **Update an existing link by its 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 its ID**
|
||||
|
||||
- Endpoint: `DELETE /api/links/:id`
|
||||
- Payload: None
|
||||
- Headers: `X-Api-Key`
|
||||
- Response Example:
|
||||
```json
|
||||
{
|
||||
"message": "Link deleted"
|
||||
}
|
||||
```
|
||||
|
||||
## Self-hosted
|
||||
|
||||
### Run via docker-compose
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
|
||||
# Optional: Generate an api key
|
||||
# docker-compose exec -it app cli --create-user=Admin
|
||||
```
|
||||
|
||||
### Run via 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
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Benchmark
|
||||
|
||||
```
|
||||
$ ./benchmark.sh
|
||||
Setting up...
|
||||
[+] Running 3/3
|
||||
✔ Network bit_default Created 0.0s
|
||||
✔ Volume "bit_sqlite_data" Created 0.0s
|
||||
✔ Container bit Started 0.1s
|
||||
Captured API Key: aHOCnZSuo2kOHy2mDa-iOA
|
||||
Waiting for the application to be ready...
|
||||
HTTP/1.1 200 OK
|
||||
Connection: keep-alive
|
||||
Content-Type: application/json
|
||||
Date: Sun, 27 Oct 2024 11:52:33 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/UaVZjA
|
||||
Starting benchmark with Bombardier...
|
||||
Bombarding http://localhost:4000/oEKLAg with 10000 request(s) using 100 connection(s)
|
||||
10000 / 10000 [===============================================================================] 100.00% 830/s 12s
|
||||
Done!
|
||||
Statistics Avg Stdev Max
|
||||
Reqs/sec 853.89 1625.49 8942.54
|
||||
Latency 118.48ms 11.52ms 142.58ms
|
||||
HTTP codes:
|
||||
1xx - 0, 2xx - 0, 3xx - 10000, 4xx - 0, 5xx - 0
|
||||
others - 0
|
||||
Throughput: 360.02KB/s
|
||||
Benchmark completed.
|
||||
Analyzing resource usage...
|
||||
**** Results ****
|
||||
Average CPU Usage: 40.68%
|
||||
Average Memory Usage: 28.62 MiB
|
||||
./benchmark.sh: line 135: 61567 Terminated: 15 monitor_resource_usage
|
||||
[+] Running 2/2
|
||||
✔ Container bit Removed 10.1s
|
||||
✔ Network bit_default Removed
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
- Setup
|
||||
```bash
|
||||
brew tap amberframework/micrate
|
||||
brew install micrate
|
||||
```
|
||||
|
||||
```bash
|
||||
shards run bit
|
||||
```
|
||||
|
||||
- Generate the `X-Api-Key`
|
||||
|
||||
```bash
|
||||
shards run cli -- --create-user=Admin
|
||||
```
|
||||
|
||||
- Run tests
|
||||
|
||||
```bash
|
||||
ENV=test crystal spec
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it (<https://github.com/sjdonado/bit/fork>)
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin my-new-feature`)
|
||||
5. Create a new Pull Request
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -0,0 +1,179 @@
|
||||
require "uuid"
|
||||
require "user_agent_parser"
|
||||
|
||||
UserAgent.load_regexes(File.read("data/regexes.yaml"))
|
||||
|
||||
require "../lib/controller.cr"
|
||||
|
||||
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
|
||||
|
||||
spawn do
|
||||
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
|
||||
user_agent = user_agent_str != "Unknown" ? UserAgent.new(user_agent_str) : nil
|
||||
|
||||
language_header = env.request.headers["Accept-Language"]? || "Unknown"
|
||||
language = language_header.split(',').first.split(';').first
|
||||
|
||||
referer = env.request.headers["Referer"]?
|
||||
|
||||
click = Click.new
|
||||
click.id = UUID.v4.to_s
|
||||
click.link = link
|
||||
click.language = language
|
||||
click.user_agent = user_agent_str
|
||||
click.browser = user_agent ? user_agent.family : "Unknown"
|
||||
click.os = user_agent ? (user_agent.os.try &.family || "Unknown") : "Unknown"
|
||||
click.source = referer ? URI.parse(referer).host : "Unknown"
|
||||
|
||||
changeset = Database.insert(click)
|
||||
if changeset.errors.any?
|
||||
Log.error { "Logging click event failed: #{changeset.errors}" }
|
||||
end
|
||||
end
|
||||
|
||||
env.response.status_code = 301
|
||||
env.response.headers["Location"] = link.url!
|
||||
env.response.headers["Content-Type"] = "text/html"
|
||||
env.response.print("Redirecting...")
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
require "../lib/controller.cr"
|
||||
|
||||
module App::Controllers::Ping
|
||||
class Get < App::Lib::BaseController
|
||||
def call(env)
|
||||
response = {"pong" => "ok"}
|
||||
response.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 :language, String
|
||||
field :browser, String
|
||||
field :os, String
|
||||
field :source, String
|
||||
|
||||
belongs_to :link, Link
|
||||
end
|
||||
|
||||
validate_required [:user_agent, :language, :source]
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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("language", @click.language)
|
||||
builder.field("browser", @click.browser)
|
||||
builder.field("os", @click.os)
|
||||
builder.field("source", @click.source)
|
||||
builder.field("created_at", @click.created_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
require "digest"
|
||||
require "base64"
|
||||
|
||||
module App::Services::SlugService
|
||||
def self.shorten_url(url : String, user_id : String) : String
|
||||
combined = "#{user_id}-#{url}"
|
||||
crc32_hash = Digest::CRC32.digest(combined)
|
||||
base62_encoded = Base64.urlsafe_encode(crc32_hash).strip.tr("-_=", "")
|
||||
|
||||
base62_encoded
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
module App
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
Executable
+156
@@ -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
|
||||
@@ -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
|
||||
+5957
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,19 @@
|
||||
-- +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,
|
||||
language 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,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:
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
REGEXES_URL="https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml"
|
||||
DOWNLOAD_DIR="data"
|
||||
REGEXES_FILE="regexes.yaml"
|
||||
|
||||
mkdir -p $DOWNLOAD_DIR
|
||||
|
||||
curl -L -o $DOWNLOAD_DIR/$REGEXES_FILE $REGEXES_URL
|
||||
|
||||
echo "Regexes file downloaded to $DOWNLOAD_DIR/$REGEXES_FILE"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 665 B |
Binary file not shown.
|
Before Width: | Height: | Size: 628 B |
-51
@@ -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
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
|
||||
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"
|
||||
end
|
||||
end
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
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
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: bit
|
||||
version: 1.4.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
|
||||
version: 2.0.1
|
||||
|
||||
development_dependencies:
|
||||
dotenv:
|
||||
github: gdotdesign/cr-dotenv
|
||||
spec-kemal:
|
||||
github: kemalcr/spec-kemal
|
||||
|
||||
crystal: ">= 1.12.1"
|
||||
|
||||
license: MIT
|
||||
@@ -0,0 +1,278 @@
|
||||
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" do
|
||||
link = "https://test.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
|
||||
|
||||
response.headers["Location"].should eq(link)
|
||||
end
|
||||
|
||||
it "should create a new click after redirect" do
|
||||
link = "https://sjdonado.com"
|
||||
test_user = create_test_user()
|
||||
|
||||
test_link = create_test_link(test_user, link)
|
||||
serialized_link = App::Serializers::Link.new(test_link)
|
||||
|
||||
get(serialized_link.refer, headers: HTTP::Headers{"User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"})
|
||||
|
||||
Fiber.yield # replace yield with sleep 5 to debug errors
|
||||
|
||||
response.headers["Location"].should eq(link)
|
||||
|
||||
updated_test_link = get_test_link(test_link.id)
|
||||
updated_test_link.clicks.size.should eq(test_link.clicks.size + 1)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user