----------------------------------------------------------------------------- -- URI Parsing, Composition and Relative URL Resolution -- -- Originally based on LuaSocket toolkit by Diego Nehab -- RCS ID: $Id: url.lua,v 1.38 2006/04/03 04:45:42 diego Exp $ -- -- Extended for Media Player: -- - Structured query/fragment parsing with URL decoding -- - Security: protocol whitelisting, XSS prevention, path traversal blocking -- - HTML entity encoding/decoding with Unicode support -- -- https://github.com/PurrCoding/gm-mediaplayer ----------------------------------------------------------------------------- local isstring, istable, tostring, tonumber = isstring, istable, tostring, tonumber local pairs, ipairs = pairs, ipairs local string_gsub = string.gsub local string_format = string.format local string_byte = string.byte local string_char = string.char local string_lower = string.lower local string_match = string.match local string_gmatch = string.gmatch local string_sub = string.sub local string_GetFileFromFilename = string.GetFileFromFilename local string_GetExtensionFromFilename = string.GetExtensionFromFilename local table_insert = table.insert local table_concat = table.concat module("url") ----------------------------------------------------------------------------- -- Security limits ----------------------------------------------------------------------------- local ALLOWED_PROTOCOLS = { ["http"] = true, ["https"] = true } local MAX_URL_LENGTH = 2048 local MAX_PARAM_LENGTH = 1024 local MAX_PATH_DEPTH = 25 ----------------------------------------------------------------------------- -- HTML entity table ----------------------------------------------------------------------------- local entities = { -- Core (& must be encoded first / decoded last — see htmlentities / htmlentities_decode) ['&'] = '&', [' ']=' ', ['"']='"', ["'"]=''', ['<']='<', ['>']='>', -- Latin-1 Supplement (U+00A1–U+00FF) ['¡']='¡', ['¢']='¢', ['£']='£', ['¤']='¤', ['¥']='¥', ['¦']='¦', ['§']='§', ['¨']='¨', ['©']='©', ['ª']='ª', ['«']='«', ['¬']='¬', ['­']='­', ['®']='®', ['¯']='¯', ['°']='°', ['±']='±', ['²']='²', ['³']='³', ['´']='´', ['µ']='µ', ['¶']='¶', ['·']='·', ['¸']='¸', ['¹']='¹', ['º']='º', ['»']='»', ['¼']='¼', ['½']='½', ['¾']='¾', ['¿']='¿', ['À']='À', ['Á']='Á', ['Â']='Â', ['Ã']='Ã', ['Ä']='Ä', ['Å']='Å', ['Æ']='Æ', ['Ç']='Ç', ['È']='È', ['É']='É', ['Ê']='Ê', ['Ë']='Ë', ['Ì']='Ì', ['Í']='Í', ['Î']='Î', ['Ï']='Ï', ['Ð']='Ð', ['Ñ']='Ñ', ['Ò']='Ò', ['Ó']='Ó', ['Ô']='Ô', ['Õ']='Õ', ['Ö']='Ö', ['×']='×', ['Ø']='Ø', ['Ù']='Ù', ['Ú']='Ú', ['Û']='Û', ['Ü']='Ü', ['Ý']='Ý', ['Þ']='Þ', ['ß']='ß', ['à']='à', ['á']='á', ['â']='â', ['ã']='ã', ['ä']='ä', ['å']='å', ['æ']='æ', ['ç']='ç', ['è']='è', ['é']='é', ['ê']='ê', ['ë']='ë', ['ì']='ì', ['í']='í', ['î']='î', ['ï']='ï', ['ð']='ð', ['ñ']='ñ', ['ò']='ò', ['ó']='ó', ['ô']='ô', ['õ']='õ', ['ö']='ö', ['÷']='÷', ['ø']='ø', ['ù']='ù', ['ú']='ú', ['û']='û', ['ü']='ü', ['ý']='ý', ['þ']='þ', ['ÿ']='ÿ', -- Latin Extended-A (U+0100–U+017F) — Polish, Czech, Slovak, Croatian, Hungarian, Turkish, etc. ['Ā']='Ā', ['ā']='ā', ['Ă']='Ă', ['ă']='ă', ['Ą']='Ą', ['ą']='ą', ['Ć']='Ć', ['ć']='ć', ['Č']='Č', ['č']='č', ['Ď']='Ď', ['ď']='ď', ['Đ']='Đ', ['đ']='đ', ['Ě']='Ě', ['ě']='ě', ['Ę']='Ę', ['ę']='ę', ['Ğ']='Ğ', ['ğ']='ğ', ['İ']='İ', ['ı']='ı', ['Ł']='Ł', ['ł']='ł', ['Ń']='Ń', ['ń']='ń', ['Ň']='Ň', ['ň']='ň', ['Ő']='Ő', ['ő']='ő', ['Œ']='Œ', ['œ']='œ', ['Ř']='Ř', ['ř']='ř', ['Ś']='Ś', ['ś']='ś', ['Š']='Š', ['š']='š', ['Ş']='Ş', ['ş']='ş', ['Ť']='Ť', ['ť']='ť', ['Ű']='Ű', ['ű']='ű', ['Ů']='Ů', ['ů']='ů', ['Ÿ']='Ÿ', ['Ź']='Ź', ['ź']='ź', ['Ž']='Ž', ['ž']='ž', ['Ż']='Ż', ['ż']='ż', -- Greek (U+0391–U+03C9) ['Α']='Α', ['Β']='Β', ['Γ']='Γ', ['Δ']='Δ', ['Ε']='Ε', ['Ζ']='Ζ', ['Η']='Η', ['Θ']='Θ', ['Ι']='Ι', ['Κ']='Κ', ['Λ']='Λ', ['Μ']='Μ', ['Ν']='Ν', ['Ξ']='Ξ', ['Ο']='Ο', ['Π']='Π', ['Ρ']='Ρ', ['Σ']='Σ', ['Τ']='Τ', ['Υ']='Υ', ['Φ']='Φ', ['Χ']='Χ', ['Ψ']='Ψ', ['Ω']='Ω', ['α']='α', ['β']='β', ['γ']='γ', ['δ']='δ', ['ε']='ε', ['ζ']='ζ', ['η']='η', ['θ']='θ', ['ι']='ι', ['κ']='κ', ['λ']='λ', ['μ']='μ', ['ν']='ν', ['ξ']='ξ', ['ο']='ο', ['π']='π', ['ρ']='ρ', ['σ']='σ', ['τ']='τ', ['υ']='υ', ['φ']='φ', ['χ']='χ', ['ψ']='ψ', ['ω']='ω', -- Cyrillic Basic (U+0410–U+044F) — Russian, Bulgarian ['А']='А', ['Б']='Б', ['В']='В', ['Г']='Г', ['Д']='Д', ['Е']='Е', ['Ж']='Ж', ['З']='З', ['И']='И', ['Й']='Й', ['К']='К', ['Л']='Л', ['М']='М', ['Н']='Н', ['О']='О', ['П']='П', ['Р']='Р', ['С']='С', ['Т']='Т', ['У']='У', ['Ф']='Ф', ['Х']='Х', ['Ц']='Ц', ['Ч']='Ч', ['Ш']='Ш', ['Щ']='Щ', ['Ъ']='Ъ', ['Ы']='Ы', ['Ь']='Ь', ['Э']='Э', ['Ю']='Ю', ['Я']='Я', ['а']='а', ['б']='б', ['в']='в', ['г']='г', ['д']='д', ['е']='е', ['ж']='ж', ['з']='з', ['и']='и', ['й']='й', ['к']='к', ['л']='л', ['м']='м', ['н']='н', ['о']='о', ['п']='п', ['р']='р', ['с']='с', ['т']='т', ['у']='у', ['ф']='ф', ['х']='х', ['ц']='ц', ['ч']='ч', ['ш']='ш', ['щ']='щ', ['ъ']='ъ', ['ы']='ы', ['ь']='ь', ['э']='э', ['ю']='ю', ['я']='я', -- Cyrillic Extended — Ukrainian (Є І Ї Ґ), Belarusian (Ў), Serbian/Macedonian (Ђ Љ Њ Ћ Џ), Russian (Ё) ['Ё']='Ё', ['ё']='ё', ['Є']='Є', ['є']='є', ['І']='І', ['і']='і', ['Ї']='Ї', ['ї']='ї', ['Ґ']='Ґ', ['ґ']='ґ', ['Ў']='Ў', ['ў']='ў', ['Ђ']='Ђ', ['ђ']='ђ', ['Љ']='Љ', ['љ']='љ', ['Њ']='Њ', ['њ']='њ', ['Ћ']='Ћ', ['ћ']='ћ', ['Џ']='Џ', ['џ']='џ', -- Arabic (U+0621–U+064A) — basic consonants ['ء']='ء', ['آ']='آ', ['أ']='أ', ['ؤ']='ؤ', ['إ']='إ', ['ئ']='ئ', ['ا']='ا', ['ب']='ب', ['ة']='ة', ['ت']='ت', ['ث']='ث', ['ج']='ج', ['ح']='ح', ['خ']='خ', ['د']='د', ['ذ']='ذ', ['ر']='ر', ['ز']='ز', ['س']='س', ['ش']='ش', ['ص']='ص', ['ض']='ض', ['ط']='ط', ['ظ']='ظ', ['ع']='ع', ['غ']='غ', ['ف']='ف', ['ق']='ق', ['ك']='ك', ['ل']='ل', ['م']='م', ['ن']='ن', ['ه']='ه', ['و']='و', ['ى']='ى', ['ي']='ي', -- Hebrew (U+05D0–U+05EA) — letters including final forms ['א']='א', ['ב']='ב', ['ג']='ג', ['ד']='ד', ['ה']='ה', ['ו']='ו', ['ז']='ז', ['ח']='ח', ['ט']='ט', ['י']='י', ['ך']='ך', ['כ']='כ', ['ל']='ל', ['ם']='ם', ['מ']='מ', ['ן']='ן', ['נ']='נ', ['ס']='ס', ['ע']='ע', ['ף']='ף', ['פ']='פ', ['ץ']='ץ', ['צ']='צ', ['ק']='ק', ['ר']='ר', ['ש']='ש', ['ת']='ת', -- Devanagari (U+0900–U+096F) — Hindi/Sanskrit vowels, consonants, matras, digits ['ँ']='ँ', ['ं']='ं', ['ः']='ः', ['अ']='अ', ['आ']='आ', ['इ']='इ', ['ई']='ई', ['उ']='उ', ['ऊ']='ऊ', ['ऋ']='ऋ', ['ए']='ए', ['ऐ']='ऐ', ['ओ']='ओ', ['औ']='औ', ['क']='क', ['ख']='ख', ['ग']='ग', ['घ']='घ', ['ङ']='ङ', ['च']='च', ['छ']='छ', ['ज']='ज', ['झ']='झ', ['ञ']='ञ', ['ट']='ट', ['ठ']='ठ', ['ड']='ड', ['ढ']='ढ', ['ण']='ण', ['त']='त', ['थ']='थ', ['द']='द', ['ध']='ध', ['न']='न', ['प']='प', ['फ']='फ', ['ब']='ब', ['भ']='भ', ['म']='म', ['य']='य', ['र']='र', ['ल']='ल', ['व']='व', ['श']='श', ['ष']='ष', ['स']='स', ['ह']='ह', ['ा']='ा', ['ि']='ि', ['ी']='ी', ['ु']='ु', ['ू']='ू', ['े']='े', ['ै']='ै', ['ो']='ो', ['ौ']='ौ', ['्']='्', ['०']='०', ['१']='१', ['२']='२', ['३']='३', ['४']='४', ['५']='५', ['६']='६', ['७']='७', ['८']='८', ['९']='९', -- Hiragana (U+3041–U+3096) ['ぁ']='ぁ',['あ']='あ',['ぃ']='ぃ',['い']='い',['ぅ']='ぅ',['う']='う',['ぇ']='ぇ',['え']='え', ['ぉ']='ぉ',['お']='お',['か']='か',['が']='が',['き']='き',['ぎ']='ぎ',['く']='く',['ぐ']='ぐ', ['け']='け',['げ']='げ',['こ']='こ',['ご']='ご',['さ']='さ',['ざ']='ざ',['し']='し',['じ']='じ', ['す']='す',['ず']='ず',['せ']='せ',['ぜ']='ぜ',['そ']='そ',['ぞ']='ぞ',['た']='た',['だ']='だ', ['ち']='ち',['ぢ']='ぢ',['っ']='っ',['つ']='つ',['づ']='づ',['て']='て',['で']='で',['と']='と', ['ど']='ど',['な']='な',['に']='に',['ぬ']='ぬ',['ね']='ね',['の']='の',['は']='は',['ば']='ば', ['ぱ']='ぱ',['ひ']='ひ',['び']='び',['ぴ']='ぴ',['ふ']='ふ',['ぶ']='ぶ',['ぷ']='ぷ',['へ']='へ', ['べ']='べ',['ぺ']='ぺ',['ほ']='ほ',['ぼ']='ぼ',['ぽ']='ぽ',['ま']='ま',['み']='み',['む']='む', ['め']='め',['も']='も',['ゃ']='ゃ',['や']='や',['ゅ']='ゅ',['ゆ']='ゆ',['ょ']='ょ',['よ']='よ', ['ら']='ら',['り']='り',['る']='る',['れ']='れ',['ろ']='ろ',['ゎ']='ゎ',['わ']='わ',['ゐ']='ゐ', ['ゑ']='ゑ',['を']='を',['ん']='ん',['ゔ']='ゔ',['ゕ']='ゕ',['ゖ']='ゖ', -- Katakana (U+30A1–U+30F6) ['ァ']='ァ',['ア']='ア',['ィ']='ィ',['イ']='イ',['ゥ']='ゥ',['ウ']='ウ',['ェ']='ェ',['エ']='エ', ['ォ']='ォ',['オ']='オ',['カ']='カ',['ガ']='ガ',['キ']='キ',['ギ']='ギ',['ク']='ク',['グ']='グ', ['ケ']='ケ',['ゲ']='ゲ',['コ']='コ',['ゴ']='ゴ',['サ']='サ',['ザ']='ザ',['シ']='シ',['ジ']='ジ', ['ス']='ス',['ズ']='ズ',['セ']='セ',['ゼ']='ゼ',['ソ']='ソ',['ゾ']='ゾ',['タ']='タ',['ダ']='ダ', ['チ']='チ',['ヂ']='ヂ',['ッ']='ッ',['ツ']='ツ',['ヅ']='ヅ',['テ']='テ',['デ']='デ',['ト']='ト', ['ド']='ド',['ナ']='ナ',['ニ']='ニ',['ヌ']='ヌ',['ネ']='ネ',['ノ']='ノ',['ハ']='ハ',['バ']='バ', ['パ']='パ',['ヒ']='ヒ',['ビ']='ビ',['ピ']='ピ',['フ']='フ',['ブ']='ブ',['プ']='プ',['ヘ']='ヘ', ['ベ']='ベ',['ペ']='ペ',['ホ']='ホ',['ボ']='ボ',['ポ']='ポ',['マ']='マ',['ミ']='ミ',['ム']='ム', ['メ']='メ',['モ']='モ',['ャ']='ャ',['ヤ']='ヤ',['ュ']='ュ',['ユ']='ユ',['ョ']='ョ',['ヨ']='ヨ', ['ラ']='ラ',['リ']='リ',['ル']='ル',['レ']='レ',['ロ']='ロ',['ヮ']='ヮ',['ワ']='ワ',['ヰ']='ヰ', ['ヱ']='ヱ',['ヲ']='ヲ',['ン']='ン',['ヴ']='ヴ',['ヵ']='ヵ',['ヶ']='ヶ', -- Thai (U+0E01–U+0E5B) — consonants, vowels, tone marks, digits ['ก']='ก', ['ข']='ข', ['ค']='ค', ['ง']='ง', ['จ']='จ', ['ช']='ช', ['ซ']='ซ', ['ญ']='ญ', ['ด']='ด', ['ต']='ต', ['ถ']='ถ', ['ท']='ท', ['น']='น', ['บ']='บ', ['ป']='ป', ['ผ']='ผ', ['ฝ']='ฝ', ['พ']='พ', ['ฟ']='ฟ', ['ภ']='ภ', ['ม']='ม', ['ย']='ย', ['ร']='ร', ['ล']='ล', ['ว']='ว', ['ส']='ส', ['ห']='ห', ['อ']='อ', ['ฮ']='ฮ', ['ะ']='ะ', ['า']='า', ['ิ']='ิ', ['ี']='ี', ['ึ']='ึ', ['ื']='ื', ['ุ']='ุ', ['ู']='ู', ['เ']='เ', ['แ']='แ', ['โ']='โ', ['ใ']='ใ', ['ไ']='ไ', ['ๆ']='ๆ', ['็']='็', ['่']='่', ['้']='้', ['๊']='๊', ['๋']='๋', ['์']='์', ['๐']='๐', ['๑']='๑', ['๒']='๒', ['๓']='๓', ['๔']='๔', ['๕']='๕', ['๖']='๖', ['๗']='๗', ['๘']='๘', ['๙']='๙', -- Typography & punctuation ['‚']='‚', ['„']='„', ['\xe2\x80\x98']='‘', ['\xe2\x80\x99']='’', ['\xe2\x80\x9c']='“', ['\xe2\x80\x9d']='”', ['…']='…', ['–']='–', ['—']='—', ['•']='•', ['†']='†', ['‡']='‡', ['‰']='‰', ['′']='′', ['″']='″', ['‾']='‾', ['⁄']='⁄', ['‖']='‖', ['‹']='‹', ['›']='›', -- Math & science ['∀']='∀', ['∂']='∂', ['∃']='∃', ['∅']='∅', ['∇']='∇', ['∈']='∈', ['∉']='∉', ['∋']='∋', ['∏']='∏', ['∑']='∑', ['∗']='∗', ['√']='√', ['∝']='∝', ['∞']='∞', ['∠']='∠', ['∧']='∧', ['∨']='∨', ['∩']='∩', ['∪']='∪', ['∫']='∫', ['∴']='∴', ['∼']='∼', ['≅']='≅', ['≈']='≈', ['≠']='≠', ['≡']='≡', ['≤']='≤', ['≥']='≥', ['⊂']='⊂', ['⊃']='⊃', ['⊆']='⊆', ['⊇']='⊇', ['⊕']='⊕', ['⊗']='⊗', ['⊥']='⊥', ['⋅']='⋅', ['⌈']='⌈', ['⌉']='⌉', ['⌊']='⌊', ['⌋']='⌋', -- Arrows ['←']='←', ['→']='→', ['↑']='↑', ['↓']='↓', ['↔']='↔', ['↕']='↕', ['↩']='↩', ['↪']='↪', ['⇐']='⇐', ['⇒']='⇒', ['⇔']='⇔', ['⇑']='⇑', ['⇓']='⇓', -- Currency ['€']='€', ['₿']='₿', ['₹']='₹', ['₩']='₩', ['₪']='₪', ['₫']='₫', ['₭']='₭', ['₮']='₮', ['₱']='₱', ['₲']='₲', ['₴']='₴', ['₵']='₵', ['₸']='₸', ['₺']='₺', ['₼']='₼', ['₽']='₽', -- Misc & media-relevant symbols ['™']='™', ['№']='№', ['℗']='℗', ['℠']='℠', ['℃']='℃', ['℉']='℉', ['♠']='♠', ['♣']='♣', ['♥']='♥', ['♦']='♦', ['♩']='♩', ['♪']='♪', ['♫']='♫', ['♬']='♬', ['▶']='▶', ['◀']='◀', ['▲']='▲', ['▼']='▼', ['⏸']='⏸', ['⏹']='⏹', ['⏺']='⏺', ['★']='★', ['☆']='☆', ['✓']='✓',['✗']='✗', ['☎']='☎', ['✉']='✉', } -- Strip all control characters (0-31 and 127) from a string. local function removeControlChars(str) return (string_gsub(str, "%c", "")) end -- Encode a string to HTML entities. -- & is encoded first to avoid double-encoding subsequent replacements. function htmlentities(s) if not s or not isstring(s) then return s or "" end s = string_gsub(s, '&', '&') for k, v in pairs(entities) do if k ~= '&' then s = string_gsub(s, k, v) end end return s end -- Decode HTML entities back to characters. -- & is decoded last to avoid double-decoding (e.g. &lt; -> < not <). function htmlentities_decode(s) if not s or not isstring(s) then return s or "" end for k, v in pairs(entities) do if k ~= '&' then s = string_gsub(s, v, k) end end s = string_gsub(s, '&', '&') return s end -- Escape the minimal set of characters needed to prevent XSS via HTML injection. -- & is encoded first to avoid double-encoding subsequent replacements. function htmlentities_secure(s) if not s or not isstring(s) then return s or "" end s = string_gsub(s, '&', '&') s = string_gsub(s, '<', '<') s = string_gsub(s, '>', '>') s = string_gsub(s, '"', '"') s = string_gsub(s, "'", ''') s = string_gsub(s, '/', '/') s = string_gsub(s, '\\', '\') return s end -- Percent-encode all non-alphanumeric characters in a string. function escape(s) if not s or not isstring(s) then return s or "" end return string_gsub(s, "([^A-Za-z0-9_])", function(c) return string_format("%%%02x", string_byte(c)) end) end -- Build a lookup set from an array. local function make_set(t) local s = {} for _, v in ipairs(t) do s[v] = 1 end return s end -- Characters that are safe unescaped inside a path segment (RFC 2396). local segment_set = make_set { "-", "_", ".", "!", "~", "*", "'", "(", ")", ":", "@", "&", "=", "+", "$", "," } local function protect_segment(s) return string_gsub(s, "([^A-Za-z0-9_])", function(c) if segment_set[c] then return c else return string_format("%%%02x", string_byte(c)) end end) end -- Decode a percent-encoded string. function unescape(s) if not s or not isstring(s) then return s or "" end return string_gsub(s, "%%(%x%x)", function(hex) local num = tonumber(hex, 16) if num and num >= 0 and num <= 255 then return string_char(num) else return "%" .. hex end end) end -- Returns true if the scheme is in the allowed protocol whitelist. function isAllowedProtocol(scheme) if not scheme or not isstring(scheme) then return false end return ALLOWED_PROTOCOLS[string_lower(scheme)] or false end -- Sanitize a key/value parameter pair. -- Returns nil, nil if either contains dangerous patterns (XSS, injection, etc.). function sanitizeParam(key, value) if not key or not value then return nil, nil end key = tostring(key):sub(1, MAX_PARAM_LENGTH) value = tostring(value):sub(1, MAX_PARAM_LENGTH) local dangerous_patterns = { "]*>", "javascript:", "vbscript:", "data:", "on%w+%s*=", "expression%s*%(", "<%s*iframe", "<%s*object", "<%s*embed", "<%s*link", "<%s*meta" } for _, pattern in ipairs(dangerous_patterns) do if string_match(string_lower(value), pattern) or string_match(string_lower(key), pattern) then return nil, nil end end key = removeControlChars(key) value = removeControlChars(value) return key, value end -- Sanitize a URL path. -- Rejects paths with traversal sequences, excessive depth, or dangerous extensions. -- Returns nil if the path is rejected, otherwise returns the cleaned path string. function sanitizePath(path) if not path or not isstring(path) then return "/" end path = removeControlChars(path) path = string_gsub(path, "\\", "/") local dangerous_sequences = { "%.%./", "%.%.\\", "/%.%./", "\\%.%.\\", "%.%.%.", "/%.%.", "\\%.%." } for _, sequence in ipairs(dangerous_sequences) do path = string_gsub(path, sequence, "/") end local segments = {} for segment in string_gmatch(path, "[^/]+") do if segment ~= "" and segment ~= "." then if segment == ".." then return nil end table_insert(segments, segment) end end if #segments > MAX_PATH_DEPTH then return nil end local clean_path = "/" .. table_concat(segments, "/") local dangerous_extensions = { "%.php$", "%.asp$", "%.jsp$", "%.cgi$", "%.pl$", "%.py$", "%.sh$", "%.bat$", "%.cmd$" } for _, ext_pattern in ipairs(dangerous_extensions) do if string_match(string_lower(clean_path), ext_pattern) then return nil end end return clean_path end -- Validate and sanitize a full URL string. -- Checks length, strips whitespace/control chars, validates protocol and path. -- Returns the cleaned URL string, or nil if rejected. function sanitizeURL(url_string) if not url_string or not isstring(url_string) then return nil end if #url_string > MAX_URL_LENGTH then return nil end url_string = string_gsub(url_string, "^%s+", "") url_string = string_gsub(url_string, "%s+$", "") url_string = removeControlChars(url_string) local parsed = parse(url_string) if not parsed then return nil end if parsed.scheme and not isAllowedProtocol(parsed.scheme) then return nil end if parsed.path then local clean_path = sanitizePath(parsed.path) if not clean_path then return nil end parsed.path = clean_path end return build(parsed) end -- Resolve a relative path against a base path. local function absolute_path(base_path, relative_path) if string_sub(relative_path, 1, 1) == "/" then return relative_path end local path = string_gsub(base_path, "[^/]*$", "") path = path .. relative_path path = string_gsub(path, "([^/]*%./)", function(s) if s ~= "./" then return s else return "" end end) path = string_gsub(path, "/%.$", "/") local reduced while reduced ~= path do reduced = path path = string_gsub(reduced, "([^/]*/%.%./)", function(s) if s ~= "../../" then return "" else return s end end) end path = string_gsub(reduced, "([^/]*/%.%.)$", function(s) if s ~= "../.." then return "" else return s end end) return path end -- Parse a URL string into a table of components (RFC 2396). -- Returns: { scheme, authority, host, port, user, password, path, file, params, query, fragment } function parse(url, default) local parsed = {} for i, v in pairs(default or parsed) do parsed[i] = v end if not url or url == "" then return nil, "invalid url" end url = string_gsub(url, "#(.*)$", function(f) parsed.fragment = f return "" end) url = string_gsub(url, "^([%w][%w%+%-%.]*)%://", function(s) parsed.scheme = string_lower(s) return "" end) url = string_gsub(url, "^([^/%?]*)", function(n) parsed.authority = n return "" end) url = string_gsub(url, "%?(.*)", function(q) parsed.query = q return "" end) url = string_gsub(url, "%;(.*)", function(p) parsed.params = p return "" end) if url ~= "" then parsed.path = url if string_GetFileFromFilename(url) then parsed.file = { name = string_GetFileFromFilename(url), ext = string_GetExtensionFromFilename(url) } end else parsed.path = "/" end local authority = parsed.authority if not authority then return parsed end authority = string_gsub(authority, "^([^@]*)@", function(u) parsed.userinfo = u return "" end) authority = string_gsub(authority, ":([^:]*)$", function(p) local port_num = tonumber(p) if port_num and port_num >= 1 and port_num <= 65535 then parsed.port = p end return "" end) if authority ~= "" then parsed.host = string_lower(authority) end local userinfo = parsed.userinfo if not userinfo then return parsed end userinfo = string_gsub(userinfo, ":([^:]*)$", function(p) parsed.password = p return "" end) parsed.user = userinfo return parsed end -- Split "key=value" on the first '=' only, so "key=a=b" -> key="key", value="a=b". local function split_key_value(pair) local key, value = string_match(pair, "^([^=]*)=?(.*)$") return key, value or "" end -- Parse a query string ("k1=v1&k2=v2") into a sanitized key/value table. -- Parameters that fail sanitization are silently dropped. local function parse_query_string(query_string) local params = {} for pair in string_gmatch(query_string, "[^&]+") do local key, value = split_key_value(pair) if key and key ~= "" then key = unescape(key) value = unescape(value) local clean_key, clean_value = sanitizeParam(key, value) if clean_key and clean_value then params[clean_key] = clean_value end end end return params end -- Like parse(), but also sanitizes the URL and returns structured query/fragment tables. -- -- query -> { key = "value", ... } -- fragment hash_type values: -- "anchor" -> fragment.anchor (e.g. #section1) -- "route" -> fragment.route (e.g. #/users/profile) -- "route_with_params"-> fragment.route + fragment.params -- "parameters" -> fragment.params (e.g. #key=val&key2=val2) -- "content" -> fragment.content (anything else) -- -- Returns nil if the URL fails security validation. function parse2(url, default) local clean_url = sanitizeURL(url) if not clean_url then return nil end local parsed = parse(clean_url, default) if not parsed then return end if parsed.scheme and not isAllowedProtocol(parsed.scheme) then return nil end if parsed.query then parsed.query = parse_query_string(parsed.query) end if parsed.fragment then local fragment = parsed.fragment parsed.fragment = { raw = htmlentities_secure(fragment), params = {}, hash_type = "unknown" } local route_part, query_part = string_match(fragment, "^([^%?]*)%?(.*)$") if route_part and query_part then local clean_route = sanitizePath(route_part) if clean_route then parsed.fragment.hash_type = "route_with_params" parsed.fragment.route = clean_route parsed.fragment.params = parse_query_string(query_part) else parsed.fragment.hash_type = "content" parsed.fragment.content = htmlentities_secure(fragment) return parsed end elseif string_match(fragment, "^[%w%-_]+$") then parsed.fragment.hash_type = "anchor" parsed.fragment.anchor = htmlentities_secure(fragment) elseif string_match(fragment, "[&=]") then parsed.fragment.hash_type = "parameters" parsed.fragment.params = parse_query_string(fragment) elseif string_match(fragment, "^/") then local clean_route = sanitizePath(fragment) if clean_route then parsed.fragment.hash_type = "route" parsed.fragment.route = clean_route else parsed.fragment.hash_type = "content" parsed.fragment.content = htmlentities_secure(fragment) end else parsed.fragment.hash_type = "content" parsed.fragment.content = htmlentities_secure(fragment) end end return parsed end -- Rebuild a URL string from a parsed component table. -- Handles both string query/fragment (from parse) and table forms (from parse2). function build(parsed) local url = (parsed.path or ""):gsub("[^/]+", unescape) url = url:gsub("[^/]*", protect_segment) if parsed.params then url = url .. ";" .. parsed.params end if parsed.query then if istable(parsed.query) then local parts = {} for k, v in pairs(parsed.query) do table_insert(parts, escape(tostring(k)) .. "=" .. escape(tostring(v))) end if #parts > 0 then url = url .. "?" .. table_concat(parts, "&") end else url = url .. "?" .. tostring(parsed.query) end end local authority = parsed.authority if parsed.host then authority = parsed.host if parsed.port then authority = authority .. ":" .. parsed.port end local userinfo = parsed.userinfo if parsed.user then userinfo = parsed.user if parsed.password then userinfo = userinfo .. ":" .. parsed.password end end if userinfo then authority = userinfo .. "@" .. authority end end if authority then url = "//" .. authority .. url end if parsed.scheme then url = parsed.scheme .. ":" .. url end if parsed.fragment then if istable(parsed.fragment) then url = url .. "#" .. (parsed.fragment.raw or "") else url = url .. "#" .. tostring(parsed.fragment) end end url = string_gsub(url, "%?$", "") url = string_gsub(url, "/$", "") return url end -- Compose an absolute URL from a base URL and a relative URL (RFC 2396). function absolute(base_url, relative_url) local base_parsed if istable(base_url) then base_parsed = base_url base_url = build(base_parsed) else base_parsed = parse(base_url) end local relative_parsed = parse(relative_url) if not base_parsed then return relative_url end if not relative_parsed then return base_url end if relative_parsed.scheme then return relative_url end relative_parsed.scheme = base_parsed.scheme if not relative_parsed.authority then relative_parsed.authority = base_parsed.authority if not relative_parsed.path then relative_parsed.path = base_parsed.path if not relative_parsed.params then relative_parsed.params = base_parsed.params if not relative_parsed.query then relative_parsed.query = base_parsed.query end end else relative_parsed.path = absolute_path(base_parsed.path or "", relative_parsed.path) end end return build(relative_parsed) end -- Break a path string into an unescaped segment list. function parse_path(path) local parsed = {} path = path or "" string_gsub(path, "([^/]*)", function(s) table_insert(parsed, s) end) for i = 1, #parsed do parsed[i] = unescape(parsed[i]) end if string_sub(path, 1, 1) == "/" then parsed.is_absolute = 1 end if string_sub(path, -1, -1) == "/" then parsed.is_directory = 1 end return parsed end -- Build a path string from a segment list produced by parse_path. -- Pass unsafe=true to skip percent-encoding of segments. function build_path(parsed, unsafe) local path = "" local escape_fn = unsafe and function(x) return x end or protect_segment local n = #parsed for i = 1, n - 1 do if parsed[i] ~= "" or parsed[i + 1] == "" then path = path .. escape_fn(parsed[i]) if i < n - 1 or parsed[i + 1] ~= "" then path = path .. "/" end end end if n > 0 then path = path .. escape_fn(parsed[n]) if parsed.is_directory then path = path .. "/" end end if parsed.is_absolute then path = "/" .. path end return path end ----------------------------------------------------------------------------- -- Helper functions ----------------------------------------------------------------------------- -- Return the value of a query parameter from a parse2 result, or nil. function getQueryParam(parsed_url, param_name) if not parsed_url or not parsed_url.query then return nil end if istable(parsed_url.query) then return parsed_url.query[param_name] end return nil end -- Return the value of a fragment parameter from a parse2 result, or nil. function getFragmentParam(parsed_url, param_name) if not parsed_url or not parsed_url.fragment then return nil end if istable(parsed_url.fragment) and parsed_url.fragment.params then return parsed_url.fragment.params[param_name] end return nil end -- Return true if the fragment's hash_type matches the given string. function hasHashType(parsed_url, hash_type) if not parsed_url or not parsed_url.fragment then return false end if istable(parsed_url.fragment) then return parsed_url.fragment.hash_type == hash_type end return false end -- Return true if the parsed URL has the named query parameter. function hasQueryParam(parsed_url, param_name) if not parsed_url or not parsed_url.query then return false end if istable(parsed_url.query) then return parsed_url.query[param_name] ~= nil end return false end -- Return true if the URL string has a valid scheme and host. function isValidURL(url_string) if not url_string or url_string == "" then return false end local parsed = parse(url_string) return parsed ~= nil and parsed.scheme ~= nil and parsed.host ~= nil end -- Normalize a parsed URL in-place: lowercase host, strip default ports, ensure path. function normalizeURL(parsed_url) if not parsed_url then return nil end if parsed_url.host then parsed_url.host = string_lower(parsed_url.host) end if parsed_url.port then if (parsed_url.scheme == "http" and parsed_url.port == "80") or (parsed_url.scheme == "https" and parsed_url.port == "443") then parsed_url.port = nil end end if not parsed_url.path or parsed_url.path == "" then parsed_url.path = "/" end return parsed_url end -- Like getQueryParam but validates the param name and HTML-escapes the value. function getQueryParamSecure(parsed_url, param_name) if not parsed_url or not parsed_url.query or not param_name then return nil end local clean_name, _ = sanitizeParam(param_name, "dummy") if not clean_name then return nil end if istable(parsed_url.query) then local value = parsed_url.query[clean_name] return value and htmlentities_secure(value) or nil end return nil end -- Like getFragmentParam but validates the param name and HTML-escapes the value. function getFragmentParamSecure(parsed_url, param_name) if not parsed_url or not parsed_url.fragment or not param_name then return nil end local clean_name, _ = sanitizeParam(param_name, "dummy") if not clean_name then return nil end if istable(parsed_url.fragment) and parsed_url.fragment.params then local value = parsed_url.fragment.params[clean_name] return value and htmlentities_secure(value) or nil end return nil end