diff --git a/.gitmodules b/.gitmodules index 748e50f5b1..2387ef3ad5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1361,6 +1361,9 @@ [submodule "vendor/grammars/typst-grammar"] path = vendor/grammars/typst-grammar url = https://github.com/michidk/typst-grammar.git +[submodule "vendor/grammars/ucode-lsp"] + path = vendor/grammars/ucode-lsp + url = https://github.com/NoahBPeterson/ucode-lsp.git [submodule "vendor/grammars/verilog.tmbundle"] path = vendor/grammars/verilog.tmbundle url = https://github.com/textmate/verilog.tmbundle diff --git a/grammars.yml b/grammars.yml index a15a5790af..12a233eebb 100644 --- a/grammars.yml +++ b/grammars.yml @@ -1223,6 +1223,8 @@ vendor/grammars/typespec: - source.tsp vendor/grammars/typst-grammar: - source.typst +vendor/grammars/ucode-lsp: +- source.ucode vendor/grammars/verilog.tmbundle: - source.verilog vendor/grammars/vsc-ember-syntax: diff --git a/lib/linguist/heuristics.yml b/lib/linguist/heuristics.yml index c621d3ab4c..746491fe74 100644 --- a/lib/linguist/heuristics.yml +++ b/lib/linguist/heuristics.yml @@ -1019,6 +1019,12 @@ disambiguations: - language: Typst pattern: '^#(import|show|let|set)' - language: XML +- extensions: ['.uc'] + rules: + - language: ucode + named_pattern: ucode + - language: UnrealScript + named_pattern: unrealscript - extensions: ['.url'] rules: - language: INI @@ -1137,6 +1143,19 @@ named_patterns: - '(?i)^[ ]*\$(CONSOLE|CHECKING):' - '(?i)^[ ]*\$(FULLSCREEN|RESIZE|STATIC|DYNAMIC|NOPREFIX|SCREENSHOW|SCREENHIDE|EXEICON)\b' raku: '^\s*(?:use\s+v6\b|\bmodule\b|\b(?:my\s+)?class\b)' + ucode: + - '^#!.*\bucode\b' + - '^\s*[''"]use strict[''"];' + - '^[ \t]*(?:export|import)\b' + - '\b(?:require|include)\s*\(\s*[''"]' + - '^\s*let\s+\w+' + - '\b(?:printf|sprintf)\s*\(' + unrealscript: + - '(?i)^\s*class\s+\w+\s+extends\s+[\w.]+' + - '(?i)^\s*defaultproperties\b' + - '(?i)^\s*var\s*\(' + - '(?i)^\s*(?:simulated|exec|native|reliable|unreliable|static\s+final|final)\s+(?:function|event)\b' + - '(?i)^\s*#exec\b' vb-class: '^[ ]*VERSION [0-9]\.[0-9] CLASS' vb-form: '^[ ]*VERSION [0-9]\.[0-9]{2}' vb-module: '^[ ]*Attribute VB_Name = ' diff --git a/lib/linguist/languages.yml b/lib/linguist/languages.yml index 92b013df81..ec6157456b 100644 --- a/lib/linguist/languages.yml +++ b/lib/linguist/languages.yml @@ -9413,6 +9413,18 @@ templ: ace_mode: text tm_scope: source.templ language_id: 795579337 +ucode: + type: programming + color: "#00b8d4" + extensions: + - ".uc" + interpreters: + - ucode + ace_mode: javascript + codemirror_mode: javascript + codemirror_mime_type: text/javascript + tm_scope: source.ucode + language_id: 365454253 vCard: type: data color: "#ee2647" diff --git a/samples/ucode/commands.uc b/samples/ucode/commands.uc new file mode 100644 index 0000000000..9126d59eb0 --- /dev/null +++ b/samples/ucode/commands.uc @@ -0,0 +1,256 @@ +// Copyright 2012-2022 Jo-Philipp Wich +// Licensed to the public under the Apache License 2.0. + +'use strict'; + +import { basename, mkstemp, popen } from 'fs'; +import { urldecode } from 'luci.http'; + +// Decode a given string into arguments following shell quoting rules +// [[abc\ def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]] +function parse_args(str) { + let args = []; + + function isspace(c) { + if (c == 9 || c == 10 || c == 11 || c == 12 || c == 13 || c == 32) + return c; + } + + function isquote(c) { + if (c == 34 || c == 39 || c == 96) + return c; + } + + function isescape(c) { + if (c == 92) + return c; + } + + function ismeta(c) { + if (c == 36 || c == 92 || c == 96) + return c; + } + + // Scan substring defined by the indexes [s, e] of the string "str", + // perform unquoting and de-escaping on the fly and store the result + function unquote(start, end) { + let esc, quote, res = []; + + for (let off = start; off < end; off++) { + const byte = ord(str, off); + const q = isquote(byte); + const e = isescape(byte); + const m = ismeta(byte); + + if (esc) { + if (!m) + push(res, 92); + + push(res, byte); + esc = false; + } + else if (e && quote != 39) { + esc = true; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + quote = q; + } + else { + push(res, byte); + } + } + + push(args, chr(...res)); + } + + // Find substring boundaries in "str". Ignore escaped or quoted + // whitespace, pass found start- and end-index for each substring + // to unquote() + let esc, start, quote; + + for (let off = 0; off <= length(str); off++) { + const byte = ord(str, off); + const q = isquote(byte); + const s = isspace(byte) ?? (byte === null); + const e = isescape(byte); + + if (esc) { + esc = false; + } + else if (e && quote != 39) { + esc = true; + start ??= off; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + start ??= off; + quote = q; + } + else if (s && !quote) { + if (start !== null) { + unquote(start, off); + start = null; + } + } + else { + start ??= off; + } + } + + // If the "quote" is still set we encountered an unfinished string + if (quote) + unquote(start, length(str)); + + return args; +} + +function test_binary(str) { + for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off)) + if (byte <= 8 || (byte >= 14 && byte <= 31)) + return true; + + return false; +} + +function parse_cmdline(cmdid, args) { + if (uci.get('luci', cmdid) == 'command') { + let cmd = uci.get_all('luci', cmdid); + let argv = parse_args(cmd?.command); + + if (cmd?.param == '1') { + if (length(args)) + push(argv, ...(parse_args(urldecode(args)) ?? [])); + else if (length(args = http.formvalue('args'))) + push(argv, ...(parse_args(args) ?? [])); + } + + return map(argv, v => match(v, /[^\w.\/|-]/) ? `'${replace(v, "'", "'\\''")}'` : v); + } +} + +function execute_command(callback, ...args) { + let argv = parse_cmdline(...args); + + if (argv) { + let outfd = mkstemp(); + let errfd = mkstemp(); + + const exitcode = system(`${join(' ', argv)} >&${outfd.fileno()} 2>&${errfd.fileno()}`); + + outfd.seek(0); + errfd.seek(0); + + const stdout = outfd.read(1024 * 512) ?? ''; + const stderr = errfd.read(1024 * 512) ?? ''; + + outfd.close(); + errfd.close(); + + const binary = test_binary(stdout); + + callback({ + ok: true, + command: join(' ', argv), + stdout: binary ? null : stdout, + stderr, + exitcode, + binary + }); + } + else { + callback({ + ok: false, + code: 404, + reason: "No such command" + }); + } +} + +function return_json(result) { + if (result.ok) { + http.prepare_content('application/json'); + http.write_json(result); + } + else { + http.status(result.code, result.reason); + } +} + + +function return_html(result) { + if (result.ok) { + include('commands_public', result); + } + else { + http.status(result.code, result.reason); + } +} + +return { + action_run: function(...args) { + execute_command(return_json, ...args); + }, + + action_download: function(...args) { + const argv = parse_cmdline(...args); + + if (argv) { + const fd = popen(`${join(' ', argv)} 2>/dev/null`); + + if (fd) { + let filename = replace(basename(argv[0]), /\W+/g, '.'); + let chunk = fd.read(4096) ?? ''; + let name; + + if (test_binary(chunk)) { + http.header("Content-Disposition", `attachment; filename=${filename}.bin`); + http.prepare_content("application/octet-stream"); + } + else { + http.header("Content-Disposition", `attachment; filename=${filename}.txt`); + http.prepare_content("text/plain"); + } + + while (length(chunk)) { + http.write(chunk); + chunk = fd.read(4096); + } + + fd.close(); + } + else { + http.status(500, "Failed to execute command"); + } + } + else { + http.status(404, "No such command"); + } + }, + + action_public: function(cmdid, ...args) { + let disp = false; + + if (substr(cmdid, -1) == "s") { + disp = true; + cmdid = substr(cmdid, 0, -1); + } + + if (cmdid && + uci.get('luci', cmdid) == 'command' && + uci.get('luci', cmdid, 'public') == '1') + { + if (disp) + execute_command(return_html, cmdid, ...args); + else + this.action_download(cmdid, args); + } + else { + http.status(403, "Access to command denied"); + } + } +}; diff --git a/samples/ucode/ddns.uc b/samples/ucode/ddns.uc new file mode 100644 index 0000000000..d7b0af2f12 --- /dev/null +++ b/samples/ucode/ddns.uc @@ -0,0 +1,319 @@ +#!/usr/bin/env ucode + +'use strict'; + +import { readfile, popen, stat, glob } from 'fs'; +import { init_enabled } from 'luci.sys'; +import { isnan } from 'math'; +import { cursor } from 'uci'; + +const uci = cursor(); +const ddns_log_path = '/var/log/ddns'; +const ddns_package_path = '/usr/share/ddns'; +const ddns_run_path = '/var/run/ddns'; +const luci_helper = '/usr/lib/ddns/dynamic_dns_lucihelper.sh'; +const ddns_version_file = '/usr/share/ddns/version'; + + +function shellquote(value) { + if (value == null) + value = ''; + + return "'" + replace(value, "'", "'\\''") + "'"; +} + +function get_dateformat() { + return uci.get('ddns', 'global', 'ddns_dateformat') || '%F %R'; +} + +function uptime() { + return split(readfile('/proc/uptime'), ' ')?.[0]; +} + +function killcmd(procid, signal) { + if (!signal) { + signal = 0; + } + // by default, we simply re-nice a process to check it is running + return system(`kill -${signal} ${procid}`); +} + +function trimnonewline(input) { + return replace(trim(input), /\n/g, ''); +} + +function get_date(seconds, format) { + return trimnonewline( popen(`date -d @${seconds} "+${format}" 2>/dev/null`, 'r')?.read?.('line') ); +} + +// convert epoch date to given format +function epoch2date(epoch, format) { + if (!format || length(format) < 2) { + format = get_dateformat(); + } + format = replace(format, /%n/g, '
'); // Replace '%n' with '
' + format = replace(format, /%t/g, ' '); // Replace '%t' with four spaces + + return get_date(epoch, format); +} + +// function to calculate seconds from given interval and unit +function calc_seconds(interval, unit) { + let parsedInterval = int(interval); + if (isnan(parsedInterval)) { + return null; + } + + switch (unit) { + case 'days': + return parsedInterval * 86400; // 60 sec * 60 min * 24 h + case 'hours': + return parsedInterval * 3600; // 60 sec * 60 min + case 'minutes': + return parsedInterval * 60; // 60 sec + case 'seconds': + return parsedInterval; + default: + return null; + } +} + +const methods = { + get_services_log: { + args: { service_name: 'service_name' }, + call: function(request) { + let result = 'File not found or empty'; + + // Get the log directory. Fall back to '/var/log/ddns' if not found + let logdir = uci.get('ddns', 'global', 'ddns_logdir') || ddns_log_path; + + // Fall back to default logdir with insecure path + if (match(logdir, /\.\.\//)) { + logdir = ddns_log_path; + } + + // Check if service_name is provided and log file exists + if (request.args && request.args.service_name && stat(`${logdir}/${request.args.service_name}.log`)?.type == 'file' ) { + result = readfile(`${logdir}/${request.args.service_name}.log`); + } + + uci.unload(); + return { result: result }; + } + }, + + get_services_status: { + call: function() { + const rundir = uci.get('ddns', 'global', 'ddns_rundir') || ddns_run_path; + let res = {}; + + uci.foreach('ddns', 'service', function(s) { + /* uci.foreach danger zone: if you inadvertently call uci.unload('ddns') + anywhere in this foreach loop, you will produce some spectacular undefined behaviour */ + let ip, lastUpdate, nextUpdate, nextCheck; + const section = s['.name']; + if (section == '.anonymous') + return; + + if (stat(`${rundir}/${section}.ip`)?.type == 'file') { + ip = readfile(`${rundir}/${section}.ip`); + } else { + const dnsServer = s['dns_server'] || ''; + const forceIpVersion = int(s['force_ipversion'] || 0); + const forceDnsTcp = int(s['force_dnstcp'] || 0); + // const isGlue = int(s['is_glue'] || 0); + const useIpv6 = int(s['use_ipv6'] || 0); + const lookupHost = s['lookup_host'] || '_nolookup_'; + let command = [luci_helper]; + + if (useIpv6 == 1) push(command, '-6'); + if (forceIpVersion == 1) push(command, '-f'); + if (forceDnsTcp == 1) push(command, '-t'); + // if (isGlue == 1) push(command, '-g'); + + push(command, '-l', shellquote(lookupHost)); + push(command, '-S', shellquote(section)); + if (length(dnsServer) > 0) push(command, '-d', shellquote(dnsServer)); + push(command, '-- get_registered_ip'); + + const result = system(`${join(' ', command)}`); + } + + lastUpdate = int(readfile(`${rundir}/${section}.update`) || 0); + nextCheck = int(readfile(`${rundir}/${section}.nextcheck`) || 0); + + let pid = int(readfile(`${rundir}/${section}.pid`) || 0); + + // if killcmd succeeds (0) to re-nice the process, we do not assume the pid is dead + if (pid > 0 && killcmd(pid)) { + pid = 0; + } + + let _uptime = int(uptime()); + + const forcedUpdateInterval = calc_seconds( + int(s['force_interval']) || 72, + s['force_unit'] || 'hours' + ); + + const checkInterval = calc_seconds( + int(s['check_interval']) || 10, + s['check_unit'] || 'minutes' + ); + + let convertedLastUpdate; + if (lastUpdate > 0) { + const epoch = time() - _uptime + lastUpdate; + convertedLastUpdate = epoch2date(epoch); + nextUpdate = epoch2date(epoch + forcedUpdateInterval); + } + + let convertedNextCheck; + if (nextCheck > 0) { + const epoch = time() - _uptime + nextCheck; + convertedNextCheck = epoch2date(epoch); + } + + if (pid > 0 && (lastUpdate + forcedUpdateInterval - _uptime) <= 0) { + nextUpdate = 'Verify'; + } else if (forcedUpdateInterval === 0) { + nextUpdate = 'Run once'; + } else if (pid == 0 && s['enabled'] == '0') { + nextUpdate = 'Disabled'; + } else if (pid == 0 && s['enabled'] != '0') { + nextUpdate = 'Stopped'; + } + + res[section] = { + ip: ip ? replace(trim(ip), '\n', '
') : null, + last_update: lastUpdate !== 0 ? convertedLastUpdate : null, + next_update: nextUpdate || null, + next_check : nextCheck !== 0 ? convertedNextCheck : null, + pid: pid || null, + }; + }); + + uci.unload('ddns'); + return res; + } + }, + + get_ddns_state: { + call: function() { + + const services_mtime = stat(ddns_package_path + '/list')?.mtime; + let res = {}; + let ver, control; + + if (stat(ddns_version_file)?.type == 'file') { + ver = readfile(ddns_version_file); + } + + res['_version'] = ver; + res['_enabled'] = init_enabled('ddns'); + res['_curr_dateformat'] = epoch2date(time()); + res['_services_list'] = (services_mtime && epoch2date(services_mtime)) || 'NO_LIST'; + + uci.unload('ddns'); + return res; + } + }, + + get_env: { + call: function () { + let res = {}; + let cache = {}; + + const hasCommand = (command) => { return (system(`command -v ${command} 1>/dev/null`) == 0) ? true : false }; + + const hasWget = () => { + return cache.has_wget ??= hasCommand('wget'); + }; + + const hasWgetSsl = () => { + return cache.has_wgetssl ??= hasWget() && system(`wget 2>&1 | grep -iqF 'https'`) == 0 ? true : false; + }; + + const hasGNUWgetSsl = () => { + return cache.has_gnuwgetssl ??= hasWget() && system(`wget -V 2>&1 | grep -iqF '+https'`) == 0 ? true : false; + }; + + const hasCurl = () => { + return cache.has_curl ??= hasCommand('curl'); + }; + + const hasCurlSsl = () => { + return cache.has_curl_ssl ??= system(`curl -V 2>&1 | grep -qF 'https'`) == 0 ? true : false; + }; + + const hasFetch = () => { + return cache.has_fetch ??= hasCommand('uclient-fetch'); + }; + + const hasFetchSsl = () => { + return cache.has_fetch_ssl ??= stat('/lib/libustream-ssl.so') ? true : false; + }; + + const hasCurlPxy = () => { + return cache.has_curl_proxy ??= system(`grep -i 'all_proxy' /usr/lib/libcurl.so*`) == 0 ? true : false; + }; + + const hasBbwget = () => { + return cache.has_bbwget ??= system(`wget -V 2>&1 | grep -iqF 'busybox'`) == 0 ? true : false; + }; + + + res['has_wget'] = hasWget(); + res['has_curl'] = hasCurl(); + + res['has_ssl'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurlSsl() || (hasFetch() && hasFetchSsl()); + res['has_proxy'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurlPxy() || hasFetch() || hasBbwget(); + res['has_forceip'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurl() || hasFetch(); + res['has_bindnet'] = hasCurl() || hasGNUWgetSsl(); + + const hasBindHost = () => { + if (cache['has_bindhost']) return cache['has_bindhost']; + const commands = ['host', 'khost', 'drill']; + for (let command in commands) { + if (hasCommand(command)) { + cache['has_bindhost'] = true; + return true; + } + } + + cache['has_bindhost'] = false; + return false; + }; + + res['has_bindhost'] = cache['has_bindhost'] || hasBindHost(); + + const hasHostIp = () => { + return hasCommand('hostip'); + }; + + const hasNslookup = () => { + return hasCommand('nslookup'); + }; + + res['has_dnsserver'] = cache['has_bindhost'] || hasNslookup() || hasHostIp() || hasBindHost(); + + const checkCerts = () => { + let present = false; + for (let cert in glob('/etc/ssl/certs/*.crt', '/etc/ssl/certs/*.pem')) { + if (cert != null) + present = true; + } + return present; + }; + + res['has_cacerts'] = checkCerts(); + + res['has_ipv6'] = (stat('/proc/net/ipv6_route')?.type == 'file' && + (stat('/usr/sbin/ip6tables')?.type == 'file' || stat('/usr/sbin/nft')?.type == 'file')); + + return res; + } + } +}; + +return { 'luci.ddns': methods }; diff --git a/samples/ucode/http.uc b/samples/ucode/http.uc new file mode 100644 index 0000000000..350bcc5eef --- /dev/null +++ b/samples/ucode/http.uc @@ -0,0 +1,611 @@ +// Copyright 2022 Jo-Philipp Wich +// Licensed to the public under the Apache License 2.0. + +import { + urlencode as _urlencode, + urldecode as _urldecode, + urlencoded_parser, multipart_parser, header_attribute, + ENCODE_IF_NEEDED, ENCODE_FULL, DECODE_IF_NEEDED, DECODE_PLUS +} from 'lucihttp'; + +import { + error as fserror, + stdin, stdout, mkstemp +} from 'fs'; + +import { + openlog, syslog, closelog, LOG_NOTICE, LOG_LOCAL0 +} from 'log'; + +import { run_plugins } from 'luciplugins'; + +// luci.http module scope +export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size + +// Decode a mime encoded http message body with multipart/form-data +// Content-Type. Stores all extracted data associated with its parameter name +// in the params table within the given message object. Multiple parameter +// values are stored as tables, ordinary ones as strings. +// If an optional file callback function is given then it is fed with the +// file contents chunk by chunk and only the extracted file name is stored +// within the params table. The callback function will be called subsequently +// with three arguments: +// o Table containing decoded (name, file) and raw (headers) mime header data +// o String value containing a chunk of the file data +// o Boolean which indicates whether the current chunk is the last one (eof) +export function mimedecode_message_body(src, msg, file_cb) { + let len = 0, maxlen = +msg.env.CONTENT_LENGTH; + let err, header, field, parser; + + parser = multipart_parser(msg.env.CONTENT_TYPE, function(what, buffer, length) { + if (what == parser.PART_INIT) { + field = {}; + } + else if (what == parser.HEADER_NAME) { + header = lc(buffer); + } + else if (what == parser.HEADER_VALUE && header) { + if (lc(header) == 'content-disposition' && + header_attribute(buffer, null) == 'form-data') { + field.name = header_attribute(buffer, 'name'); + field.file = header_attribute(buffer, 'filename'); + field[1] = field.file; + } + + field.headers = field.headers || {}; + field.headers[header] = buffer; + } + else if (what == parser.PART_BEGIN) { + return !field.file; + } + else if (what == parser.PART_DATA && field.name && length > 0) { + if (field.file) { + if (file_cb) { + file_cb(field, buffer, false); + + msg.params[field.name] = msg.params[field.name] || field; + } + else { + if (!field.fd) + field.fd = mkstemp(field.name); + + if (field.fd) { + field.fd.write(buffer); + msg.params[field.name] = msg.params[field.name] || field; + } + } + } + else { + field.value = buffer; + } + } + else if (what == parser.PART_END && field.name) { + if (field.file && msg.params[field.name]) { + if (file_cb) + file_cb(field, '', true); + else if (field.fd) + field.fd.seek(0); + } + else { + let val = msg.params[field.name]; + + if (type(val) == 'array') + push(val, field.value || ''); + else if (val != null) + msg.params[field.name] = [ val, field.value || '' ]; + else + msg.params[field.name] = field.value || ''; + } + + field = null; + } + else if (what == parser.ERROR) { + err = buffer; + } + + return true; + }, HTTP_MAX_CONTENT); + + while (true) { + let chunk = src(); + + len += length(chunk); + + if (maxlen && len > maxlen + 2) + die('Message body size exceeds Content-Length'); + + if (!parser.parse(chunk)) + die(err); + + if (chunk == null) + break; + } +}; + +// Decode an urlencoded http message body with application/x-www-urlencoded +// Content-Type. Stores all extracted data associated with its parameter name +// in the params table within the given message object. Multiple parameter +// values are stored as tables, ordinary ones as strings. +export function urldecode_message_body(src, msg) { + let len = 0, maxlen = +msg.env.CONTENT_LENGTH; + let err, name, value, parser; + + parser = urlencoded_parser(function (what, buffer, length) { + if (what == parser.TUPLE) { + name = null; + value = null; + } + else if (what == parser.NAME) { + name = _urldecode(buffer, DECODE_PLUS); + } + else if (what == parser.VALUE && name) { + let val = msg.params[name]; + + if (type(val) == 'array') + push(val, _urldecode(buffer, DECODE_PLUS) || ''); + else if (val != null) + msg.params[name] = [ val, _urldecode(buffer, DECODE_PLUS) || '' ]; + else + msg.params[name] = _urldecode(buffer, DECODE_PLUS) || ''; + } + else if (what == parser.ERROR) { + err = buffer; + } + + return true; + }, HTTP_MAX_CONTENT); + + while (true) { + let chunk = src(); + + len += length(chunk); + + if (maxlen && len > maxlen + 2) + die('Message body size exceeds Content-Length'); + + if (!parser.parse(chunk)) + die(err); + + if (chunk == null) + break; + } +}; + +// This function will examine the Content-Type within the given message object +// to select the appropriate content decoder. +// Currently the application/x-www-urlencoded and application/form-data +// mime types are supported. If the encountered content encoding can't be +// handled then the whole message body will be stored unaltered as 'content' +// property within the given message object. +export function parse_message_body(src, msg, filecb) { + if (msg.env.CONTENT_LENGTH || msg.env.REQUEST_METHOD == 'POST') { + let ctype = header_attribute(msg.env.CONTENT_TYPE, null); + + // Is it multipart/mime ? + if (ctype == 'multipart/form-data') + return mimedecode_message_body(src, msg, filecb); + + // Is it application/x-www-form-urlencoded ? + else if (ctype == 'application/x-www-form-urlencoded') + return urldecode_message_body(src, msg); + + // Unhandled encoding + // If a file callback is given then feed it chunk by chunk, else + // store whole buffer in message.content + let sink; + + // If we have a file callback then feed it + if (type(filecb) == 'function') { + let meta = { + name: 'raw', + encoding: msg.env.CONTENT_TYPE + }; + + sink = (chunk) => { + if (chunk != null) + return filecb(meta, chunk, false); + else + return filecb(meta, null, true); + }; + } + + // ... else append to .content + else { + let chunks = [], len = 0; + + sink = (chunk) => { + len += length(chunk); + + if (len > HTTP_MAX_CONTENT) + die('POST data exceeds maximum allowed length'); + + if (chunk != null) { + push(chunks, chunk); + } + else { + msg.content = join('', chunks); + msg.content_length = len; + } + }; + } + + // Pump data... + while (true) { + let chunk = src(); + + sink(chunk); + + if (chunk == null) + break; + } + + return true; + } + + return false; +}; + +export function build_querystring(q) { + let s = []; + + for (let k, v in q) { + push(s, + length(s) ? '&' : '?', + _urlencode(k, ENCODE_IF_NEEDED | ENCODE_FULL) || k, + '=', + _urlencode(v, ENCODE_IF_NEEDED | ENCODE_FULL) || v + ); + } + + return join('', s); +}; + +export function urlencode(value) { + if (value == null) + return null; + + value = '' + value; + + return _urlencode(value, ENCODE_IF_NEEDED | ENCODE_FULL) || value; +}; + +export function urldecode(value, decode_plus) { + if (value == null) + return null; + + value = '' + value; + + return _urldecode(value, DECODE_IF_NEEDED | (decode_plus ? DECODE_PLUS : 0)) || value; +}; + +// Extract and split urlencoded data pairs, separated bei either "&" or ";" +// from given url or string. Returns a table with urldecoded values. +// Simple parameters are stored as string values associated with the parameter +// name within the table. Parameters with multiple values are stored as array +// containing the corresponding values. +export function urldecode_params(url, tbl) { + let parser, name, value; + let params = tbl || {}; + + parser = urlencoded_parser(function(what, buffer, length) { + if (what == parser.TUPLE) { + name = null; + value = null; + } + else if (what == parser.NAME) { + name = _urldecode(buffer); + } + else if (what == parser.VALUE && name) { + params[name] = _urldecode(buffer) || ''; + } + + return true; + }); + + if (parser) { + let m = match(('' + (url || '')), /[^?]*$/); + + parser.parse(m ? m[0] : ''); + parser.parse(null); + } + + return params; +}; + +// Encode each key-value-pair in given table to x-www-urlencoded format, +// separated by '&'. Tables are encoded as parameters with multiple values by +// repeating the parameter name with each value. +export function urlencode_params(tbl) { + let enc = []; + + for (let k, v in tbl) { + if (type(v) == 'array') { + for (let v2 in v) { + if (length(enc)) + push(enc, '&'); + + push(enc, + _urlencode(k), + '=', + _urlencode('' + v2)); + } + } + else { + if (length(enc)) + push(enc, '&'); + + push(enc, + _urlencode(k), + '=', + _urlencode('' + v)); + } + } + + return join(enc, ''); +}; + + +// Default IO routines suitable for CGI invocation +let avail_len = +getenv('CONTENT_LENGTH'); + +const default_source = () => { + let rlen = min(avail_len, 4096); + + if (rlen == 0) { + stdin.close(); + + return null; + } + + let chunk = stdin.read(rlen); + + if (chunk == null) + die(`Input read error: ${fserror()}`); + + avail_len -= length(chunk); + + return chunk; +}; + +const default_sink = (...chunks) => { + for (let chunk in chunks) + stdout.write(chunk); + + stdout.flush(); +}; + +const Class = { + formvalue: function(name, noparse) { + if (!noparse && !this.parsed_input) + this._parse_input(); + + if (name != null) + return this.message.params[name]; + else + return this.message.params; + }, + + formvaluetable: function(prefix) { + let vals = {}; + + prefix = (prefix || '') + '.'; + + if (!this.parsed_input) + this._parse_input(); + + for (let k, v in this.message.params) + if (index(k, prefix) == 0) + vals[substr(k, length(prefix))] = '' + v; + + return vals; + }, + + content: function() { + if (!this.parsed_input) + this._parse_input(); + + return this.message.content; + }, + + getcookie: function(name) { + return header_attribute(`cookie; ${this.getenv('HTTP_COOKIE') ?? ''}`, name); + }, + + getenv: function(name) { + if (name != null) + return this.message.env[name]; + else + return this.message.env; + }, + + setfilehandler: function(callback) { + if (type(callback) == 'resource' && type(callback.call) == 'function') + this.filehandler = (...args) => callback.call(...args); + else if (type(callback) == 'function') + this.filehandler = callback; + else + die('Invalid callback argument for setfilehandler()'); + + if (!this.parsed_input) + return; + + // If input has already been parsed then uploads are stored as unlinked + // temporary files pointed to by open file handles in the parameter + // value table. Loop all params, and invoke the file callback for any + // param with an open file handle. + for (let name, value in this.message.params) { + while (value?.fd) { + let data = value.fd.read(1024); + let eof = (length(data) == 0); + + this.filehandler(value, data, eof); + + if (eof) { + value.fd.close(); + value.fd = null; + } + } + } + }, + + _parse_input: function() { + parse_message_body( + this.input, + this.message, + this.filehandler + ); + + this.parsed_input = true; + }, + + close: function() { + this.write_headers(); + this.closed = true; + }, + + header: function(key, value) { + this.headers ??= {}; + this.headers[lc(key)] = value; + }, + + prepare_content: function(mime) { + if (!this.headers?.['content-type']) { + if (mime == 'application/xhtml+xml') { + if (index(this.getenv('HTTP_ACCEPT'), mime) == -1) { + mime = 'text/html; charset=UTF-8'; + this.header('Vary', 'Accept'); + } + } + + this.header('Content-Type', mime); + } + }, + + status: function(code, message) { + this.status_code = code ?? 200; + this.status_message = message ?? 'OK'; + }, + + write_headers: function() { + if (this.eoh) + return; + + if (!this.status_code) + this.status(); + + if (!this.headers?.['content-type']) + this.header('Content-Type', 'text/html; charset=UTF-8'); + + if (!this.headers?.['cache-control']) { + this.header('Cache-Control', 'no-cache'); + this.header('Expires', '0'); + } + + if (!this.headers?.['x-frame-options']) + this.header('X-Frame-Options', 'SAMEORIGIN'); + + if (!this.headers?.['x-xss-protection']) + this.header('X-XSS-Protection', '1; mode=block'); + + if (!this.headers?.['x-content-type-options']) + this.header('X-Content-Type-Options', 'nosniff'); + + /* http header plugins */ + let log_class = 'http.uc'; + openlog(log_class); + for (let plugin_id, p_output in run_plugins('/luci/plugins/http/headers', 'http_headers_enabled')) { + + /* header plugins shall return e.g.: ['X-Header', 'foo'] */ + if (type(p_output) !== 'array' || length(p_output) !== 2) + continue; + + if (type(p_output[0]) !== 'string' || type(p_output[1]) !== 'string') + continue; + + if (!match(p_output[0], /^[A-Za-z0-9-]+$/)) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("Invalid header name from plugin %s output: %s", plugin_id, p_output[0])); + continue; + } + + /* header plugin values shall not contain line-feeds */ + if (match(p_output[1], /[\r\n]/)) { + syslog(LOG_NOTICE|LOG_LOCAL0, + sprintf("\\r and/or \\n in plugin %s output", plugin_id)); + continue; + } + + if(!this.headers?.[p_output[0]]) + this.header(p_output[0], p_output[1]); + + } + closelog(); + + this.output('Status: '); + this.output(this.status_code); + this.output(' '); + this.output(this.status_message); + this.output('\r\n'); + + for (let k, v in this.headers) { + this.output(k); + this.output(': '); + this.output(v); + this.output('\r\n'); + } + + this.output('\r\n'); + + this.eoh = true; + }, + + // If the content chunk is nil this function will automatically invoke close. + write: function(content) { + if (content != null && !this.closed) { + this.write_headers(); + this.output(content); + + return true; + } + else { + this.close(); + } + }, + + redirect: function(url) { + this.status(302, 'Found'); + this.header('Location', url ?? '/'); + this.close(); + }, + + write_json: function(value) { + this.write(sprintf('%.J', value)); + }, + + urlencode, + urlencode_params, + + urldecode, + urldecode_params, + + build_querystring +}; + +export default function(env, sourcein, sinkout) { + return proto({ + input: sourcein ?? default_source, + output: sinkout ?? default_sink, + + // File handler nil by default to let .content() work + file: null, + + // HTTP-Message table + message: { + env, + headers: {}, + params: urldecode_params(env?.QUERY_STRING ?? '') + }, + + parsed_input: false + }, Class); +}; diff --git a/test/test_heuristics.rb b/test/test_heuristics.rb index f708d0fac5..1de5fc48e2 100755 --- a/test/test_heuristics.rb +++ b/test/test_heuristics.rb @@ -1199,6 +1199,13 @@ def test_typ_by_heuristics }) end + def test_uc_by_heuristics + assert_heuristics({ + "ucode" => all_fixtures("ucode", "*.uc"), + "UnrealScript" => all_fixtures("UnrealScript", "*.uc") + }) + end + def test_url_by_heuristics assert_heuristics({ "INI" => Dir.glob("#{fixtures_path}/Generic/url/INI/*"), diff --git a/vendor/README.md b/vendor/README.md index 44de308f78..6a1411bd9e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -754,6 +754,7 @@ This is a list of grammars that Linguist selects to provide syntax highlighting - **reStructuredText:** [Lukasa/language-restructuredtext](https://github.com/Lukasa/language-restructuredtext) - **sed:** [Alhadis/language-sed](https://github.com/Alhadis/language-sed) - **templ:** [templ-go/templ-vscode](https://github.com/templ-go/templ-vscode) +- **ucode:** [NoahBPeterson/ucode-lsp](https://github.com/NoahBPeterson/ucode-lsp) - **vCard:** [cstrachan88/vscode-vcard](https://github.com/cstrachan88/vscode-vcard) - **wisp:** [atom/language-clojure](https://github.com/atom/language-clojure) - **xBase:** [hernad/atom-language-harbour](https://github.com/hernad/atom-language-harbour) diff --git a/vendor/grammars/ucode-lsp b/vendor/grammars/ucode-lsp new file mode 160000 index 0000000000..45ea5f31ad --- /dev/null +++ b/vendor/grammars/ucode-lsp @@ -0,0 +1 @@ +Subproject commit 45ea5f31ad19f3befab8013a44dcb8500ca89399 diff --git a/vendor/licenses/git_submodule/ucode-lsp.dep.yml b/vendor/licenses/git_submodule/ucode-lsp.dep.yml new file mode 100644 index 0000000000..8055666f96 --- /dev/null +++ b/vendor/licenses/git_submodule/ucode-lsp.dep.yml @@ -0,0 +1,33 @@ +--- +name: ucode-lsp +version: 45ea5f31ad19f3befab8013a44dcb8500ca89399 +type: git_submodule +homepage: https://github.com/NoahBPeterson/ucode-lsp.git +license: mit +licenses: +- sources: LICENSE + text: |- + MIT License + + Copyright (c) 2025 Noah Peterson + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- sources: README.md + text: MIT License — see [LICENSE](LICENSE) file for details. +notices: []