Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bcb1308
fix: update deployment workflow to trigger on develop branch
stawan15 Jul 16, 2026
b2edf99
fix: update deployment workflow to push to develop branch instead of …
stawan15 Jul 16, 2026
0f69dc5
fix: update deployment workflow to push to develop branch instead of …
stawan15 Jul 16, 2026
35b2edb
fix: correct typo in submit button text from "Shorten URL" to "Shorte…
stawan15 Jul 16, 2026
ff06cf7
fix: update deployment script to push to main branch instead of develop
stawan15 Jul 16, 2026
738d275
fix: correct submit button text from "Shorten UL" to "Shorten URL"
stawan15 Jul 16, 2026
e885ce1
feat: add URL lengthening and expanding functionality with correspond…
stawan15 Jul 16, 2026
da65c1f
fix: update workflow name from "Deploy" to "Deploy-feature"
stawan15 Jul 16, 2026
95807db
feat: implement tools card for URL lengthening and expanding with fil…
stawan15 Jul 16, 2026
914346a
fix: enhance body styles for better text overflow handling
stawan15 Jul 16, 2026
6e191ae
feat: implement request throttling for URL expansion to prevent abuse
stawan15 Jul 16, 2026
6c5055b
fix: improve error handling in request throttling for URL expansion
stawan15 Jul 16, 2026
03cac49
feat: enhance body styles to limit text overflow with line clamping
stawan15 Jul 16, 2026
2848f68
fix: update viewport meta tag to improve mobile responsiveness
stawan15 Jul 16, 2026
843bcde
Merge pull request #15 from stawan15/feature/long-url
stawan15 Jul 16, 2026
8bb9f48
feat: auto-refresh page after displaying long URL for better user exp…
stawan15 Jul 16, 2026
e19f7e2
Merge pull request #16 from stawan15/feature/lengthen-updated
stawan15 Jul 16, 2026
a878157
fix: simplify URL result handling by removing redundant updates and a…
stawan15 Jul 16, 2026
5f463bd
feat: implement ActionCable channels for real-time URL updates and cr…
stawan15 Jul 16, 2026
3cdc699
Merge pull request #17 from stawan15/feature/lengthen-no-show
stawan15 Jul 16, 2026
73d118a
fix: update predeploy script to use db:prepare instead of db:migrate
stawan15 Jul 16, 2026
81eea89
Merge pull request #18 from stawan15/feature/lengthen-no-show
stawan15 Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/deploy-feature.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Deploy-feature

