10 Commits

Author SHA1 Message Date
Juan Rodriguez a85d5a8c73 chore: bump version 2024-07-14 22:30:15 +02:00
Juan Rodriguez 80cebe3357 fix: update slug size after 2nd attempt 2024-07-14 22:30:15 +02:00
Juan Rodriguez 451a5fbf0f fix: link email validate format regex 2024-07-14 14:33:34 +02:00
Juan Rodriguez aeb6d1164b fix: remove X-Powered-By header 2024-07-14 14:33:19 +02:00
Juan Rodriguez 2f14cd82dd fix: error handling override kemal default response 2024-07-14 11:19:59 +02:00
Juan Rodriguez faedd0bc7a fix: missing errors content type 2024-07-14 09:26:38 +02:00
Juan Rodriguez 1d207fae64 fix: log level error for production env 2024-07-14 09:26:25 +02:00
Juan Rodriguez a71f345f66 refactor: APP_URL + DATABASE_URL default values 2024-07-14 08:46:29 +02:00
Juan Rodriguez 7cc6c1197f refactor: auto run migrations on startup 2024-07-14 08:46:11 +02:00
Juan Rodriguez 115bbf7366 refactor: reduce docker image size 2024-07-12 23:18:06 +02:00
16 changed files with 171 additions and 94 deletions
-1
View File
@@ -1,6 +1,5 @@
.git
/bin/
/.shards/
/bruno/
/spec/
/sqlite/
+10 -3
View File
@@ -39,6 +39,15 @@ jobs:
VERSION=$(echo $VERSION | tr -d '\n\r')
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.0.0
@@ -46,9 +55,7 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
sjdonado/bit:latest
${{ github.event_name == 'release' && env.RELEASE_TAG && 'sjdonado/bit:${{ env.RELEASE_TAG }}' || '' }}
tags: sjdonado/bit:${{ env.TAGS }}
- name: Attest
uses: actions/attest-build-provenance@v1
+19 -9
View File
@@ -1,5 +1,6 @@
FROM alpine:edge as base
FROM alpine:edge AS build
ENV ENV=production
WORKDIR /usr/src/app
RUN apk update && apk add --no-cache \
@@ -9,20 +10,29 @@ RUN apk update && apk add --no-cache \
sqlite-dev \
openssl-dev
FROM base AS build
ENV ENV=production
COPY . .
COPY . .
RUN shards install
RUN shards build --progress
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
FROM base AS release
RUN mkdir -p /usr/src/app/sqlite
COPY --from=build /usr/src/app/db db
COPY --from=build /usr/src/app/data data
COPY --from=build /usr/src/app/bin /usr/local/bin
COPY --from=build /usr/src/app/data /usr/local/data
EXPOSE 4000/tcp
CMD ["bit"]
+38 -55
View File
@@ -1,6 +1,6 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit/general)
[![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit/general)
[![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/repository/docker/sjdonado/bit/general)
[![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit)
[![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit)
[![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/repository/docker/sjdonado/bit)
# Benchmark
@@ -39,16 +39,16 @@ Average Response Time: 12.37 µs
# Self-hosted
- Run via docker-compose
## Run via docker-compose
```bash
docker-compose up
docker-compose exec -it app migrate
# Generate an api key
docker-compose exec -it app cli --create-user=Admin
```
- Run via docker cli
## Run via docker cli
```bash
docker run \
@@ -59,11 +59,10 @@ docker run \
-e APP_URL="http://localhost:4000" \
sjdonado/bit
docker exec -it bit migrate
docker exec -it bit cli --create-user=Admin
```
- Dokku
## Dokku
```dockerfile
FROM sjdonado/bit
@@ -83,7 +82,6 @@ dokku config:set bit DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&s
dokku ports:add bit http:80:4000
dokku ports:add bit https:443:4000
dokku run bit migrate
dokku run bit cli --create-user=Admin
```
@@ -93,11 +91,9 @@ dokku run bit cli --create-user=Admin
1. **Ping the API**
- **Endpoint**: `/api/ping`
- **HTTP Method**: GET
- **Description**: Ping the API to check if it's running
- **Payload**: -
- **Response Example**:
- Endpoint: `GET /api/ping`
- Payload: None
- Response Example
```json
{
"message": "pong"
@@ -106,12 +102,10 @@ dokku run bit cli --create-user=Admin
2. **Retrieve a link by its slug**
- **Endpoint**: `/:slug`
- **HTTP Method**: GET
- **Description**: Retrieve a link by its slug
- **Payload**: -
- **Headers**: `X-Api-Key`
- **Response Example**:
- Endpoint: `GET /:slug`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": {
@@ -135,12 +129,10 @@ dokku run bit cli --create-user=Admin
3. **Retrieve all links**
- **Endpoint**: `/api/links`
- **HTTP Method**: GET
- **Description**: Retrieve all links
- **Payload**: -
- **Headers**: `X-Api-Key`
- **Response Example**:
- Endpoint: `GET /api/links`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": [
@@ -166,12 +158,10 @@ dokku run bit cli --create-user=Admin
4. **Retrieve a link by its ID**
- **Endpoint**: `/api/links/:id`
- **HTTP Method**: GET
- **Description**: Retrieve a link by its ID
- **Payload**: -
- **Headers**: `X-Api-Key`
- **Response Example**:
- Endpoint: `GET /api/links/:id`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": {
@@ -195,17 +185,15 @@ dokku run bit cli --create-user=Admin
5. **Create a new link**
- **Endpoint**: `/api/links`
- **HTTP Method**: POST
- **Description**: Create a new link
- **Payload**:
- Endpoint\*\*: `POST /api/links`
- Payload:
```json
{
"url": "https://example.com"
}
```
- **Headers**: `X-Api-Key`
- **Response Example**:
- Headers: `X-Api-Key`
- Response Example:
```json
{
"data": {
@@ -219,17 +207,15 @@ dokku run bit cli --create-user=Admin
6. **Update an existing link by its ID**
- **Endpoint**: `/api/links/:id`
- **HTTP Method**: PUT
- **Description**: Update an existing link by its ID
- **Payload**:
- Endpoint: `PUT /api/links/:id`
- Payload:
```json
{
"url": "https://newexample.com"
}
```
- **Headers**: `X-Api-Key`
- **Response Example**:
- Headers: `X-Api-Key`
- Response Example:
```json
{
"data": {
@@ -243,12 +229,10 @@ dokku run bit cli --create-user=Admin
7. **Delete a link by its ID**
- **Endpoint**: `/api/links/:id`
- **HTTP Method**: DELETE
- **Description**: Delete a link by its ID
- **Payload**: -
- **Headers**: `X-Api-Key`
- **Response Example**:
- Endpoint: `DELETE /api/links/:id`
- Payload: None
- Headers: `X-Api-Key`
- Response Example:
```json
{
"message": "Link deleted"
@@ -267,7 +251,7 @@ Options:
# Development
1. **Installation**
## Installation
```bash
brew tap amberframework/micrate
@@ -275,23 +259,22 @@ brew install micrate
```
```bash
shards run migrate
shards run bit
```
2. **Generate the `X-Api-Key`**
## Generate the `X-Api-Key`
```bash
shards run cli -- --create-user=Admin
```
# Run tests
## Run tests
```bash
ENV=test crystal spec
```
# Contributing
## Contributing
1. Fork it (<https://github.com/sjdonado/bit/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
+9
View File
@@ -1,6 +1,15 @@
require "log"
ENV["ENV"] ||= "development"
ENV["APP_URL"] ||= "http://localhost:4000"
ENV["DATABASE_URL"] ||= "sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
"
{% if env("ENV") != "production" %}
require "dotenv"
Dotenv.load ".env.#{ENV["ENV"]}" # File must exist in non-production!
{% end %}
{% if env("ENV") == "production" %}
Log.setup(:error)
{% end %}
+4 -2
View File
@@ -27,12 +27,14 @@ module App::Controllers::Link
link.url = url
link.user = user
attempts = 0
loop do
slug = Random::Secure.urlsafe_base64(4).gsub(/[^a-zA-Z0-9]/, "")
if !Database.get_by(Link, slug: slug)
slug = Random::Secure.urlsafe_base64(attempts >= 2 ? 6 : 5).gsub(/[^a-zA-Z0-9]/, "")
unless Database.get_by(Link, slug: slug)
link.slug = slug
break
end
attempts += 1
end
changeset = Database.insert(link)
+8
View File
@@ -1,5 +1,6 @@
require "sqlite3"
require "crecto"
require"micrate"
module App::Lib
class Database
@@ -14,5 +15,12 @@ module App::Lib
if ENV["ENV"] == "development"
Crecto::DbLogger.set_handler(STDOUT)
end
def self.run_migrations
Micrate::DB.connection_url = ENV["DATABASE_URL"]
Micrate::Cli.run_up
end
run_migrations
end
end
+25
View File
@@ -1,8 +1,18 @@
require "kemal"
module App
class InternalServerErrorException < Kemal::Exceptions::CustomException
def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 500
context.response.print({ "error" => "Internal Server Error" }.to_json)
super(context)
end
end
class BadRequestException < Kemal::Exceptions::CustomException
def initialize(context, message : String)
context.response.content_type = "application/json"
context.response.status_code = 400
context.response.print({ "error" => message }.to_json)
super(context)
@@ -11,13 +21,16 @@ module App
class UnauthorizedException < Kemal::Exceptions::CustomException
def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 401
context.response.print({ "error" => "Unauthorized access" }.to_json)
super(context)
end
end
class ForbiddenException < Kemal::Exceptions::CustomException
def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 403
context.response.print({ "error" => "Access not allowed" }.to_json)
super(context)
@@ -26,16 +39,28 @@ module App
class NotFoundException < Kemal::Exceptions::CustomException
def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 404
context.response.print({ "error" => "Resource not found" }.to_json)
super(context)
end
end
class UnprocessableEntityException < Kemal::Exceptions::CustomException
def initialize(context, message : Hash(String, Array(String)))
context.response.content_type = "application/json"
context.response.status_code = 422
context.response.print({ "errors" => message }.to_json)
super(context)
end
end
end
error 500 do |env|
App::InternalServerErrorException.new(env)
""
end
error 404 do |env|
""
end
+1 -1
View File
@@ -17,6 +17,6 @@ module App::Models
unique_constraint :slug
validate_required [:slug, :url]
validate_format :url, /\A(?:https?:\/\/)?(?:[\w-]+\.)+[\w-]+(?:\/\S*)?/
validate_format :url, /\Ahttps?:\/\/(?:[\w.-]+)(?::\d+)?(?:[\/?#]\S*)?\z/i
end
end
+1
View File
@@ -5,6 +5,7 @@ module App
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"
env.response.headers.delete("X-Powered-By")
end
after_all do |env|
-6
View File
@@ -134,12 +134,6 @@ if [ $? -ne 0 ]; then
exit 1
fi
docker-compose exec -T app migrate
if [ $? -ne 0 ]; then
echo "Failed to run database migrations."
exit 1
fi
# Create a new user and capture the API key
output=$(docker-compose exec -T app cli --create-user=Admin)
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
-4
View File
@@ -11,8 +11,4 @@ require "./app/routes"
add_context_storage_type(App::Models::User)
add_handler(App::Middlewares::Auth.new)
error 500 { |env| {"error" => "Internal Server Error" }.to_json}
error 401 { |env| {"error" => "Unauthorized" }.to_json}
error 404 { |env| {"error" => "Not Found" }.to_json}
Kemal.run
@@ -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;
+6 -3
View File
@@ -3,7 +3,10 @@ services:
build: .
environment:
ENV: production
DATABASE_URL: sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
APP_URL: http://0.0.0.0:4001
ports:
- 4001:4000
- 4000:4000
volumes:
- sqlite_data:/app/sqlite
volumes:
sqlite_data:
-7
View File
@@ -1,7 +0,0 @@
require "sqlite3"
require"micrate"
require "../app/config/env"
Micrate::DB.connection_url = ENV["DATABASE_URL"]
Micrate::Cli.run_up
+1 -3
View File
@@ -1,5 +1,5 @@
name: bit
version: 1.1.0
version: 1.2.0
authors:
- Juan Rodriguez <sjdonado@icloud.com>
@@ -9,8 +9,6 @@ targets:
main: bit.cr
cli:
main: scripts/cli.cr
migrate:
main: scripts/migrate.cr
dependencies:
kemal: