Skip to content
Merged
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
164 changes: 164 additions & 0 deletions src/features/browser/utils/urlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Determines whether a given string is a valid URL.
*
* @param url - The string to validate as a URL.
* @returns `true` if the string is a valid URL, `false` otherwise.
*/
export const isValidUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

/**
* Checks if a given string is a valid domain name.
*
* @param domain - The domain name string to validate.
* @returns `true` if the domain is valid, otherwise `false`.
*/
export const isValidDomain = (domain: string): boolean => {
const domainRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return domainRegex.test(domain);
};

/**
* Normalizes a given input string into a valid URL or a Google search URL.
*
* @param input - The string to normalize as a URL or search query.
* @returns The normalized URL or search URL.
*/
export const normalizeUrl = (input: string): string => {
if (!input.trim()) {
return "";
}

const trimmedInput = input.trim();

// If it looks like a search query (contains spaces or no dots), use Google search
if (
trimmedInput.includes(" ") ||
(!trimmedInput.includes(".") && !trimmedInput.startsWith("localhost"))
) {
return `https://www.google.com/search?q=${encodeURIComponent(
trimmedInput
)}`;
}

// Handle localhost
if (trimmedInput.startsWith("localhost")) {
return trimmedInput.startsWith("http")
? trimmedInput
: `http://${trimmedInput}`;
}

// Add protocol if missing
if (
!trimmedInput.startsWith("http://") &&
!trimmedInput.startsWith("https://")
) {
return `https://${trimmedInput}`;
}

return trimmedInput;
};

export const extractDomain = (url: string): string => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};

export const extractProtocol = (url: string): string => {
try {
return new URL(url).protocol;
} catch {
return "https:";
}
};

/**
* Determines if a given URL is considered secure.
*
* @param url - The URL string to check.
* @returns `true` if the URL is secure, otherwise `false`.
*/
export const isSecureUrl = (url: string): boolean => {
return url.startsWith("https://") || url.startsWith("localhost");
};

/**
* Formats a given URL string for display by extracting the hostname,
* pathname (if not root), and search parameters.
* If the input is not a valid URL, returns the original string.
*
* @param url - The URL string to format for display.
* @returns A formatted string suitable for display, or the original string if invalid.
*/
export const formatUrlForDisplay = (url: string): string => {
try {
const urlObj = new URL(url);
let displayUrl = urlObj.hostname;

if (urlObj.pathname !== "/") {
displayUrl += urlObj.pathname;
}

if (urlObj.search) {
displayUrl += urlObj.search;
}

return displayUrl;
} catch {
return url;
}
};

export const getUrlWithoutProtocol = (url: string): string => {
try {
const urlObj = new URL(url);
return url.replace(`${urlObj.protocol}//`, "");
} catch {
return url;
}
};

export const isSameOrigin = (url1: string, url2: string): boolean => {
try {
const urlObj1 = new URL(url1);
const urlObj2 = new URL(url2);
return urlObj1.origin === urlObj2.origin;
} catch {
return false;
}
};

/**
* Builds a search URL for the specified search engine using the provided query.
*
* @param query - The search query string to be encoded and appended to the URL.
* @param searchEngine - The search engine to use ("google", "bing", or "duckduckgo"). Defaults to "google".
* @returns The complete search URL for the specified search engine.
*/
export const buildSearchUrl = (
query: string,
searchEngine: string = "google"
): string => {
const encodedQuery = encodeURIComponent(query);

const searchEngines = {
google: `https://www.google.com/search?q=${encodedQuery}`,
bing: `https://www.bing.com/search?q=${encodedQuery}`,
duckduckgo: `https://duckduckgo.com/?q=${encodedQuery}`,
};

return (
searchEngines[searchEngine as keyof typeof searchEngines] ||
searchEngines.google
);
};
2 changes: 1 addition & 1 deletion src/features/scripts/hooks/useScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
updateUserScript,
} from "../scriptsSlice";
import { ScriptExecution, UserScript } from "../types";
import { createPatternRegex } from "../utils";
import { createPatternRegex } from "../utils/patternUtils";

