- Lab forms: a non-empty email must be valid and website/portfolio must resolve to an https URL; invalid input shows an inline hint and blocks publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram are exempt since they may be bare @handles. - New PhotoDropzone component: drag & drop plus multi-select image input, emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage (drop several photos at once) and AddPhotoToSpot, replacing the old one-file-at-a-time inputs. - Spot photo uploads run sequentially and dedupe URLs.
114 lines
4 KiB
TypeScript
114 lines
4 KiB
TypeScript
/**
|
|
* Sanitizes a user-supplied URL to prevent phishing and script injection.
|
|
*
|
|
* Rules:
|
|
* - Only emits https:// URLs. `javascript:`, `data:`, `vbscript:`, `file:`,
|
|
* relative paths, and every other scheme are rejected (returns null).
|
|
* - Bare `http://` links are UPGRADED to `https://` rather than dropped, so we
|
|
* don't silently lose links that work fine over TLS. Plain `http` is a
|
|
* downgrade / mixed-content / IP-leak vector and also fails outright inside
|
|
* the native (Capacitor) HTTPS-only WebView, so we never emit it.
|
|
* - Returns null for invalid / unsafe URLs (caller should hide the link).
|
|
*
|
|
* VPN/Tor users are unaffected — this validates the URL scheme & structure,
|
|
* not the user's network origin.
|
|
*/
|
|
export function sanitizeUrl(url: string | undefined | null): string | null {
|
|
if (!url) return null;
|
|
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
|
|
if (parsed.protocol === 'https:') {
|
|
return parsed.href;
|
|
}
|
|
|
|
// Upgrade http → https instead of dropping the link outright.
|
|
if (parsed.protocol === 'http:') {
|
|
parsed.protocol = 'https:';
|
|
return parsed.href;
|
|
}
|
|
|
|
// Everything else (javascript:, data:, file:, etc.) is unsafe.
|
|
return null;
|
|
} catch {
|
|
// Not a valid absolute URL (also rejects relative paths — intentional,
|
|
// since untrusted relative URLs can trigger same-origin requests).
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allowlists safe characters for interpolating an untrusted string into a CSS
|
|
* value (font-family, animation-name, etc.). Keeps Unicode letters/numbers,
|
|
* spaces, hyphens, underscores, apostrophes and periods; strips everything
|
|
* that could break out of the CSS string context (`"`, `)`, `}`, `;`, `\`).
|
|
*
|
|
* URLs in `url(...)` must use `sanitizeUrl()` instead — the URL constructor
|
|
* percent-encodes the dangerous characters and rejects non-http(s) schemes.
|
|
*/
|
|
export function sanitizeCssString(value: string | undefined | null): string {
|
|
if (!value) return '';
|
|
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
|
}
|
|
|
|
/**
|
|
* Normalize a user-typed URL *before storing it* so bare domains still work.
|
|
*
|
|
* Users routinely type `example.com` or `instagram.com/me` with no scheme.
|
|
* `new URL()` rejects those, so `sanitizeUrl()` would hide the link and the
|
|
* user would think they'd provided it. Prefix `https://` for host-like values
|
|
* only (must contain a dot, no spaces/colons) so handles like `@skater` and
|
|
* already-schemed values (`https://…`, `mailto:…`) pass through untouched.
|
|
*
|
|
* Returns '' for empty input. Stored values are still sanitized again at render.
|
|
*/
|
|
export function normalizeUrlInput(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return '';
|
|
// Already has a scheme (https:, http:, mailto:, …) — leave it alone.
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed;
|
|
// Only invent a scheme for host-like values: domain.tld optionally + path.
|
|
if (!/^[\w-]+(\.[\w-]+)+(\/\S*)?$/i.test(trimmed)) return trimmed;
|
|
return `https://${trimmed}`;
|
|
}
|
|
|
|
/**
|
|
* Extracts just the hostname for display (e.g. "Opens: example.com")
|
|
*/
|
|
export function urlDomain(url: string): string {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, '');
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a tel: or mailto: href.
|
|
* Only allows the specific scheme, strips anything else.
|
|
*/
|
|
export function sanitizeTel(value: string | undefined | null): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
// Allow digits, +, -, spaces, parentheses only
|
|
const digits = trimmed.replace(/[^0-9+\-\s().]/g, '');
|
|
return digits.length >= 5 ? `tel:${digits}` : null;
|
|
}
|
|
|
|
/** Basic email pattern: non-empty local@domain.tld. */
|
|
export function isValidEmail(value: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
}
|
|
|
|
export function sanitizeEmail(value: string | undefined | null): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (isValidEmail(trimmed)) {
|
|
return `mailto:${trimmed}`;
|
|
}
|
|
return null;
|
|
}
|