on:
push:
branches: [ feature/* ]

jobs:
deploy:
runs-on: self-hosted

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Push to Dokku
run: |
git remote add dokku dokku@localhost:${{ secrets.DOKKU_APP }} 2>/dev/null || true
git push dokku HEAD:main --force
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Deploy

on:
push:
branches: [ main ]
branches: [ develop ]

jobs:
deploy:
Expand All @@ -17,4 +17,4 @@ jobs:
- name: Push to Dokku
run: |
git remote add dokku dokku@localhost:${{ secrets.DOKKU_APP }} 2>/dev/null || true
git push dokku main:main --force
git push dokku HEAD:main --force
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "URL Shortener built with Rails",
"scripts": {
"dokku": {
"predeploy": "bundle exec rails db:migrate"
"predeploy": "bundle exec rails db:prepare"
}
},
"formation": {
Expand Down
120 changes: 120 additions & 0 deletions app/assets/stylesheets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,31 @@ body {
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.links-filter {
margin-left: auto;
display: flex;
gap: 0.3rem;
}
.filter-btn {
background: none;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-btn:hover {
border-color: var(--blue);
color: var(--blue);
}
.filter-btn.active {
background: var(--blue);
border-color: var(--blue);
color: #fff;
font-weight: 500;
}

.links-title {
font-size: 1.6rem;
Expand Down Expand Up @@ -423,6 +448,11 @@ body {
color: var(--blue);
text-decoration: none;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.link-short:hover { text-decoration: underline; }

Expand Down Expand Up @@ -867,3 +897,93 @@ body {
.link-date { display: none; }
#version-banner { white-space: normal; width: calc(100% - 2rem); }
}

/* ── Tools card (Lengthen / Expand tabs) ────────────────────────────── */
.tools-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r);
max-width: 720px;
margin: 0 auto 1.5rem;
box-shadow: 0 1px 6px rgba(0,0,0,.06);
overflow: hidden;
}
.tools-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tools-tab {
flex: 1;
background: none;
border: none;
padding: 0.7rem 1rem;
font-size: 0.88rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.tools-tab:hover { color: var(--text); background: var(--hover, rgba(0,0,0,.03)); }
.tools-tab.active { color: var(--blue); border-bottom: 2px solid var(--blue); margin-bottom: -1px; }
.tools-panel { padding: 1.25rem 2rem 1.5rem; }

/* ── Expand URL section ──────────────────────────────────────────────── */
.expand-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 1.5rem 2rem;
max-width: 720px;
margin: 0 auto 1.5rem;
box-shadow: 0 1px 6px rgba(0,0,0,.06);
}
.expand-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.expand-hint {
font-size: 0.82rem;
color: var(--muted);
margin-bottom: 0.75rem;
}
.expand-row {
display: flex;
border: 1px solid var(--border);
border-radius: var(--r);
overflow: hidden;
background: var(--card);
}
.expand-row:focus-within {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
}
.expand-row .form-input {
border: none;
box-shadow: none;
border-radius: 0;
flex: 1;
}
#expand-result {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.expand-result-label {
font-size: 0.82rem;
color: var(--muted);
white-space: nowrap;
}
.expand-result-link {
font-size: 0.88rem;
color: var(--blue);
word-break: break-all;
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.expand-result-link:hover { text-decoration: underline; }
4 changes: 4 additions & 0 deletions app/channels/application_cable/channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
4 changes: 4 additions & 0 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
99 changes: 99 additions & 0 deletions app/controllers/urls_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
require "net/http"

class UrlsController < ApplicationController
before_action :authenticate_user!, only: [:destroy, :analytics]
before_action :set_url, only: [:destroy, :analytics, :qr_code]
before_action :throttle_expand!, only: [:expand]

def index
@url = Url.new
Expand All @@ -19,6 +22,7 @@ def create
@url.user = current_user if user_signed_in?

if @url.save
broadcast_url_to_user(@url)
if user_signed_in?
redirect_to root_path, notice: "Short URL created successfully!"
else
Expand Down Expand Up @@ -46,6 +50,41 @@ def analytics
@recent_clicks = @url.click_events.order(created_at: :desc).limit(50)
end

def lengthen
raw = params[:url].to_s.strip
raw = "https://#{raw}" unless raw.match?(/\Ahttps?:\/\//i)

@url = Url.new(original_url: raw)
@url.user = current_user if user_signed_in?

# Override auto-generated short_code with a very long one
@url.short_code = generate_long_code

if @url.save
broadcast_url_to_user(@url)
render json: { long_url: short_url_for(@url) }
else
render json: { error: @url.errors.full_messages.first }, status: :unprocessable_entity
end
end

def expand
raw = params[:url].to_s.strip
raw = "https://#{raw}" unless raw.match?(/\Ahttps?:\/\//i)

begin
uri = URI.parse(raw)
raise ArgumentError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError, ArgumentError
return render json: { error: "Invalid URL" }, status: :unprocessable_entity
end

final_url = follow_redirects(uri)
render json: { original: raw, expanded: final_url }
rescue => e
render json: { error: "Could not expand URL: #{e.message}" }, status: :unprocessable_entity
end

def qr_code
qr = RQRCode::QRCode.new(short_url_for(@url))
svg = qr.as_svg(
Expand Down Expand Up @@ -75,6 +114,66 @@ def set_url
end
end

PRIVATE_IP_PATTERNS = [
/\A127\./,
/\A10\./,
/\A172\.(1[6-9]|2[0-9]|3[01])\./,
/\A192\.168\./,
/\A::1\z/,
/\Alocalhost\z/i
].freeze

MAX_REDIRECTS = 10

def follow_redirects(uri, hops = 0)
raise "Too many redirects" if hops >= MAX_REDIRECTS

host = uri.host.to_s
raise "Requests to private addresses are not allowed" if PRIVATE_IP_PATTERNS.any? { |p| p.match?(host) }

use_ssl = uri.scheme == "https"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl, open_timeout: 5, read_timeout: 5) do |http|
http.head(uri.request_uri)
end

case response
when Net::HTTPRedirection
location = response["location"]
next_uri = URI.parse(location)
next_uri = uri.merge(next_uri) if next_uri.relative?
follow_redirects(next_uri, hops + 1)
else
uri.to_s
end
end

def generate_long_code
loop do
code = SecureRandom.alphanumeric(rand(200..300))
return code unless Url.exists?(short_code: code)
end
end

EXPAND_RATE_LIMIT = 10 # requests per window
EXPAND_RATE_WINDOW = 60 # seconds

def throttle_expand!
key = "expand:#{request.remote_ip}"
count = Rails.cache.increment(key, 1, expires_in: EXPAND_RATE_WINDOW) rescue nil
return unless count.is_a?(Integer) && count > EXPAND_RATE_LIMIT
render json: { error: "Too many requests. Please slow down." }, status: :too_many_requests
end

def broadcast_url_to_user(url)
return unless url.user_id.present?
Turbo::StreamsChannel.broadcast_prepend_to(
"urls_#{url.user_id}",
target: "urls_list",
partial: "urls/url",
locals: { url: url }
)
end

def permitted_url_params
allowed = [:original_url]
allowed << :short_code if user_signed_in?
Expand Down
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title><%= content_for(:title) || "Shorty — URL Shortener" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="description" content="Shorty - fast URL shortener with click analytics and QR codes.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
Expand Down
27 changes: 27 additions & 0 deletions app/views/urls/_url.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<% kind = url.short_code.length > 20 ? "long" : "short" %>
<div class="link-card" data-url-id="<%= url.id %>" data-kind="<%= kind %>">
<%# Left: URL info — always visible %>
<div class="link-card-body">
<div class="link-card-top">
<%= link_to short_url_for(url), redirect_short_url_path(url.short_code),
target: "_blank", rel: "noopener noreferrer", class: "link-short" %>
<button class="copy-btn" data-url="<%= short_url_for(url) %>">Copy</button>
</div>
<%= link_to truncate(url.original_url, length: 72), url.original_url,
target: "_blank", rel: "noopener noreferrer", class: "link-original",
title: url.original_url %>
</div>

<%# Right: stats + actions %>
<div class="link-card-side">
<span class="link-clicks"><%= url.clicks %> <%= url.clicks == 1 ? "click" : "clicks" %></span>
<span class="link-date"><%= url.created_at.strftime("%b %d") %></span>
<div class="link-actions">
<%= link_to "Stats", analytics_url_path(url), class: "link-action" %>
<%= link_to "QR", qr_code_url_path(url), class: "link-action" %>
<%= link_to "Delete", url_path(url),
class: "link-action link-action-delete",
data: { turbo_method: :delete, turbo_confirm: "Delete this link?" } %>
</div>
</div>
</div>
Loading