Dup Goto 📝

SimpleUrlShortener

PT2/lang/php/web 01-20 19:00:38
To Pop
476 lines, 1592 words, 13121 chars Tuesday 2026-01-20 19:00:38

Simple url shortener backend

This is only intended for personal use, possibly giving urls to a few friends. Nothing that requires scaling.

This is made by Gemini using the following prompts.

The frontend then sends JSON to the backend of the form

{
    "short": "never",
    "url": "https://gonna.give.you.up/"
}

To delete, set the url to !delete.

Then one can write a simple frontend with Javascript.

CORS headers are sent in the cors.php file if needed (e.g. if the add short interface is on a different domain than the get short frontend, nice for security as the main user-facing frontend domain has no capacity to add or delete).

<?php
header("Access-Control-Allow-Origin: *"); // or specify your domain, e.g. "https://example.com"
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");

A backend file for a given table then needs to be merely

<?php
define('PREFIX','bobbins');
require('short_be.php');

A frontend script does

<?php
define('BACKEND',"never_gonna.php");
define('LIST_URL',"https://give.you.up/.list");
define('SHORT_URL_PREFIX','https://never.gonna.let.you.down.com/');
require_once("add_short_common.php");

1 Prompt for Gemini

A php class which manages shortened urls. The backend database connection credentials etc are in dbauth.php. Then there is a PREFIX defined for the tables. Table is named PREFIX_short_to_url. This table has two elements: short and url. If the url to be stored is !delete, then the short is deleted. A web client sends POST requests in json of the form { "short": "abc", "url": "https://domain/..." }. Domains main be local to the lan, so needn't contain a period. Either the url is a valid url, or else it is !delete. Anything else and an error is returned. The backend returns a json object, either { "message": "added abc => url", "short": short, "url": url }, or { "message": "deleted abc" } or { "error": "error message" . So a success is indicated by the presence of a "message" element, or else there is an "error" element. If there is a PHP error of some sort, the client should write this to the console. The top of the PHP backend has an easy way to enable/disable detailed error printing in the case of PHP errors. The backend database is Mysql.

2 Prompt for Gemini

The dbauth.php defines define('HOST',"..."); define('PORT',...); define('USER',"..."); define('PASS',"..."); define('DB',"...");

Example reporting errors (from Gemini)

fetch('api/index.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ short: 'nas', url: 'http://nas-server/storage' })
})
.then(response => {
    if (!response.ok) {
        // This catches 500 errors and logs the PHP output to console
        response.text().then(text => console.error("PHP Error:", text));
    }
    return response.json();
})
.then(data => {
    if (data.message) console.log("Success:", data.message);
    if (data.error) console.warn("App Error:", data.error);
});

table create (from Gemini)

