195 lines
6.2 KiB
Crystal
195 lines
6.2 KiB
Crystal
module App::Controllers
|
|
class LinkController < App::Lib::BaseController
|
|
include App::Models
|
|
include App::Lib
|
|
include App::Services
|
|
|
|
def initialize(@env : HTTP::Server::Context)
|
|
ClickTracker.init
|
|
super(@env)
|
|
end
|
|
|
|
def create
|
|
body = parse_body(["url"])
|
|
url = body["url"].to_s
|
|
|
|
query = Database::Query.where(url: url, user_id: current_user_id).limit(1)
|
|
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
|
if existing_link
|
|
return render_json({"data" => App::Serializers::Link.new(existing_link)})
|
|
end
|
|
|
|
link = Link.new
|
|
link.url = url
|
|
link.user_id = current_user_id
|
|
link.slug = SlugService.shorten_url(url, current_user_id)
|
|
|
|
changeset = Database.insert(link)
|
|
if !changeset.valid?
|
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
|
end
|
|
|
|
inserted_link = Database.get!(Link, changeset.instance.id)
|
|
|
|
render_json({"data" => App::Serializers::Link.new(inserted_link)}, 201)
|
|
end
|
|
|
|
def redirect
|
|
slug = @env.params.url["slug"]
|
|
|
|
link_data = nil
|
|
# LIMIT 1 degrades performance on unique field searches
|
|
# slug autoindex has better perormance than the covering index
|
|
Database.raw_query("SELECT id, url FROM links WHERE slug = (?)", slug) do |result|
|
|
if result.move_next
|
|
link_data = {result.read(Int64), result.read(String)}
|
|
end
|
|
end
|
|
raise App::NotFoundException.new(@env) unless link_data
|
|
|
|
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["Connection"] = "close"
|
|
|
|
@env.response.headers["Location"] = link_data[1]
|
|
@env.response.headers["X-Forwarded-For"] = client_ip
|
|
@env.response.headers["User-Agent"] = user_agent_str
|
|
|
|
spawn track_click(link_data[0], client_ip, user_agent_str)
|
|
end
|
|
|
|
def list_all
|
|
limit, cursor = pagination_params
|
|
|
|
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)
|
|
|
|
links = Database.all(Link, query)
|
|
|
|
paginated_response(links, limit) { |link| App::Serializers::Link.new(link) }
|
|
end
|
|
|
|
def get
|
|
link_id = @env.params.url["id"].to_i64
|
|
|
|
query = Database::Query.where(id: link_id, user_id: current_user_id).limit(1)
|
|
link = Database.all(Link, query).first?
|
|
raise App::NotFoundException.new(@env) if link.nil?
|
|
|
|
clicks_query = Database::Query.where(link_id: link_id)
|
|
.order_by("id DESC")
|
|
.limit(100)
|
|
link.clicks = Database.all(Click, clicks_query)
|
|
|
|
render_json({"data" => App::Serializers::Link.new(link)})
|
|
end
|
|
|
|
def list_clicks
|
|
link_id = @env.params.url["id"].to_i64
|
|
|
|
# Verify link exists and belongs to user
|
|
link_query = Database::Query.where(id: link_id, user_id: current_user_id).limit(1)
|
|
link = Database.all(Link, link_query).first?
|
|
raise App::NotFoundException.new(@env) if link.nil?
|
|
|
|
limit, cursor = pagination_params
|
|
|
|
query = Database::Query.where(link_id: link_id)
|
|
query = query.where("id < ?", cursor) if cursor
|
|
query = query.order_by("id DESC").limit(limit + 1)
|
|
|
|
clicks = Database.all(Click, query)
|
|
|
|
paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) }
|
|
end
|
|
|
|
def update
|
|
id = @env.params.url["id"].to_i64
|
|
body = parse_body(["url"])
|
|
new_url = body["url"].to_s
|
|
|
|
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 != current_user_id
|
|
|
|
# Check for existing URL
|
|
existing_query = Database::Query.where(url: new_url, user_id: current_user_id).limit(1)
|
|
if Database.all(Link, existing_query).first?
|
|
raise App::UnprocessableEntityException.new(@env, { "url" => ["URL already exists"] })
|
|
end
|
|
|
|
link.url = new_url
|
|
link.slug = SlugService.shorten_url(new_url, current_user_id)
|
|
|
|
changeset = Database.update(link)
|
|
if !changeset.valid?
|
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
|
end
|
|
|
|
render_json({"data" => App::Serializers::Link.new(link)})
|
|
end
|
|
|
|
def delete
|
|
id = @env.params.url["id"].to_i64
|
|
|
|
link = Database.get(Link, id)
|
|
raise App::NotFoundException.new(@env) if !link
|
|
raise App::ForbiddenException.new(@env) if link.user_id != current_user_id
|
|
|
|
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id)
|
|
if result.rows_affected == 0
|
|
raise App::UnprocessableEntityException.new(@env, { "id" => ["Row delete failed"] })
|
|
end
|
|
|
|
@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 track_click(link_id, client_ip, user_agent_str)
|
|
source = @env.params.query["utm_source"]? || "Direct"
|
|
referer = @env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source
|
|
|
|
ClickTracker.track(
|
|
link_id: link_id,
|
|
client_ip: client_ip,
|
|
user_agent: user_agent_str,
|
|
source: source,
|
|
referer: referer
|
|
)
|
|
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
|