export const useScripts = () => {
const dispatch = useAppDispatch();
Expand Down
22 changes: 18 additions & 4 deletions src/features/scripts/scriptsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
} from "@reduxjs/toolkit";
import { importScript } from "./api/scriptApi";
import { ScriptExecution, ScriptsState, UserScript } from "./types";
import { validateScript } from "./utils";
import { validateUrlPattern } from "./utils/patternUtils";
import { validateScript } from "./utils/scriptValidator";

// Async thunk to add a new user script by importing from a URL or direct input
export const addUserScript = createAsyncThunk<
Expand All @@ -22,6 +23,17 @@ export const addUserScript = createAsyncThunk<
{ rejectValue: string }
>("scripts/createUserScript", async (args, { rejectWithValue }) => {
try {
// Validate URL patterns
const urlPatternValidations = args.urlPatterns.map(validateUrlPattern);
const invalidPatterns = urlPatternValidations.filter(
(result) => !result.valid
);
if (invalidPatterns.length > 0) {
return rejectWithValue(
invalidPatterns.map((result) => result.error).join(", ")
);
}

let code: string | undefined;

if (args.code) {
Expand All @@ -33,9 +45,11 @@ export const addUserScript = createAsyncThunk<
}

// Validate the script code before creating the script
const validation = validateScript(code);
if (!validation.valid) {
return rejectWithValue(validation.error ?? "Unknown validation error");
const scriptValidation = validateScript(code);
if (!scriptValidation.valid) {
return rejectWithValue(
scriptValidation.error ?? "Unknown validation error"
);
}

const now = new Date().toISOString();
Expand Down
59 changes: 0 additions & 59 deletions src/features/scripts/utils/index.ts

This file was deleted.

85 changes: 85 additions & 0 deletions src/features/scripts/utils/patternUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export interface PatternValidationResult {
valid: boolean;
error?: string;
}

/**
* Validates a URL pattern string to ensure it meets specific criteria.
*
* The validation checks include:
* - The pattern is not empty or whitespace.
* - The pattern does not contain invalid characters: `<`, `>`, `"`, `|`, or `\`.
* - The pattern can be converted to a valid regular expression using `globToRegex`.
*
* @param pattern - The URL pattern string to validate.
* @returns An object indicating whether the pattern is valid and, if invalid, an error message.
*/
export const validateUrlPattern = (
pattern: string
): PatternValidationResult => {
if (!pattern.trim()) {
return { valid: false, error: "Pattern cannot be empty" };
}

const trimmedPattern = pattern.trim();

// Check for invalid characters
const invalidChars = /[<>"|\\]/;
if (invalidChars.test(trimmedPattern)) {
return { valid: false, error: "Pattern contains invalid characters" };
}

try {
// Convert glob pattern to regex and test if it's valid
const regexPattern = globToRegex(trimmedPattern);
new RegExp(regexPattern, "i");
return { valid: true };
} catch {
return { valid: false, error: "Invalid pattern format" };
}
};

export const globToRegex = (pattern: string): string => {
return pattern
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars
.replace(/\\\*/g, ".*") // Convert * to .*
.replace(/\\\?/g, "."); // Convert ? to .
};

/**
* Creates a regular expression from a URL pattern string, supporting wildcards.
*
* @param pattern - The URL pattern string, which may include '*' wildcards.
* @returns A RegExp object that matches URLs according to the given pattern.
*
* @example
* createPatternRegex("*.example.com");
* Matches: "https://sub.example.com", "http://foo.bar.example.com"
*
* createPatternRegex("example.com/path*");
* Matches: "https://example.com/path", "https://example.com/path/to/resource"
*/
export function createPatternRegex(pattern: string): RegExp {
// Escape regex special characters except '*'
let escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&");
// Replace '*' with '.*'
escaped = escaped.replace(/\*/g, ".*");

// Build the final regex string
let regexStr = "^(https?:\\/\\/)?";
if (pattern.startsWith("*.")) {
// Wildcard subdomain
regexStr += "([\\w-]+\\.)+" + escaped.slice(2);
} else {
regexStr += escaped;
}

// Determine if pattern allows paths
if (pattern.endsWith("/*") || pattern.endsWith("*")) {
regexStr += "(\\/.*)?$";
} else {
regexStr += "\\/?$"; // Only allow optional trailing slash, not paths
}

return new RegExp(regexStr, "i");
}
Loading