CREATE TABLE IF NOT EXISTS `YOURPREFIX_short_to_url` ( `short` VARCHAR(191) NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY (`short`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

table create php (from Gemini)

<?php
// Enable error reporting for setup
ini_set('display_errors', 1);
error_reporting(E_ALL);

require_once("dbauth.php");

try {
    // 1. Connect to MySQL
    $mysqli = new mysqli(HOST, USER, PASS, DB, PORT);

    if ($mysqli->connect_error) {
        throw new Exception("Connection failed: " . $mysqli->connect_error);
    }

    // 2. Prepare the Table Name
    $tableName = PREFIX . "_short_to_url";

    // 3. Define the SQL
    // We use VARCHAR(191) for 'short' to ensure compatibility with utf8mb4 indexing
    $sql = "CREATE TABLE IF NOT EXISTS `$tableName` (
        `short` VARCHAR(191) NOT NULL,
        `url` TEXT NOT NULL,
        PRIMARY KEY (`short`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";

    // 4. Execute
    if ($mysqli->query($sql) === TRUE) {
        echo "<h3>Success!</h3>";
        echo "Table <strong>$tableName</strong> is ready.";
    } else {
        throw new Exception("Error creating table: " . $mysqli->error);
    }

    $mysqli->close();

} catch (Exception $e) {
    echo "<h3>Setup Failed</h3>";
    echo "Error: " . $e->getMessage();
}

PHP Backend

<?php
/**
 * Error Reporting Toggle
 */
$DEBUG_MODE = true; 

if ($DEBUG_MODE) {
    ini_set('display_errors', 1);
    ini_set('display_startup_errors', 1);
    error_reporting(E_ALL);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

// Security and Credentials
require_once("cors.php");
require_once("bouncer.php");
require_once("dbauth.php"); 

header('Content-Type: application/json');

/**
 * URL Manager Class
 */
class UrlManager {
    private $db;
    private $table;

    public function __construct() {
        // Using the constants defined in dbauth.php
        $this->db = new mysqli(HOST, USER, PASS, DB, PORT);

        if ($this->db->connect_error) {
            throw new Exception("Connection failed: " . $this->db->connect_error);
        }

        // table name: PREFIX_short_to_url
        $this->table = PREFIX . "_short_to_url";
    }

    public function handleRequest($jsonInput) {
        $data = json_decode($jsonInput, true);

        // Validation: Basic structure
        if (!$data || !isset($data['short']) || !isset($data['url'])) {
            return ["error" => "Invalid JSON: 'short' and 'url' fields required."];
        }

        $short = trim($data['short']);
        $url = trim($data['url']);

        if (empty($short)) {
            return ["error" => "The 'short' value cannot be empty."];
        }

        // Action: Delete
        if ($url === "!delete") {
            return $this->deleteShort($short);
        }

        // Action: Add/Update
        // Validates protocol; accepts LAN domains (no period required)
        if (preg_match('~^(?:f|ht)tps?://~i', $url)) {
            return $this->upsertShort($short, $url);
        }

        return ["error" => "Invalid URL format or command."];
    }

    private function upsertShort($short, $url) {
        $sql = "INSERT INTO {$this->table} (short, url) VALUES (?, ?) 
                ON DUPLICATE KEY UPDATE url = VALUES(url)";

        $stmt = $this->db->prepare($sql);
        $stmt->bind_param("ss", $short, $url);

        if ($stmt->execute()) {
            return [
                "message" => "added $short => $url",
                "short" => $short,
                "url" => $url
            ];
        }
        return ["error" => "DB Execution failed: " . $this->db->error];
    }

    private function deleteShort($short) {
        $stmt = $this->db->prepare("DELETE FROM {$this->table} WHERE short = ?");
        $stmt->bind_param("s", $short);

        if ($stmt->execute()) {
            return ["message" => "deleted $short"];
        }
        return ["error" => "DB Execution failed: " . $this->db->error];
    }
}

/**
 * Execution
 */
try {
    $manager = new UrlManager();
    $rawInput = file_get_contents('php://input');
    $response = $manager->handleRequest($rawInput);
    echo json_encode($response);
} catch (Exception $e) {
    // If PHP crashes, this is caught here. 
    // Client logs response text to console.
    http_response_code(500);
    echo json_encode(["error" => $e->getMessage()]);
}

dbauth.php defines the constants for the database HOST, USER, PASS, DB, PORT. bouncer.php is arbitrary PHP code that determines if the request is authorised, and errors out if not.

add_short_common.php - Web frontend to set shorts

<?php
require_once("bouncer.php");
$qs = $_SERVER["QUERY_STRING"];
if( $qs != "" ) {
  $qs = urldecode($qs);
  $qs = preg_replace("@['\"]@","",$qs);
  $val = "value='$qs'";
  $af1 = "";
  $af2 = "autofocus";
} else {
  $val = "";
  $af1 = "autofocus";
  $af2 = "";
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>URL Shortener</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 2em auto; }
label { display: block; margin-top: 1em; }
input { width: 100%; padding: 0.5em; }
button { margin-top: 1em; padding: 0.5em 1em; }
#message { margin-top: 1em; font-weight: bold; }
</style>
</head>
<body>

<h1>Add a Short URL</h1>

<label for="short">Short code:</label>
<input type="text" id="short" placeholder="e.g. abc123, leave blank for auto generate" <?php echo $af1." ".$val; ?>>

<label for="url">Full URL:</label>
<input type="text" id="url" placeholder="e.g. https://example.com/page" <?php echo $af2; ?>>

<button id="submit">Save</button>
<div id="message"></div>

<script>
function getmods(e) {
  let t = ""
  if( e.shiftKey ) t = "S-"+t
  if( e.metaKey ) t = "M-"+t
  if( e.ctrlKey ) t = "C-"+t
  if( e.altKey ) t = "A-"+t
  return t
}

function acms(e) {
    let key = e.key
    const code = e.code.toLowerCase()
    if( code === "backquote" ) { key = "`" }
    if( code.startsWith("digit") ) { key = code.substr(5) }
    if( code === "space" ) { key = "space" }
    if( key === "-" ) { key = "minus" }
    if( key === "_" ) { key = "minus" }
    if( key === "+" ) { key = "equals" }
    let t = key.toLowerCase()
    t = getmods(e)+t
    console.log("acms",t)
    return t
}

window.addEventListener("keydown", e => {
  const k = acms(e)
  switch(k) {
    case "C-s":
      e.preventDefault()
      return dosubmit()
    case "A-l":
      e.preventDefault()
      return dolist()
  }
})

document.getElementById("submit").addEventListener("click", dosubmit)

function dolist() {
  window.location.href = "<?php echo LIST_URL; ?>"
}

async function dosubmit() {
  const short = document.getElementById("short").value.trim()
  const url = document.getElementById("url").value.trim()
  const message = document.getElementById("message")

  message.textContent = ""

  if (!url) {
    message.textContent = "Please fill in the url."
    return
  }
  if( url === "!delete" ) {
    await do_delete(short)
  } else {
    await do_insert(short,url)
  }
}

async function do_insert(short,url) {
  const message = document.getElementById("message")
  try {
    const reqbody = JSON.stringify({ short, url })
    console.log(reqbody)
    const response = await fetch("<?php echo BACKEND; ?>", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: reqbody
    })

    const text = await response.text()
    console.log(text)
    const data = JSON.parse(text)
    console.log({data})

    if( data.short ) {
      if( data.short === "#fail" ) {
        message.textContent = "#fail"
      } else {
        const short = data.short
        const short_url = `<?php echo SHORT_URL_PREFIX; ?>${short}`
        message.textContent = ""
        let span = document.createElement("span")
        span.textContent = short
        message.append(span)
        message.append(document.createTextNode(" "))
        let a = document.createElement("a")
        a.textContent = short_url
        a.href = short_url
        message.append(a)
      }
    } else {
      message.textContent = data.message || "Done."
    }
  } catch (err) {
    message.textContent = "Error: " + err
  }
}

async function do_delete(short) {
  const message = document.getElementById("message")
  try {
    const reqbody = JSON.stringify({ short, url:"!delete" })
    console.log(reqbody)
    const response = await fetch("insert_short.php", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: reqbody
    })

    const text = await response.text()
    console.log(text)
    const data = JSON.parse(text)
    console.log({data})

    if( data.message ) {
      message.textContent = data.message || "Done."
    } else {
      message.textContent = "no message"
    }
  } catch (err) {
    message.textContent = "Error: " + err
  }
}
</script>

</body>
</html>

Redirector

<?php
require_once("db.php");

$conn = new mysqli(HOST,USER,PASS,DB);
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}

$stmt = $conn->prepare("SELECT id, short, url FROM ".PREFIX."_short_to_url WHERE short = ?");
$stmt->bind_param("s", $req);
$stmt->execute();
$result = $stmt->get_result();

if ($row = $result->fetch_assoc()) {
  $url = $row["url"];
  $url = str_replace("%s",$qstring,$url);
} else {
  $url = null;
}

$stmt->close();
$conn->close();
<?php
require_once("db.php");
require_once("query_core.php");

if( !is_null($url) ) {
  if (filter_var($url, FILTER_VALIDATE_URL)) {
    header("Location: " . $url, true, 302);  // 302 = temporary redirect
    exit();
  } else {
    http_response_code(500);
    $message = "Stored URL is invalid. '$url'";
    $message_class = "error";
  }
} else {
  $message = "No URL found for short code '$req'";
  $message_class = "notfound";
}