diff --git a/.dev.vars.example b/.dev.vars.example index a62696b7..dbb0e0f1 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -62,4 +62,10 @@ MAILGUN_BASE_URL=https://api.mailgun.net # POLAR_ORG_SLUG: Your Polar organization slug (for admin deep links) # # Leave all POLAR_* vars empty/unset to disable billing (self-hosted mode). -# NOTE: POLAR_ACCESS_TOKEN is a worker secret (not var) \ No newline at end of file +# NOTE: POLAR_ACCESS_TOKEN is a worker secret (not var) + +# ─── Short code collision handling ─────────────────────────────────────────── +# The maximum retry attempts when generating a short code before increasing length. +# Defaults to '3'. Higher will use more available namespace before increasing +# code length, at the cost of performance when available namespace gets full. +# COLLISION_THRESHOLD="3" diff --git a/.github/workflows/deploy-ephemeral.yml b/.github/workflows/deploy-ephemeral.yml index 14e0068b..5fef70cd 100644 --- a/.github/workflows/deploy-ephemeral.yml +++ b/.github/workflows/deploy-ephemeral.yml @@ -282,6 +282,7 @@ jobs: ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "true" + COLLISION_THRESHOLD = "${{ vars.COLLISION_THRESHOLD }}" EOF - name: Apply D1 Migrations diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 4e1f0908..ebee81e0 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -112,6 +112,7 @@ jobs: ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "false" + COLLISION_THRESHOLD = "${{ vars.COLLISION_THRESHOLD }}" EOF echo "✅ Generated wrangler.production.toml" @@ -263,6 +264,7 @@ jobs: ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "false" + COLLISION_THRESHOLD = "${{ vars.COLLISION_THRESHOLD }}" EOF echo "✅ Generated wrangler.production.toml" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index d2fafd0d..e11a6908 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -114,6 +114,7 @@ jobs: ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "true" + COLLISION_THRESHOLD = "${{ vars.COLLISION_THRESHOLD }}" EOF echo "✅ Generated wrangler.staging.toml" @@ -213,6 +214,7 @@ jobs: ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "true" + COLLISION_THRESHOLD = "${{ vars.COLLISION_THRESHOLD }}" EOF echo "✅ Generated wrangler.staging.toml" diff --git a/SECURITY.md b/SECURITY.md index 16167976..889d29f0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,7 +41,7 @@ Rushomon implements multiple layers of security: ### Input Validation - **URL validation** - Only `http://` and `https://` schemes allowed (prevents XSS via `javascript:` URLs) -- **Short code validation** - Alphanumeric, hyphens, and forward slashes, 3-100 characters (no leading/trailing hyphens or slashes, max 3 segments) +- **Short code validation** - Alphanumeric, hyphens, and forward slashes, 1-100 characters (no leading/trailing hyphens or slashes, max 3 segments) - **Reserved codes** - System routes (`api`, `auth`, `admin`) cannot be used as short codes - **Pagination limits** - Maximum 100 items per page (DoS prevention) diff --git a/config/setup.example.yaml b/config/setup.example.yaml index 3f797ae2..820e58ef 100644 --- a/config/setup.example.yaml +++ b/config/setup.example.yaml @@ -109,6 +109,13 @@ advanced: # Will be created automatically if it doesn't exist r2_assets_bucket_name: "rushomon-production-assets" + # Short code collision handling + # The maximum retry attempts when generating a short code before increasing length. + # Defaults to '3'. Higher will use more available namespace before increasing + # code length, at the cost of performance when available namespace gets full. + # collision_threshold: "3" + + # Example configurations for different scenarios: # Example 1: Single domain deployment (simplest) diff --git a/frontend/src/lib/api/settings.ts b/frontend/src/lib/api/settings.ts index 2c2a66d3..0a3bed94 100644 --- a/frontend/src/lib/api/settings.ts +++ b/frontend/src/lib/api/settings.ts @@ -13,6 +13,21 @@ export interface ApiKeyCreateResponse extends ApiKey { raw_token: string; } +export interface PublicSettings { + founder_pricing_active: boolean; + min_random_code_length: number; + min_custom_code_length: number; + system_min_code_length: number; + active_discount_amount_pro_monthly: number; + active_discount_amount_pro_annual: number; + active_discount_amount_business_monthly: number; + active_discount_amount_business_annual: number; +} + +export const settingsApi = { + getPublicSettings: () => apiClient.get('/api/settings'), +}; + export const apiKeysApi = { list: () => apiClient.get('/api/settings/api-keys'), diff --git a/frontend/src/lib/components/CreateLinkForm.svelte b/frontend/src/lib/components/CreateLinkForm.svelte index 25afa614..5806aa03 100644 --- a/frontend/src/lib/components/CreateLinkForm.svelte +++ b/frontend/src/lib/components/CreateLinkForm.svelte @@ -2,13 +2,19 @@ import { linksApi } from "$lib/api/links"; import type { Link, ApiError, UtmParams } from "$lib/types/api"; import { fetchUrlTitle, debounce } from "$lib/utils/url-title"; + import { + DEFAULT_MIN_CUSTOM_CODE_LENGTH, + MAX_SHORT_CODE_LENGTH + } from "$lib/constants"; let { onLinkCreated, isPro = false, + minShortCodeLength = DEFAULT_MIN_CUSTOM_CODE_LENGTH, }: { onLinkCreated: (link: Link) => void; isPro?: boolean; + minShortCodeLength?: number; } = $props(); let destinationUrl = $state(""); @@ -65,8 +71,8 @@ // Trim and validate short code const trimmedShortCode = shortCode.trim(); if (trimmedShortCode) { - if (trimmedShortCode.length < 3 || trimmedShortCode.length > 100) { - error = "Custom code must be 3-100 characters"; + if (trimmedShortCode.length < minShortCodeLength || trimmedShortCode.length > MAX_SHORT_CODE_LENGTH) { + error = `Custom code must be ${minShortCodeLength}-${MAX_SHORT_CODE_LENGTH} characters`; isSubmitting = false; return; } @@ -246,15 +252,11 @@
-
+ +
+
+
+

