Python ও SQLite দিয়ে URL shortener বানান (test সহ)

ক্লাসিক প্রজেক্ট যা HTTP, redirect, database ও ইনপুট যাচাই শেখায়। কোড প্রকাশের আগে চালিয়ে পরীক্ষা করা হয়েছে।

ছোট code লম্বা URL-এ redirect করছে এমন একটি terminal

URL shortener লম্বা লিংককে /wZUSXi-র মতো ছোট code বানায় এবং দর্শককে আসল ঠিকানায় পাঠায়। এটি ক্লাসিক প্রজেক্ট কারণ এতে ওয়েবের আসল ধারণা ছোঁয়া যায়: HTTP method, redirect, status code, database ও ইনপুট যাচাই। এই সংস্করণ শুধু Python-এর standard library ও SQLite ব্যবহার করে, তাই কিছু install করতে হয় না। কোড ও নিচের আউটপুট প্রকাশের আগে Python 3-এ চালানো হয়েছে।

এটি কীভাবে কাজ করে

১. Client JSON {"url": "https://..."} দিয়ে POST /shorten পাঠায়। ২. Server URL যাচাই করে, ৬ অক্ষরের random code বানায় এবং SQLite-এ code -> url রাখে। ৩. যে GET /<code> খোলে তাকে HTTP 302 redirect দিয়ে আসল URL-এ পাঠানো হয়। ৪. প্রতিটি ভিজিটে hit counter বাড়ে।

কোড

shortener.py নামে সংরক্ষণ করুন:

import json
import os
import secrets
import sqlite3
import string
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse

ALPHABET = string.ascii_letters + string.digits


def make_db(path=":memory:"):
    db = sqlite3.connect(path, check_same_thread=False)
    db.execute("CREATE TABLE IF NOT EXISTS links (code TEXT PRIMARY KEY, url TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0)")
    return db


def valid_url(url):
    parts = urlparse(url)
    return parts.scheme in ("http", "https") and bool(parts.netloc) and len(url) <= 2000


def create_link(db, url):
    for _ in range(5):  # a collision is very unlikely, but retry just in case
        code = "".join(secrets.choice(ALPHABET) for _ in range(6))
        try:
            with db:
                db.execute("INSERT INTO links (code, url) VALUES (?, ?)", (code, url))
            return code
        except sqlite3.IntegrityError:
            continue
    raise RuntimeError("could not generate a unique code")


def resolve(db, code):
    with db:
        db.execute("UPDATE links SET hits = hits + 1 WHERE code = ?", (code,))
    row = db.execute("SELECT url FROM links WHERE code = ?", (code,)).fetchone()
    return row[0] if row else None


def make_handler(db):
    class Handler(BaseHTTPRequestHandler):
        def _send(self, status, body=b"", headers=None):
            self.send_response(status)
            for key, value in (headers or {}).items():
                self.send_header(key, value)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def do_POST(self):
            if self.path != "/shorten":
                return self._send(404)
            length = int(self.headers.get("Content-Length", 0))
            if length > 10_000:
                return self._send(413)
            try:
                url = json.loads(self.rfile.read(length)).get("url", "")
            except (ValueError, AttributeError):
                return self._send(400, b'{"error":"invalid JSON"}', {"Content-Type": "application/json"})
            if not valid_url(url):
                return self._send(400, b'{"error":"url must start with http:// or https://"}', {"Content-Type": "application/json"})
            body = json.dumps({"code": create_link(db, url)}).encode()
            self._send(201, body, {"Content-Type": "application/json"})

        def do_GET(self):
            url = resolve(db, self.path.lstrip("/"))
            if url is None:
                return self._send(404, b"Not found")
            self._send(302, headers={"Location": url})

        def log_message(self, *args):
            pass  # keep the console quiet

    return Handler


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8000))
    print(f"Short links on http://127.0.0.1:{port}")
    HTTPServer(("127.0.0.1", port), make_handler(make_db("links.db"))).serve_forever()

চালান

$ PORT=8931 python3 shortener.py
Short links on http://127.0.0.1:8931

