Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# 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"
1 change: 1 addition & 0 deletions .github/workflows/deploy-ephemeral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions config/setup.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/lib/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicSettings>('/api/settings'),
};

export const apiKeysApi = {
list: () => apiClient.get<ApiKey[]>('/api/settings/api-keys'),

Expand Down
24 changes: 13 additions & 11 deletions frontend/src/lib/components/CreateLinkForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -246,15 +252,11 @@

<!-- Custom Short Code -->
<div>
<label
for="short-code"
class="block text-sm font-medium text-gray-700 mb-1"
>
Custom Short Code
<span class="text-gray-500 text-xs font-normal"
>(Optional, 3-100 characters: letters, numbers, hyphens,
forward slashes)</span
>
<label for="short-code" class="block text-sm font-medium text-gray-700 mb-1">
Custom Short Code
<span class="text-gray-500 text-xs font-normal">
(Optional, {minShortCodeLength}-{MAX_SHORT_CODE_LENGTH} characters: letters, numbers, hyphens, forward slashes)
</span>
</label>
<input
type="text"
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/lib/components/LinkModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,23 @@
import { linksApi, tagsApi } from "$lib/api/links";
import { fetchUrlTitle, debounce } from "$lib/utils/url-title";
import TagInput from "$lib/components/TagInput.svelte";
import { DEFAULT_MIN_CUSTOM_CODE_LENGTH, MAX_SHORT_CODE_LENGTH } from "$lib/constants";

interface Props {
link?: Link | null;
isOpen?: boolean;
usage?: UsageResponse | null;
minShortCodeLength?: number;
maxShortCodeLength?: number;
}

let {
link = null,
isOpen = $bindable(false),
usage = null,
// Provide default values if not passed down
minShortCodeLength = DEFAULT_MIN_CUSTOM_CODE_LENGTH,
maxShortCodeLength = MAX_SHORT_CODE_LENGTH,
}: Props = $props();

const dispatch = createEventDispatcher<{ saved: Link }>();
Expand Down Expand Up @@ -388,7 +394,7 @@
<div>
<label
for="short-code"
class="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2"
class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2"
>
{#if isEditMode}
Short Code (Read-only)
Expand Down Expand Up @@ -418,6 +424,8 @@
disabled={isEditMode ||
loading ||
!allowCustomShortCode}
minlength={minShortCodeLength}
maxlength={maxShortCodeLength}
placeholder={isEditMode
? ""
: allowCustomShortCode
Expand All @@ -435,8 +443,9 @@
>Upgrade to Pro</a
> to use custom short codes
{:else}
3-100 characters (letters, numbers, hyphens, forward
slashes). Leave empty for auto-generated code
{minShortCodeLength}-{maxShortCodeLength}
characters (letters, numbers, hyphens, forward slashes).
Leave empty for auto-generated code
{/if}
</p>
</div>
Expand Down Expand Up @@ -502,7 +511,7 @@
<div>
<label
for="redirect-type"
class="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2"
class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2"
>
Redirect Type
{#if !isProOrAbove}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Short code length defaults for the frontend

export const DEFAULT_MIN_RANDOM_CODE_LENGTH = 6;
export const DEFAULT_MIN_CUSTOM_CODE_LENGTH = 3;
export const DEFAULT_SYSTEM_MIN_CODE_LENGTH = 1;
export const MAX_SHORT_CODE_LENGTH = 100;
68 changes: 68 additions & 0 deletions frontend/src/routes/admin/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
import { onMount } from "svelte";
import { adminApi, type Discount, type Product } from "$lib/api/admin";
import { billingApi } from "$lib/api/billing";
import {
DEFAULT_MIN_RANDOM_CODE_LENGTH,
DEFAULT_MIN_CUSTOM_CODE_LENGTH,
DEFAULT_SYSTEM_MIN_CODE_LENGTH,
MAX_SHORT_CODE_LENGTH

} from "$lib/constants";

let settings = $state<Record<string, string>>({});
let loading = $state(false);
Expand Down Expand Up @@ -63,6 +70,11 @@
business_annual: "",
});

// Minimum code length
let minRandomCodeLength = $state(DEFAULT_MIN_RANDOM_CODE_LENGTH);
let minCustomCodeLength = $state(DEFAULT_MIN_CUSTOM_CODE_LENGTH);
let systemMinCodeLength = $state(DEFAULT_SYSTEM_MIN_CODE_LENGTH);

// Filter discounts for a specific product
function getDiscountsForSlot(slot: keyof typeof PRODUCT_IDS): Discount[] {
const productId = PRODUCT_IDS[slot];
Expand Down Expand Up @@ -118,6 +130,20 @@
settings = await adminApi.getSettings();
signupsEnabled = settings.signups_enabled !== "false";
defaultUserTier = settings.default_user_tier || "free";

// Load effective system minimum code length
systemMinCodeLength = parseInt(settings.system_min_code_length || DEFAULT_SYSTEM_MIN_CODE_LENGTH.toString());

// Ensure the Admin UI reflects the reality of the system, even if a lower value was set in db
minRandomCodeLength = Math.max(
systemMinCodeLength,
parseInt(settings.min_random_code_length || DEFAULT_MIN_RANDOM_CODE_LENGTH.toString())
);
minCustomCodeLength = Math.max(
systemMinCodeLength,
parseInt(settings.min_custom_code_length || DEFAULT_MIN_CUSTOM_CODE_LENGTH.toString())
);

founderPricingEnabled = settings.founder_pricing_active === "true";
discountSlots.pro_monthly =
settings.active_discount_pro_monthly || "";
Expand Down Expand Up @@ -568,6 +594,48 @@
</div>
</div>

<!-- Default Code Length Setting -->
<div class="setting-card">
<div class="setting-content">
<div class="setting-info">
<h3>Short code length limits</h3>
<p class="setting-description">
Set the minimum allowed characters for custom and random short codes.
<br/><span class="text-xs text-amber-600 font-medium">System Minimum: {systemMinCodeLength} (Automatically increases when shorter code combinations are exhausted).</span>
</p>
</div>
<div class="setting-control" style="display: flex; gap: 1rem;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
<label for="min-random-length" class="text-xs text-gray-500 font-medium">Random</label>
<input
id="min-random-length"
type="number"
min={systemMinCodeLength}
max={MAX_SHORT_CODE_LENGTH}
bind:value={minRandomCodeLength}
onchange={() => handleUpdateSetting("min_random_code_length", minRandomCodeLength.toString())}
disabled={saving}
class="tier-select"
style="width: 80px; text-align: center;"
/>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
<label for="min-custom-length" class="text-xs text-gray-500 font-medium">Custom</label>
<input
id="min-custom-length"
type="number"
min={systemMinCodeLength}
max={MAX_SHORT_CODE_LENGTH} bind:value={minCustomCodeLength}
onchange={() => handleUpdateSetting("min_custom_code_length", minCustomCodeLength.toString())}
disabled={saving}
class="tier-select"
style="width: 80px; text-align: center;"
/>
</div>
</div>
</div>
</div>

<!-- Pricing Section -->
<div class="pricing-section">
<h2 class="pricing-section-header">💰 Pricing Configuration</h2>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/routes/dashboard/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -52,6 +53,10 @@
let selectedTags = $state<string[]>([]);
let availableTags = $state<TagWithCount[]>([]);

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 || "";
Expand Down Expand Up @@ -661,6 +666,7 @@
link={editingLink}
bind:isOpen={isModalOpen}
{usage}
minShortCodeLength={effectiveMinLength}
on:saved={handleLinkSaved}
/>

Expand Down
9 changes: 7 additions & 2 deletions frontend/src/routes/dashboard/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -61,6 +64,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => {
usage,
orgLogoUrl,
orgId,
publicSettings,
initialSearch: search,
initialStatus: status || 'all',
initialSort: sort,
Expand All @@ -75,6 +79,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => {
usage: null,
orgLogoUrl: null,
orgId: '',
publicSettings: null,
initialSearch: '',
initialStatus: 'all',
initialSort: 'created'
Expand Down
Loading