Short code length limits

+

+ Set the minimum allowed characters for custom and random short codes. +
System Minimum: {systemMinCodeLength} (Automatically increases when shorter code combinations are exhausted). +

+
+
+
+ + handleUpdateSetting("min_random_code_length", minRandomCodeLength.toString())} + disabled={saving} + class="tier-select" + style="width: 80px; text-align: center;" + /> +
+
+ + handleUpdateSetting("min_custom_code_length", minCustomCodeLength.toString())} + disabled={saving} + class="tier-select" + style="width: 80px; text-align: center;" + /> +
+
+
+
+

💰 Pricing Configuration

diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 34bc4fac..d8e5a378 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -16,6 +16,7 @@ TagWithCount, PaginatedResponse, } from "$lib/types/api"; + import { DEFAULT_MIN_CUSTOM_CODE_LENGTH, DEFAULT_SYSTEM_MIN_CODE_LENGTH } from "$lib/constants"; let { data }: { data: PageData } = $props(); @@ -52,6 +53,10 @@ let selectedTags = $state([]); let availableTags = $state([]); + const effectiveMinLength = $derived( + (data as any).publicSettings?.min_custom_code_length || DEFAULT_MIN_CUSTOM_CODE_LENGTH + ); + // Initialize from data props using derived $effect(() => { search = (data as any).initialSearch || ""; @@ -661,6 +666,7 @@ link={editingLink} bind:isOpen={isModalOpen} {usage} + minShortCodeLength={effectiveMinLength} on:saved={handleLinkSaved} /> diff --git a/frontend/src/routes/dashboard/+page.ts b/frontend/src/routes/dashboard/+page.ts index a40b1da2..3a94cc24 100644 --- a/frontend/src/routes/dashboard/+page.ts +++ b/frontend/src/routes/dashboard/+page.ts @@ -3,6 +3,7 @@ import { linksApi } from '$lib/api/links'; import { usageApi } from '$lib/api/usage'; import { orgsApi } from '$lib/api/orgs'; import type { PaginatedResponse, Link, UsageResponse } from '$lib/types/api'; +import { settingsApi } from '$lib/api/settings'; export const load: PageLoad = async ({ parent, url, depends }) => { // Declare dependency for invalidation @@ -18,6 +19,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => { user: null, paginatedLinks: null, usage: null, + publicSettings: null, initialSearch: '', initialStatus: 'all', initialSort: 'created' @@ -35,8 +37,8 @@ export const load: PageLoad = async ({ parent, url, depends }) => { .map((t) => t.trim()) .filter((t) => t.length > 0); - // Fetch links, usage, and org details in parallel - const [paginatedLinks, usage, orgId, orgLogoUrl] = await Promise.all([ + // Fetch links, usage, org details, and settings in parallel + const [paginatedLinks, usage, orgId, orgLogoUrl, publicSettings] = await Promise.all([ linksApi.list( page, 10, @@ -53,6 +55,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => { .then((r) => orgsApi.getOrg(r.current_org_id)) .then((d) => d.org.logo_url) .catch(() => null), + settingsApi.getPublicSettings().catch(() => null) ]); return { @@ -61,6 +64,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => { usage, orgLogoUrl, orgId, + publicSettings, initialSearch: search, initialStatus: status || 'all', initialSort: sort, @@ -75,6 +79,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => { usage: null, orgLogoUrl: null, orgId: '', + publicSettings: null, initialSearch: '', initialStatus: 'all', initialSort: 'created' diff --git a/frontend/src/routes/dashboard/import/+page.svelte b/frontend/src/routes/dashboard/import/+page.svelte index acb3ce9c..c699ba85 100644 --- a/frontend/src/routes/dashboard/import/+page.svelte +++ b/frontend/src/routes/dashboard/import/+page.svelte @@ -2,6 +2,7 @@ import { goto } from "$app/navigation"; import { linksApi } from "$lib/api/links"; import type { ImportLinkRow, ImportBatchResult } from "$lib/api/links"; + import { MAX_SHORT_CODE_LENGTH } from "$lib/constants"; import type { UsageResponse } from "$lib/types/api"; let { data }: { data: { user: unknown; usage: UsageResponse | null } } = @@ -106,7 +107,6 @@ // ── Step 5: Progress + Results ──────────────────────────────────────────── const BATCH_SIZE = 50; - const MAX_SHORT_CODE_LENGTH = 100; let progress = $state(0); // 0-100 let importDone = $state(false); let totalCreated = $state(0); @@ -744,7 +744,7 @@ {#if row.wasTruncated} (truncated to 100)(truncated to {MAX_SHORT_CODE_LENGTH}) {/if} @@ -969,7 +969,7 @@ {truncatedCount} short code{truncatedCount === 1 ? " was" - : "s were"} longer than 100 characters and {truncatedCount === + : "s were"} longer than {MAX_SHORT_CODE_LENGTH} characters and {truncatedCount === 1 ? "was" : "were"} automatically truncated before import. diff --git a/migrations/0028_min_code_length_setting.sql b/migrations/0028_min_code_length_setting.sql new file mode 100644 index 00000000..a507385f --- /dev/null +++ b/migrations/0028_min_code_length_setting.sql @@ -0,0 +1,7 @@ +-- Initialize the code length settings +INSERT INTO settings (key, value, updated_at) +VALUES + ('min_random_code_length', '6', 0), + ('min_custom_code_length', '3', 0), + ('system_min_code_length', '1', 0); + \ No newline at end of file diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index a777e40c..c9229b7d 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -198,6 +198,7 @@ load_config_with_yq() { export ENABLE_KV_RATE_LIMITING=$(yq eval '.advanced.enable_kv_rate_limiting // false' "$config_file") export R2_BACKUP_BUCKET=$(yq eval '.advanced.r2_backup_bucket // ""' "$config_file") export R2_ASSETS_BUCKET_NAME=$(yq eval '.advanced.r2_assets_bucket_name // ""' "$config_file") + export COLLISION_THRESHOLD=$(yq eval '.advanced.collision_threshold // ""' "$config_file") # Cloudflare export CLOUDFLARE_ACCOUNT_ID=$(yq eval '.cloudflare.account_id // ""' "$config_file") diff --git a/scripts/lib/deployment.sh b/scripts/lib/deployment.sh index d31eb2e4..1a866627 100644 --- a/scripts/lib/deployment.sh +++ b/scripts/lib/deployment.sh @@ -108,6 +108,13 @@ PUBLIC_VITE_SHORT_LINK_BASE_URL = "https://${REDIRECT_DOMAIN}" ENABLE_KV_RATE_LIMITING = "${ENABLE_KV_RATE_LIMITING:-false}" EOF + # Add collision threshold if enabled + if [ -n "$COLLISION_THRESHOLD" ]; then + cat >> "$output_file" <> "$output_file" <, @@ -18,19 +21,38 @@ pub async fn handle_get_public_settings( .map(|v| v == "true") .unwrap_or(false); - // Helper to parse setting as i64 - let get_setting_i64 = |key: &str| -> i64 { + // Helper to parse setting as i64 (with default) + let get_setting_i64 = |key: &str, default: i64| -> i64 { settings .get(key) .and_then(|v| v.parse::().ok()) - .unwrap_or(0) + .unwrap_or(default) }; + let raw_min_random = get_setting_i64( + "min_random_code_length", + DEFAULT_MIN_RANDOM_CODE_LENGTH as i64, + ); + let raw_min_custom = get_setting_i64( + "min_custom_code_length", + DEFAULT_MIN_CUSTOM_CODE_LENGTH as i64, + ); + let system_min = get_setting_i64( + "system_min_code_length", + DEFAULT_SYSTEM_MIN_CODE_LENGTH as i64, + ); + + let effective_min_random = raw_min_random.max(system_min); + let effective_min_custom = raw_min_custom.max(system_min); + Response::from_json(&serde_json::json!({ "founder_pricing_active": founder_pricing_active, - "active_discount_amount_pro_monthly": get_setting_i64("active_discount_amount_pro_monthly"), - "active_discount_amount_pro_annual": get_setting_i64("active_discount_amount_pro_annual"), - "active_discount_amount_business_monthly": get_setting_i64("active_discount_amount_business_monthly"), - "active_discount_amount_business_annual": get_setting_i64("active_discount_amount_business_annual"), + "min_random_code_length": effective_min_random, + "min_custom_code_length": effective_min_custom, + "system_min_code_length": system_min, + "active_discount_amount_pro_monthly": get_setting_i64("active_discount_amount_pro_monthly", 0), + "active_discount_amount_pro_annual": get_setting_i64("active_discount_amount_pro_annual", 0), + "active_discount_amount_business_monthly": get_setting_i64("active_discount_amount_business_monthly", 0), + "active_discount_amount_business_annual": get_setting_i64("active_discount_amount_business_annual", 0), })) } diff --git a/src/db/queries.rs b/src/db/queries.rs index 3ac7fc2f..2b9ae5b4 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -3,6 +3,7 @@ use crate::models::{ user::CreateUserData, }; use crate::utils::now_timestamp; +use crate::utils::short_code::DEFAULT_SYSTEM_MIN_CODE_LENGTH; use wasm_bindgen::JsValue; use worker::d1::D1Database; use worker::*; @@ -870,6 +871,14 @@ pub async fn get_setting(db: &D1Database, key: &str) -> Result> { } } +/// Helper to fetch the current code length high watermark +pub async fn get_system_min_code_length(db: &D1Database) -> Result { + Ok(get_setting(db, "system_min_code_length") + .await? + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_SYSTEM_MIN_CODE_LENGTH)) +} + /// Get all settings as a HashMap pub async fn get_all_settings( db: &D1Database, diff --git a/src/router.rs b/src/router.rs index e6d0a9e8..d60e47df 100644 --- a/src/router.rs +++ b/src/router.rs @@ -6,7 +6,14 @@ use crate::models::{ LinkAnalyticsResponse, PaginatedResponse, PaginationMeta, Tier, TimeRange, link::{CreateLinkRequest, Link, LinkStatus, UpdateLinkRequest}, }; -use crate::utils::{generate_short_code, now_timestamp, validate_short_code, validate_url}; +use crate::utils::{ + generate_short_code_with_length, now_timestamp, + short_code::{ + DEFAULT_COLLISION_THRESHOLD, DEFAULT_MIN_CUSTOM_CODE_LENGTH, + DEFAULT_MIN_RANDOM_CODE_LENGTH, DEFAULT_SYSTEM_MIN_CODE_LENGTH, MAX_SHORT_CODE_LENGTH, + }, + validate_short_code, validate_url, +}; use chrono::{Datelike, TimeZone}; use std::future::Future; use std::pin::Pin; @@ -298,6 +305,59 @@ pub async fn handle_redirect( }) } +/// Internal helper to generate a unique short code starting at a minimum length +/// and scaling up if collisions are detected. +async fn generate_progressive_short_code( + kv: &worker::kv::KvStore, + db: &D1Database, + env: &Env, + admin_min_length: usize, + system_min_length: usize, +) -> Result { + // Move the parsing logic here! + let collision_threshold = env + .var("COLLISION_THRESHOLD") + .ok() + .and_then(|v| v.to_string().parse::().ok()) + .unwrap_or(DEFAULT_COLLISION_THRESHOLD); + + let mut current_length = admin_min_length.max(system_min_length); + let mut total_attempts = 0; + let mut current_length_attempts = 0; + + loop { + let code = generate_short_code_with_length(current_length); + + if !kv::links::short_code_exists(kv, &code).await? { + return Ok(code); + } + + total_attempts += 1; + current_length_attempts += 1; + + // Exhaustion Trigger: Dynamic threshold based on env var + if current_length_attempts >= collision_threshold { + current_length += 1; + current_length_attempts = 0; + + let _ = + db::set_setting(db, "system_min_code_length", ¤t_length.to_string()).await; + + if admin_min_length < current_length { + let _ = db::set_setting(db, "min_random_code_length", ¤t_length.to_string()) + .await; + } + } + + // Scale the ultimate fail-safe based on the threshold too + if total_attempts > (collision_threshold * 3).max(20) { + return Err(Error::RustError( + "Failed to generate unique short code".into(), + )); + } + } +} + /// Handle link creation: POST /api/links pub async fn handle_create_link(mut req: Request, ctx: RouteContext<()>) -> Result { // Authenticate request @@ -495,6 +555,27 @@ pub async fn handle_create_link(mut req: Request, ctx: RouteContext<()>) -> Resu return Response::error(error_msg, 403); } + // Fetch all settings in a single query for performance + let settings = db::get_all_settings(&db).await?; + + let min_random_length = settings + .get("min_random_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MIN_RANDOM_CODE_LENGTH); + + let min_custom_length = settings + .get("min_custom_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MIN_CUSTOM_CODE_LENGTH); + + let system_min_length = settings + .get("system_min_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_SYSTEM_MIN_CODE_LENGTH); + + // Custom codes must respect the higher of the custom rule or system physical floor + let effective_custom_min = min_custom_length.max(system_min_length); + // Generate or validate short code let short_code = if let Some(custom_code) = body.short_code { match validate_short_code(&custom_code) { @@ -504,6 +585,16 @@ pub async fn handle_create_link(mut req: Request, ctx: RouteContext<()>) -> Resu } }; + if custom_code.len() < effective_custom_min { + return Response::error( + format!( + "Custom short code must be at least {} characters", + effective_custom_min + ), + 400, + ); + } + // Check if already exists let kv = ctx.kv("URL_MAPPINGS")?; if kv::links::short_code_exists(&kv, &custom_code).await? { @@ -514,18 +605,8 @@ pub async fn handle_create_link(mut req: Request, ctx: RouteContext<()>) -> Resu } else { // Generate random code and check for collisions (very rare) let kv = ctx.kv("URL_MAPPINGS")?; - let mut code = generate_short_code(); - let mut attempts = 0; - - while kv::links::short_code_exists(&kv, &code).await? { - code = generate_short_code(); - attempts += 1; - if attempts > 10 { - return Response::error("Failed to generate unique short code", 500); - } - } - - code + generate_progressive_short_code(&kv, &db, &ctx.env, min_random_length, system_min_length) + .await? }; // Validate and normalize tags if provided @@ -1426,6 +1507,26 @@ pub async fn handle_import_links(mut req: Request, ctx: RouteContext<()>) -> Res format!("{}-{:02}", dt.year(), dt.month()) }; + let settings = db::get_all_settings(&db).await?; + + // Note: The import function uses 'min_length' instead of 'min_random_length' for this variable + let min_length = settings + .get("min_random_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MIN_RANDOM_CODE_LENGTH); + + let min_custom_length = settings + .get("min_custom_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MIN_CUSTOM_CODE_LENGTH); + + let system_min_length = settings + .get("system_min_code_length") + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_SYSTEM_MIN_CODE_LENGTH); + + let effective_custom_min = min_custom_length.max(system_min_length); + let mut created: usize = 0; let mut skipped: usize = 0; let mut failed: usize = 0; @@ -1495,6 +1596,19 @@ pub async fn handle_import_links(mut req: Request, ctx: RouteContext<()>) -> Res continue; } + if provided_code.len() < effective_custom_min { + skipped += 1; + errors.push(ImportError { + row: row_num, + destination_url: destination_url.clone(), + reason: format!( + "Custom short code must be at least {} characters", + effective_custom_min + ), + }); + continue; + } + // Try provided code, then auto-suffix on conflict (promo → promo-1 → … → promo-10) let mut resolved: Option = None; for attempt in 0u32..=10 { @@ -1513,16 +1627,16 @@ pub async fn handle_import_links(mut req: Request, ctx: RouteContext<()>) -> Res Some(c) => short_code = c, None => { // All suffix attempts exhausted — fall back to a random code - let mut fallback: Option = None; - for _ in 0..10u32 { - let candidate = generate_short_code(); - if !kv::links::short_code_exists(&kv, &candidate).await? { - fallback = Some(candidate); - break; - } - } - match fallback { - Some(c) => { + match generate_progressive_short_code( + &kv, + &db, + &ctx.env, + min_length, + system_min_length, + ) + .await + { + Ok(c) => { warnings.push(ImportWarning { row: row_num, destination_url: destination_url.clone(), @@ -1533,7 +1647,7 @@ pub async fn handle_import_links(mut req: Request, ctx: RouteContext<()>) -> Res }); short_code = c; } - None => { + Err(_) => { failed += 1; errors.push(ImportError { row: row_num, @@ -1547,18 +1661,12 @@ pub async fn handle_import_links(mut req: Request, ctx: RouteContext<()>) -> Res } } } else { - // Free tier OR Pro without provided code: auto-generate - let mut resolved: Option = None; - for _ in 0..10u32 { - let candidate = generate_short_code(); - if !kv::links::short_code_exists(&kv, &candidate).await? { - resolved = Some(candidate); - break; - } - } - match resolved { - Some(c) => short_code = c, - None => { + // Free tier OR Pro without provided code: auto-generate using progressive helper + match generate_progressive_short_code(&kv, &db, &ctx.env, min_length, system_min_length) + .await + { + Ok(c) => short_code = c, + Err(_) => { failed += 1; errors.push(ImportError { row: row_num, @@ -2733,6 +2841,21 @@ pub async fn handle_admin_update_setting( | "product_business_annual_id" => { // Any string is valid (discount UUID / product UUID / amount in cents, or empty string to clear) } + "min_random_code_length" | "min_custom_code_length" => { + let val = value.parse::().unwrap_or(0); + let db = ctx.env.get_binding::("rushomon")?; + let system_min = db::get_system_min_code_length(&db).await?; + + if val < system_min || val > MAX_SHORT_CODE_LENGTH { + return Response::error( + format!( + "The namespace for lengths under {} is exhausted. Value must be between {} and {}.", + system_min, system_min, MAX_SHORT_CODE_LENGTH + ), + 400, + ); + } + } _ => return Response::error(format!("Unknown setting: {}", key), 400), } diff --git a/src/utils/short_code.rs b/src/utils/short_code.rs index f10540b3..83865a4b 100644 --- a/src/utils/short_code.rs +++ b/src/utils/short_code.rs @@ -1,14 +1,19 @@ use rand::RngExt; const BASE62_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -const SHORT_CODE_LENGTH: usize = 6; -/// Generate a random 6-character base62 short code /// Character set: 0-9, A-Z, a-z (62 chars) /// Combinations: 62^6 = 56,800,235,584 (56.8 billion) /// Collision probability: < 0.01% at 1M links +pub const DEFAULT_MIN_RANDOM_CODE_LENGTH: usize = 6; +pub const DEFAULT_MIN_CUSTOM_CODE_LENGTH: usize = 3; +pub const DEFAULT_SYSTEM_MIN_CODE_LENGTH: usize = 1; +pub const MAX_SHORT_CODE_LENGTH: usize = 100; +pub const DEFAULT_COLLISION_THRESHOLD: usize = 3; + +/// Generate a random base62 short code pub fn generate_short_code() -> String { - generate_short_code_with_length(SHORT_CODE_LENGTH) + generate_short_code_with_length(DEFAULT_MIN_RANDOM_CODE_LENGTH) } /// Generate a random base62 short code with custom length @@ -29,7 +34,7 @@ mod tests { #[test] fn test_generate_short_code_returns_correct_length() { let code = generate_short_code(); - assert_eq!(code.len(), SHORT_CODE_LENGTH); + assert_eq!(code.len(), DEFAULT_MIN_RANDOM_CODE_LENGTH); } #[test] diff --git a/src/utils/validation.rs b/src/utils/validation.rs index 47bebd02..808204ec 100644 --- a/src/utils/validation.rs +++ b/src/utils/validation.rs @@ -1,3 +1,4 @@ +use crate::utils::short_code::MAX_SHORT_CODE_LENGTH; use url::Url; /// Reserved short codes that cannot be used (prevent conflicts with routes) @@ -57,7 +58,7 @@ pub fn validate_url(url_str: &str) -> Result { /// Validate a custom short code /// Rules: -/// - 3-100 characters long +/// - 1-100 characters long /// - Alphanumeric, hyphens, and forward slashes (a-z, A-Z, 0-9, -, /) /// - Cannot start or end with hyphen or forward slash /// - Cannot contain consecutive forward slashes @@ -65,9 +66,12 @@ pub fn validate_url(url_str: &str) -> Result { /// - Each segment 1-50 characters /// - No segment can be a reserved word pub fn validate_short_code(code: &str) -> Result<(), String> { - // Check length - if code.len() < 3 || code.len() > 100 { - return Err("Short code must be 3-100 characters long".to_string()); + // Check length (Absolute system bounds) + if code.is_empty() || code.len() > MAX_SHORT_CODE_LENGTH { + return Err(format!( + "Short code must be between 1 and {} characters long", + MAX_SHORT_CODE_LENGTH + )); } // Check charset: alphanumeric, hyphens, and forward slashes only @@ -182,9 +186,15 @@ mod tests { #[test] fn test_validate_short_code_accepts_valid_length() { - assert!(validate_short_code("abc").is_ok()); // 3 chars - minimum - // 100 chars - maximum, split into valid segments - assert!(validate_short_code(&format!("{}/{}", "a".repeat(49), "b".repeat(49))).is_ok()); + assert!(validate_short_code("a").is_ok()); // 1 char - minimum + + // Dynamically build a string of exactly MAX_SHORT_CODE_LENGTH characters, + // split by a '/' so no individual segment exceeds the 50-char limit + let half = (MAX_SHORT_CODE_LENGTH - 1) / 2; + let rest = MAX_SHORT_CODE_LENGTH - 1 - half; + let max_code = format!("{}/{}", "a".repeat(half), "b".repeat(rest)); + + assert!(validate_short_code(&max_code).is_ok()); } #[test] @@ -201,8 +211,6 @@ mod tests { #[test] fn test_validate_short_code_rejects_too_short() { - assert!(validate_short_code("ab").is_err()); // 2 chars - assert!(validate_short_code("a").is_err()); assert!(validate_short_code("").is_err()); } diff --git a/tests/links_test.rs b/tests/links_test.rs index c88cfe59..dcdf339d 100644 --- a/tests/links_test.rs +++ b/tests/links_test.rs @@ -4,6 +4,8 @@ use serde_json::json; mod common; use common::*; +use rushomon::utils::short_code::DEFAULT_MIN_RANDOM_CODE_LENGTH; + #[tokio::test] async fn test_create_link_with_random_short_code() { let client = authenticated_client(); @@ -34,7 +36,10 @@ async fn test_create_link_with_random_short_code() { // Verify response structure assert!(link["id"].is_string()); assert!(link["short_code"].is_string()); - assert_eq!(link["short_code"].as_str().unwrap().len(), 6); + assert_eq!( + link["short_code"].as_str().unwrap().len(), + DEFAULT_MIN_RANDOM_CODE_LENGTH + ); assert_eq!(link["destination_url"], "https://example.com/test-page"); assert_eq!(link["title"], "Test Link"); assert_eq!(link["status"], "active"); diff --git a/tests/settings_test.rs b/tests/settings_test.rs index b0bb13e4..7bf1a08c 100644 --- a/tests/settings_test.rs +++ b/tests/settings_test.rs @@ -4,6 +4,8 @@ use serde_json::json; mod common; use common::*; +use rushomon::utils::short_code::MAX_SHORT_CODE_LENGTH; + #[tokio::test] async fn test_settings_requires_auth() { let client = test_client(); @@ -184,3 +186,46 @@ async fn test_update_settings_requires_auth() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn test_update_min_random_code_length_validation() { + let client = authenticated_client(); + + // 1. Test valid value (lower bound) - Assuming 3 is a valid system minimum + let res = client + .put(format!("{}/api/admin/settings", BASE_URL)) + .json(&json!({ "key": "min_random_code_length", "value": "3" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // 2. Test valid value (upper bound: 100) + let res = client + .put(format!("{}/api/admin/settings", BASE_URL)) + .json( + &json!({ "key": "min_random_code_length", "value": MAX_SHORT_CODE_LENGTH.to_string() }), + ) + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // 3. Test invalid value (too short) + let res = client + .put(format!("{}/api/admin/settings", BASE_URL)) + .json(&json!({ "key": "min_random_code_length", "value": "0" })) // Assuming 0 is below system_min + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // 4. Test invalid value (too long: 101) + let res = client + .put(format!("{}/api/admin/settings", BASE_URL)) + .json(&json!({ "key": "min_random_code_length", "value": (MAX_SHORT_CODE_LENGTH + 1).to_string() })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} diff --git a/tests/validation_test.rs b/tests/validation_test.rs index 3c955208..f4e4b971 100644 --- a/tests/validation_test.rs +++ b/tests/validation_test.rs @@ -4,6 +4,8 @@ use serde_json::json; mod common; use common::*; +use rushomon::utils::short_code::{DEFAULT_MIN_CUSTOM_CODE_LENGTH, MAX_SHORT_CODE_LENGTH}; + #[tokio::test] async fn test_reject_javascript_url() { let client = authenticated_client(); @@ -120,11 +122,14 @@ async fn test_accept_valid_https_url() { async fn test_reject_short_code_too_short() { let client = authenticated_client(); + // Dynamically create a string 1 character shorter than the allowed default + let too_short_code = "a".repeat(DEFAULT_MIN_CUSTOM_CODE_LENGTH - 1); + let response = client .post(format!("{}/api/links", BASE_URL)) .json(&json!({ "destination_url": "https://example.com", - "short_code": "ab" // 2 chars, minimum is 3 + "short_code": too_short_code })) .send() .await @@ -145,7 +150,7 @@ async fn test_reject_short_code_too_long() { .post(format!("{}/api/links", BASE_URL)) .json(&json!({ "destination_url": "https://example.com", - "short_code": "a".repeat(101) // 101 chars, maximum is 100 + "short_code": "a".repeat(MAX_SHORT_CODE_LENGTH + 1) })) .send() .await diff --git a/wrangler.example.toml b/wrangler.example.toml index 6611c28b..d0143e01 100644 --- a/wrangler.example.toml +++ b/wrangler.example.toml @@ -91,3 +91,9 @@ not_found_handling = "none" # ─── Polar secrets ─────────────────────────────────────────────────────────── # wrangler secret put POLAR_ACCESS_TOKEN (from Polar Dashboard → Settings → API) # wrangler secret put INTERNAL_WEBHOOK_SECRET (generate: openssl rand -base64 32) + +# ─── Short code collision handling ─────────────────────────────────────────── +# The maximum retry attempts when generating a short code before increasing length. +# Defaults to '3'. Higher will use more available namespace before increasing +# code length, at the cost of performance when available namespace gets full. +# COLLISION_THRESHOLD = "3"