$ curl -X POST localhost:8931/shorten -d '{"url":"https://example.com/docs/getting-started"}'
{"code": "wZUSXi"}

$ curl -i localhost:8931/wZUSXi
HTTP/1.0 302 Found
Location: https://example.com/docs/getting-started

$ curl -X POST localhost:8931/shorten -d '{"url":"javascript:alert(1)"}'
{"error":"url must start with http:// or https://"}        (status 400)

$ curl localhost:8931/zzzzzz
Not found                                                  (status 404)

$ curl -X POST localhost:8931/shorten -d 'oops'
{"error":"invalid JSON"}                                   (status 400)

(Code random, তাই আপনারটি আলাদা হবে।)

কেন এভাবে বানানো

secrets দিয়ে random code। ৬২টি অক্ষর (বর্ণ ও অঙ্ক) থেকে ছয়টি দিলে প্রায় ৫,৬০০ কোটি সমন্বয় হয়। random-এর বদলে secrets module ব্যবহার করি যাতে code অনুমানযোগ্য না হয়: ক্রমিক id (১, ২, ৩...) হলে যে কেউ সব লিংক ঘুরে দেখতে পারত। code-এ primary key অনন্যতা নিশ্চিত করে, আর বিরল সংঘর্ষে loop আবার চেষ্টা করে।

URL যাচাই একটি নিরাপত্তা বৈশিষ্ট্য। পরীক্ষাটি hostname সহ শুধু http ও https অনুমতি দেয়। এটি না থাকলে কেউ javascript:alert(1) বা file: ঠিকানা জমা রাখতে পারত, আর আপনার redirect ব্যবহারকারীকে বিপজ্জনক কিছুর দিকে পাঠাত। test প্রমাণ করে এগুলো 400 দিয়ে প্রত্যাখ্যাত হয়। দৈর্ঘ্যের সীমা ও ১০,০০০ বাইটের body সীমাও লক্ষ্য করুন, যাতে কেউ বিশাল data জমা রাখতে না পারে।

302 বনাম 301। 302 (অস্থায়ী) redirect ব্যবহার করা হয় যাতে browser তা চিরকাল cache না করে, ফলে hit গোনা এবং পরে লিংক বদলানো বা বন্ধ করা যায়। 301 (স্থায়ী) browser ও search engine cache করে।

Parameterised SQL। প্রতিটি query ? placeholder ব্যবহার করে, কখনো string formatting নয়, যা SQL injection ঠেকায়।

Hit গণনা একটি UPDATE ... SET hits = hits + 1, যা database-এ atomic। মান পড়ে Python-এ এক যোগ করলে দুটি request একসঙ্গে এলে গণনা হারাতে পারত।

নীরব logging। log_message শুধু ডেমো পরিষ্কার রাখতে override করা। আসল সেবায় access log রাখবেন।

Test

Test আসল server একটি ফাঁকা port-এ (0 দিলে অপারেটিং সিস্টেম বাছে) background thread-এ চালু করে, আর in-memory database ব্যবহার করে যাতে আসল data ছোঁয় না। test_shortener.py নামে সংরক্ষণ করুন:

import http.client
import json
import threading
import unittest
from http.server import HTTPServer

import shortener


class ShortenerTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.db = shortener.make_db()
        cls.server = HTTPServer(("127.0.0.1", 0), shortener.make_handler(cls.db))
        cls.port = cls.server.server_address[1]
        threading.Thread(target=cls.server.serve_forever, daemon=True).start()

    @classmethod
    def tearDownClass(cls):
        cls.server.shutdown()

    def request(self, method, path, body=None):
        conn = http.client.HTTPConnection("127.0.0.1", self.port)
        conn.request(method, path, body=json.dumps(body) if body is not None else None)
        return conn.getresponse()

    def test_shorten_then_redirect(self):
        res = self.request("POST", "/shorten", {"url": "https://example.com/a/very/long/path"})
        self.assertEqual(res.status, 201)
        code = json.loads(res.read())["code"]
        self.assertEqual(len(code), 6)
        redirect = self.request("GET", "/" + code)
        self.assertEqual(redirect.status, 302)
        self.assertEqual(redirect.getheader("Location"), "https://example.com/a/very/long/path")

    def test_rejects_dangerous_or_bad_urls(self):
        for bad in ["javascript:alert(1)", "ftp://x.com/file", "not a url", ""]:
            self.assertEqual(self.request("POST", "/shorten", {"url": bad}).status, 400, bad)

    def test_unknown_code_is_404(self):
        self.assertEqual(self.request("GET", "/nope12").status, 404)

    def test_counts_hits(self):
        code = shortener.create_link(self.db, "https://example.org")
        self.request("GET", "/" + code).read()
        self.request("GET", "/" + code).read()
        self.assertEqual(self.db.execute("SELECT hits FROM links WHERE code=?", (code,)).fetchone()[0], 2)


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest -v
test_counts_hits ... ok
test_rejects_dangerous_or_bad_urls ... ok
test_shorten_then_redirect ... ok
test_unknown_code_is_404 ... ok

Ran 4 tests in 0.028s

OK

জানা সীমা (আসল কাজে ব্যবহারের আগে পড়ুন)

  • http.server শেখার জন্য, production-এর জন্য নয়। এটি সরলভাবে request সামলায় এবং শক্ত করা নয়। আসল ব্যবহারে একই যুক্তি framework-এ (Flask, FastAPI) বসিয়ে যথাযথ server-এর পেছনে চালান।
  • Authentication নেই। যে কেউ লিংক বানাতে পারে। খোলা shortener প্রায়ই spam ও phishing-এ অপব্যবহৃত হয়। আসল সেবায় login, rate limit এবং খারাপ লিংক রিপোর্ট ও সরানোর ব্যবস্থা থাকে।
  • Rate limiting নেই। একজন client আপনার database ভরে ফেলতে পারে।
  • Custom code, মেয়াদ বা counter-এর বেশি analytics নেই।
  • Query string থেকে redirect-এর লক্ষ্য নিলে open redirect ঝুঁকি। এখানে লক্ষ্য শুধু সংরক্ষিত, যাচাই করা লিংক থেকে আসে।
  • শুধু সংরক্ষিত URL যাচাই হয়, তার পেছনে কী আছে নয়। বৈধ https লিংকও ক্ষতিকর সাইটে নিতে পারে।

চ্যালেঞ্জ

১. hit সংখ্যা JSON-এ ফেরত দেয় এমন GET /stats/<code> যোগ করুন। ২. ব্যবহারকারীকে নিজের code বাছতে দিন (নেওয়া হয়নি কি না দেখুন এবং অনুমোদিত অক্ষর সীমিত করুন)। ৩. মেয়াদ যোগ করুন এবং তার পর 410 Gone ফেরত দিন। ৪. প্রতি IP-তে rate limit যোগ করুন। ৫. handler Flask বা FastAPI-তে আবার লিখে দেখুন framework কতটা কোড বাঁচায়। ৬. সাধারণ HTML form যোগ করুন যাতে মানুষ browser-এ ব্যবহার করতে পারে।

মূল কথা: URL shortener ছোট প্রজেক্ট, কিন্তু আসল শিক্ষা দেয়: অনুমান-অযোগ্য id, কঠোর URL যাচাই, সঠিক status code, atomic counter ও test। যেকোনো প্রকাশ্য "লিংক বানান" সুবিধাকে এমন কিছু ভাবুন যা আক্রমণকারী অপব্যবহার করার চেষ্টা করবে।


হাতে-কলমে প্রজেক্ট সিরিজ, পর্ব ২ (পুনর্ব্যবহারযোগ্য টুল): 1 নম্বর (মোট 5)।

আরও পড়ুন

Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট

আসল data এলোমেলো। standard library দিয়ে contacts পরিষ্কার করা, প্রতিটি বাতিল row ব্যাখ্যা করা ও test-সহ একটি script বানান।

প্রজেক্ট•৫ মিনিটে পড়া