From 5e6ef5508bbd847e9100d1ce75ac248f0895c120 Mon Sep 17 00:00:00 2001 From: frack113 <62423083+frack113@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:28:02 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20shared=20r?= =?UTF-8?q?epo=20section=20template,=20DOMPurify=20chat=20sanitization,=20?= =?UTF-8?q?and=20CSS=20modularization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes/page_admin.py | 4 +- src/api/routes/page_logs.py | 5 +- src/presentation/static/css/base/local.css | 103 +++++++ src/presentation/static/css/base/theme.css | 161 ++++------ .../static/css/base/utilities.css | 41 +++ .../static/css/components/forms.css | 51 ++++ src/presentation/static/css/config.css | 79 ----- .../static/css/layout/header-nav.css | 74 +---- .../static/css/layout/sidebar.css | 29 ++ src/presentation/static/css/pages/chat.css | 9 + .../static/js/external/dompurify.min.js | 3 + src/presentation/static/js/internal/chat.js | 73 +++-- src/presentation/static/js/internal/local.js | 185 ++++++++++++ .../static/js/internal/notifications.js | 8 + .../static/js/internal/repo-browser.js | 54 +++- src/presentation/templates/chat/index.html.j2 | 5 +- .../templates/config/config.html.j2 | 24 +- .../templates/dashboard/base.html.j2 | 2 +- src/presentation/templates/data/base.html.j2 | 2 +- .../templates/data/github.html.j2 | 56 +--- src/presentation/templates/data/local.html.j2 | 283 +----------------- .../templates/data/prompts.html.j2 | 18 +- .../templates/data/sigma_spec.html.j2 | 57 +--- .../templates/data/vectordb.html.j2 | 2 +- src/presentation/templates/logs/base.html.j2 | 2 +- .../templates/shared/_header.html.j2 | 36 ++- .../templates/shared/_repo_section.html.j2 | 47 +++ .../templates/shared/layout.html.j2 | 7 +- 28 files changed, 713 insertions(+), 707 deletions(-) create mode 100644 src/presentation/static/css/base/local.css create mode 100644 src/presentation/static/css/base/utilities.css create mode 100644 src/presentation/static/js/external/dompurify.min.js create mode 100644 src/presentation/static/js/internal/local.js create mode 100644 src/presentation/templates/shared/_repo_section.html.j2 diff --git a/src/api/routes/page_admin.py b/src/api/routes/page_admin.py index 2b2a658..a64985f 100644 --- a/src/api/routes/page_admin.py +++ b/src/api/routes/page_admin.py @@ -21,10 +21,12 @@ async def config_page(request: Request): """Serve the unified config dashboard page.""" cfg = get_config().to_dict() + # Escape " tag. + config_json = json.dumps(cfg).replace("1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:f;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function R(e){for(let t=0;t/gm),B=a(/\${[\w\W]*}/gm),W=a(/^data-[\-\w.\u00B7-\uFFFF]/),G=a(/^aria-[\-\w]+$/),Y=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),j=a(/^(?:\w+script|data):/i),X=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),q=a(/^html$/i),$=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var K=Object.freeze({__proto__:null,MUSTACHE_EXPR:H,ERB_EXPR:z,TMPLIT_EXPR:B,DATA_ATTR:W,ARIA_ATTR:G,IS_ALLOWED_URI:Y,IS_SCRIPT_OR_DATA:j,ATTR_WHITESPACE:X,DOCTYPE_NAME:q,CUSTOM_ELEMENT:$});const V=1,Z=3,J=7,Q=8,ee=9,te=function(){return"undefined"==typeof window?null:window};var ne=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:te();const o=e=>t(e);if(o.version="3.1.6",o.removed=[],!n||!n.document||n.document.nodeType!==ee)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:b,Element:R,NodeFilter:H,NamedNodeMap:z=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:B,DOMParser:W,trustedTypes:G}=n,j=R.prototype,X=C(j,"cloneNode"),$=C(j,"remove"),ne=C(j,"nextSibling"),oe=C(j,"childNodes"),re=C(j,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let ie,ae="";const{implementation:le,createNodeIterator:ce,createDocumentFragment:se,getElementsByTagName:ue}=r,{importNode:me}=a;let pe={};o.isSupported="function"==typeof e&&"function"==typeof re&&le&&void 0!==le.createHTMLDocument;const{MUSTACHE_EXPR:fe,ERB_EXPR:de,TMPLIT_EXPR:he,DATA_ATTR:ge,ARIA_ATTR:Te,IS_SCRIPT_OR_DATA:ye,ATTR_WHITESPACE:Ee,CUSTOM_ELEMENT:_e}=K;let{IS_ALLOWED_URI:Ae}=K,Ne=null;const be=S({},[...L,...D,...v,...x,...M]);let Se=null;const Re=S({},[...I,...U,...P,...F]);let we=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ce=null,Le=null,De=!0,ve=!0,Oe=!1,xe=!0,ke=!1,Me=!0,Ie=!1,Ue=!1,Pe=!1,Fe=!1,He=!1,ze=!1,Be=!0,We=!1,Ge=!0,Ye=!1,je={},Xe=null;const qe=S({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let $e=null;const Ke=S({},["audio","video","img","source","image","track"]);let Ve=null;const Ze=S({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Je="http://www.w3.org/1998/Math/MathML",Qe="http://www.w3.org/2000/svg",et="http://www.w3.org/1999/xhtml";let tt=et,nt=!1,ot=null;const rt=S({},[Je,Qe,et],d);let it=null;const at=["application/xhtml+xml","text/html"];let lt=null,ct=null;const st=r.createElement("form"),ut=function(e){return e instanceof RegExp||e instanceof Function},mt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ct||ct!==e){if(e&&"object"==typeof e||(e={}),e=w(e),it=-1===at.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,lt="application/xhtml+xml"===it?d:f,Ne=E(e,"ALLOWED_TAGS")?S({},e.ALLOWED_TAGS,lt):be,Se=E(e,"ALLOWED_ATTR")?S({},e.ALLOWED_ATTR,lt):Re,ot=E(e,"ALLOWED_NAMESPACES")?S({},e.ALLOWED_NAMESPACES,d):rt,Ve=E(e,"ADD_URI_SAFE_ATTR")?S(w(Ze),e.ADD_URI_SAFE_ATTR,lt):Ze,$e=E(e,"ADD_DATA_URI_TAGS")?S(w(Ke),e.ADD_DATA_URI_TAGS,lt):Ke,Xe=E(e,"FORBID_CONTENTS")?S({},e.FORBID_CONTENTS,lt):qe,Ce=E(e,"FORBID_TAGS")?S({},e.FORBID_TAGS,lt):{},Le=E(e,"FORBID_ATTR")?S({},e.FORBID_ATTR,lt):{},je=!!E(e,"USE_PROFILES")&&e.USE_PROFILES,De=!1!==e.ALLOW_ARIA_ATTR,ve=!1!==e.ALLOW_DATA_ATTR,Oe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,xe=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Me=!1!==e.SAFE_FOR_XML,Ie=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,He=e.RETURN_DOM_FRAGMENT||!1,ze=e.RETURN_TRUSTED_TYPE||!1,Pe=e.FORCE_BODY||!1,Be=!1!==e.SANITIZE_DOM,We=e.SANITIZE_NAMED_PROPS||!1,Ge=!1!==e.KEEP_CONTENT,Ye=e.IN_PLACE||!1,Ae=e.ALLOWED_URI_REGEXP||Y,tt=e.NAMESPACE||et,we=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(we.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ut(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(we.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(we.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(ve=!1),He&&(Fe=!0),je&&(Ne=S({},M),Se=[],!0===je.html&&(S(Ne,L),S(Se,I)),!0===je.svg&&(S(Ne,D),S(Se,U),S(Se,F)),!0===je.svgFilters&&(S(Ne,v),S(Se,U),S(Se,F)),!0===je.mathMl&&(S(Ne,x),S(Se,P),S(Se,F))),e.ADD_TAGS&&(Ne===be&&(Ne=w(Ne)),S(Ne,e.ADD_TAGS,lt)),e.ADD_ATTR&&(Se===Re&&(Se=w(Se)),S(Se,e.ADD_ATTR,lt)),e.ADD_URI_SAFE_ATTR&&S(Ve,e.ADD_URI_SAFE_ATTR,lt),e.FORBID_CONTENTS&&(Xe===qe&&(Xe=w(Xe)),S(Xe,e.FORBID_CONTENTS,lt)),Ge&&(Ne["#text"]=!0),Ie&&S(Ne,["html","head","body"]),Ne.table&&(S(Ne,["tbody"]),delete Ce.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ie=e.TRUSTED_TYPES_POLICY,ae=ie.createHTML("")}else void 0===ie&&(ie=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(G,c)),null!==ie&&"string"==typeof ae&&(ae=ie.createHTML(""));i&&i(e),ct=e}},pt=S({},["mi","mo","mn","ms","mtext"]),ft=S({},["foreignobject","annotation-xml"]),dt=S({},["title","style","font","a","script"]),ht=S({},[...D,...v,...O]),gt=S({},[...x,...k]),Tt=function(e){p(o.removed,{element:e});try{re(e).removeChild(e)}catch(t){$(e)}},yt=function(e,t){try{p(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){p(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Se[e])if(Fe||He)try{Tt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},Et=function(e){let t=null,n=null;if(Pe)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===it&&tt===et&&(e=''+e+"");const o=ie?ie.createHTML(e):e;if(tt===et)try{t=(new W).parseFromString(o,it)}catch(e){}if(!t||!t.documentElement){t=le.createDocument(tt,"template",null);try{t.documentElement.innerHTML=nt?ae:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),tt===et?ue.call(t,Ie?"html":"body")[0]:Ie?t.documentElement:i},_t=function(e){return ce.call(e.ownerDocument||e,e,H.SHOW_ELEMENT|H.SHOW_COMMENT|H.SHOW_TEXT|H.SHOW_PROCESSING_INSTRUCTION|H.SHOW_CDATA_SECTION,null)},At=function(e){return e instanceof B&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof z)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof b&&e instanceof b},bt=function(e,t,n){pe[e]&&u(pe[e],(e=>{e.call(o,t,n,ct)}))},St=function(e){let t=null;if(bt("beforeSanitizeElements",e,null),At(e))return Tt(e),!0;const n=lt(e.nodeName);if(bt("uponSanitizeElement",e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&_(/<[/\w]/g,e.innerHTML)&&_(/<[/\w]/g,e.textContent))return Tt(e),!0;if(e.nodeType===J)return Tt(e),!0;if(Me&&e.nodeType===Q&&_(/<[/\w]/g,e.data))return Tt(e),!0;if(!Ne[n]||Ce[n]){if(!Ce[n]&&wt(n)){if(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n))return!1;if(we.tagNameCheck instanceof Function&&we.tagNameCheck(n))return!1}if(Ge&&!Xe[n]){const t=re(e)||e.parentNode,n=oe(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=X(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,ne(e))}}}return Tt(e),!0}return e instanceof R&&!function(e){let t=re(e);t&&t.tagName||(t={namespaceURI:tt,tagName:"template"});const n=f(e.tagName),o=f(t.tagName);return!!ot[e.namespaceURI]&&(e.namespaceURI===Qe?t.namespaceURI===et?"svg"===n:t.namespaceURI===Je?"svg"===n&&("annotation-xml"===o||pt[o]):Boolean(ht[n]):e.namespaceURI===Je?t.namespaceURI===et?"math"===n:t.namespaceURI===Qe?"math"===n&&ft[o]:Boolean(gt[n]):e.namespaceURI===et?!(t.namespaceURI===Qe&&!ft[o])&&!(t.namespaceURI===Je&&!pt[o])&&!gt[n]&&(dt[n]||!ht[n]):!("application/xhtml+xml"!==it||!ot[e.namespaceURI]))}(e)?(Tt(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!_(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Z&&(t=e.textContent,u([fe,de,he],(e=>{t=g(t,e," ")})),e.textContent!==t&&(p(o.removed,{element:e.cloneNode()}),e.textContent=t)),bt("afterSanitizeElements",e,null),!1):(Tt(e),!0)},Rt=function(e,t,n){if(Be&&("id"===t||"name"===t)&&(n in r||n in st))return!1;if(ve&&!Le[t]&&_(ge,t));else if(De&&_(Te,t));else if(!Se[t]||Le[t]){if(!(wt(e)&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,e)||we.tagNameCheck instanceof Function&&we.tagNameCheck(e))&&(we.attributeNameCheck instanceof RegExp&&_(we.attributeNameCheck,t)||we.attributeNameCheck instanceof Function&&we.attributeNameCheck(t))||"is"===t&&we.allowCustomizedBuiltInElements&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n)||we.tagNameCheck instanceof Function&&we.tagNameCheck(n))))return!1}else if(Ve[t]);else if(_(Ae,g(n,Ee,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!$e[e]){if(Oe&&!_(ye,g(n,Ee,"")));else if(n)return!1}else;return!0},wt=function(e){return"annotation-xml"!==e&&h(e,_e)},Ct=function(e){bt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Se};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=lt(a);let p="value"===a?c:y(c);if(n.attrName=s,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,bt("uponSanitizeAttribute",e,n),p=n.attrValue,Me&&_(/((--!?|])>)|<\/(style|title)/i,p)){yt(a,e);continue}if(n.forceKeepAttr)continue;if(yt(a,e),!n.keepAttr)continue;if(!xe&&_(/\/>/i,p)){yt(a,e);continue}ke&&u([fe,de,he],(e=>{p=g(p,e," ")}));const f=lt(e.nodeName);if(Rt(f,s,p)){if(!We||"id"!==s&&"name"!==s||(yt(a,e),p="user-content-"+p),ie&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(f,s)){case"TrustedHTML":p=ie.createHTML(p);break;case"TrustedScriptURL":p=ie.createScriptURL(p)}try{l?e.setAttributeNS(l,a,p):e.setAttribute(a,p),At(e)?Tt(e):m(o.removed)}catch(e){}}}bt("afterSanitizeAttributes",e,null)},Lt=function e(t){let n=null;const o=_t(t);for(bt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)bt("uponSanitizeShadowNode",n,null),St(n)||(n.content instanceof s&&e(n.content),Ct(n));bt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(nt=!e,nt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Ue||mt(t),o.removed=[],"string"==typeof e&&(Ye=!1),Ye){if(e.nodeName){const t=lt(e.nodeName);if(!Ne[t]||Ce[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof b)n=Et("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===V&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!Ie&&-1===e.indexOf("<"))return ie&&ze?ie.createHTML(e):e;if(n=Et(e),!n)return Fe?null:ze?ae:""}n&&Pe&&Tt(n.firstChild);const c=_t(Ye?e:n);for(;i=c.nextNode();)St(i)||(i.content instanceof s&&Lt(i.content),Ct(i));if(Ye)return e;if(Fe){if(He)for(l=se.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Se.shadowroot||Se.shadowrootmode)&&(l=me.call(a,l,!0)),l}let m=Ie?n.outerHTML:n.innerHTML;return Ie&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&_(q,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([fe,de,he],(e=>{m=g(m,e," ")})),ie&&ze?ie.createHTML(m):m},o.setConfig=function(){mt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Ue=!0},o.clearConfig=function(){ct=null,Ue=!1},o.isValidAttribute=function(e,t,n){ct||mt({});const o=lt(e),r=lt(t);return Rt(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(pe[e]=pe[e]||[],p(pe[e],t))},o.removeHook=function(e){if(pe[e])return m(pe[e])},o.removeHooks=function(e){pe[e]&&(pe[e]=[])},o.removeAllHooks=function(){pe={}},o}();return ne})); +//# sourceMappingURL=purify.min.js.map diff --git a/src/presentation/static/js/internal/chat.js b/src/presentation/static/js/internal/chat.js index 145ef32..0e93ed0 100644 --- a/src/presentation/static/js/internal/chat.js +++ b/src/presentation/static/js/internal/chat.js @@ -1,17 +1,4 @@ (() => { - function showToast(message, type) { - if (!type) type = "info"; - const container = document.getElementById("toast-container"); - if (!container) return; - const toast = document.createElement("div"); - toast.className = `toast ${type}`; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => { - toast.remove(); - }, 3000); - } - function init() { const messagesEl = document.getElementById("chat-messages"); const chatForm = document.getElementById("chat-form"); @@ -28,12 +15,35 @@ marked.setOptions({ gfm: true, breaks: true }); } + const MD_TAGS = [ + "a", "b", "i", "em", "strong", "code", "pre", "br", "hr", "p", + "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", "span" + ]; + const MD_ATTRS = ["href", "title", "colspan", "rowspan"]; + + if (typeof DOMPurify !== "undefined") { + DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A") { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + }); + } + function renderMarkdown(text) { if (typeof marked !== "undefined") { const normalized = text .replace(/([^\n])\n*(#{1,6}\s)/g, "$1\n\n$2") .replace(/([^\n])\n*(\|)/g, "$1\n\n$2"); - return marked.parse(normalized); + const html = marked.parse(normalized); + if (typeof DOMPurify !== "undefined") { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: MD_TAGS, + ALLOWED_ATTR: MD_ATTRS + }); + } + return html; } const div = document.createElement("div"); div.textContent = text; @@ -229,13 +239,19 @@ } function setLoading(loading) { - sendBtn.disabled = loading; - sendBtn.innerHTML = loading - ? '' - : ''; if (loading) { + sendBtn.innerHTML = + ''; + sendBtn.title = "Stop"; + sendBtn.setAttribute("aria-label", "Stop"); + sendBtn.classList.add("is-loading"); showTyping(); } else { + sendBtn.innerHTML = + ''; + sendBtn.title = "Send"; + sendBtn.setAttribute("aria-label", "Send"); + sendBtn.classList.remove("is-loading"); hideTyping(); } } @@ -281,7 +297,17 @@ } if (newChatBtn) { - newChatBtn.addEventListener("click", clearChat); + newChatBtn.addEventListener("click", async () => { + const hasMessages = !!messagesEl.querySelector(".message"); + if (!hasMessages) { + clearChat(); + return; + } + const ok = await showConfirm( + "Start a new chat? This clears the current history.", + ); + if (ok) clearChat(); + }); } loadPrompts(); @@ -473,6 +499,15 @@ this.style.height = "auto"; this.style.height = `${this.scrollHeight}px`; }); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + chatForm.requestSubmit(); + } + }); + + input.focus(); } document.addEventListener("DOMContentLoaded", init); diff --git a/src/presentation/static/js/internal/local.js b/src/presentation/static/js/internal/local.js new file mode 100644 index 0000000..33c62aa --- /dev/null +++ b/src/presentation/static/js/internal/local.js @@ -0,0 +1,185 @@ +// Local files management + +const uploadZone = document.getElementById('upload-zone'); +const fileInput = document.getElementById('file-input'); +const localFilesBody = document.getElementById('local-files-body'); +const totalCount = document.getElementById('total-count'); +const pendingCount = document.getElementById('pending-count'); +const embeddedCount = document.getElementById('embedded-count'); + +// Drag and drop handlers +uploadZone.addEventListener('click', () => fileInput.click()); + +uploadZone.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInput.click(); + } +}); + +uploadZone.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadZone.classList.add('dragover'); +}); + +uploadZone.addEventListener('dragleave', () => { + uploadZone.classList.remove('dragover'); +}); + +uploadZone.addEventListener('drop', (e) => { + e.preventDefault(); + uploadZone.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + } +}); + +fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + handleFiles(e.target.files); + e.target.value = ''; + } +}); + +async function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function getFileType(contentType) { + if (!contentType || contentType === 'unknown') return 'Unknown'; + return contentType.charAt(0).toUpperCase() + contentType.slice(1); +} + +function getEmbedStatusBadge(embedStatus) { + const statusMap = { + 'discovery': { class: 'discovery', label: 'Pending' }, + 'embedded': { class: 'embedded', label: 'Embedded' }, + 'error': { class: 'error', label: 'Error' }, + }; + const config = statusMap[embedStatus] || { class: 'discovery', label: embedStatus || 'Unknown' }; + return `${config.label}`; +} + +function formatTimestamp(isoStr) { + if (!isoStr) return '-'; + const d = new Date(isoStr); + return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); +} + +async function loadLocalFiles() { + try { + const response = await fetch('/api/v1/files/local/list?limit=1000&offset=0'); + const data = await response.json(); + + if (!data.success || !data.data) { + localFilesBody.innerHTML = 'No local files found.'; + totalCount.textContent = '0'; + pendingCount.textContent = '0'; + embeddedCount.textContent = '0'; + return; + } + + const files = data.data; + totalCount.textContent = data.total || files.length; + pendingCount.textContent = files.filter(f => f.embed_status === 'discovery').length; + embeddedCount.textContent = files.filter(f => f.embed_status === 'embedded').length; + + if (files.length === 0) { + localFilesBody.innerHTML = 'No local files found. Upload a file to get started.'; + return; + } + + localFilesBody.innerHTML = files.map((file, idx) => ` + + ${file.file_name || file.title || '?'} + ${getFileType(file.content_type)} + ${file.file_size ? formatFileSize(file.file_size) : '0 B'} + ${getEmbedStatusBadge(file.embed_status)} + + + `).join(''); + + } catch (err) { + console.error('Failed to load local files:', err); + localFilesBody.innerHTML = 'Error loading files.'; + } +} + +async function deleteFile(idx, originalUrl) { + if (!(await showConfirm('Are you sure you want to delete this file? This cannot be undone.'))) return; + + try { + const response = await fetch(`/api/v1/files/local/delete?file_path=${encodeURIComponent(originalUrl)}`); + const data = await response.json(); + + if (data.success) { + showToast('File deleted'); + loadLocalFiles(); + } else { + showToast('Error: ' + (data.error || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('Failed to delete file:', err); + showToast('Error deleting file.', 'error'); + } +} + +async function handleFiles(fileList) { + const uploadProgress = document.getElementById('upload-progress'); + const statusMessage = document.getElementById('status-message'); + const pb = new ProgressBar({ + container: uploadProgress, + fill: document.getElementById('progress-fill'), + text: document.getElementById('status-message'), + }); + + pb.show(); + pb.setText(`Uploading ${fileList.length} file(s)...`; + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + pb.setProgress(((i + 1) / fileList.length) * 100); + pb.setText(`Uploading ${i + 1}/${fileList.length}: ${file.name}`); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('collection_name', 'local'); + + const response = await fetch('/api/v1/files/local/add', { + method: 'POST', + body: formData, + }); + + const data = await response.json(); + if (data.success) { + successCount++; + } else { + errorCount++; + console.error('Upload failed:', data.error); + } + } catch (err) { + errorCount++; + console.error('Upload error:', err); + } + } + + pb.complete(); + pb.setText(`Complete: ${successCount} uploaded, ${errorCount} errors`); + + setTimeout(() => { + pb.hide(); + loadLocalFiles(); + }, 2000); +} + +// Load files on page load +loadLocalFiles(); +setInterval(loadLocalFiles, 30000); \ No newline at end of file diff --git a/src/presentation/static/js/internal/notifications.js b/src/presentation/static/js/internal/notifications.js index 252f0ae..d2f2ee3 100644 --- a/src/presentation/static/js/internal/notifications.js +++ b/src/presentation/static/js/internal/notifications.js @@ -1,6 +1,14 @@ (() => { const TOAST_TTL = 3500; + function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function createContainer() { let c = document.getElementById("toast-container"); if (!c) { diff --git a/src/presentation/static/js/internal/repo-browser.js b/src/presentation/static/js/internal/repo-browser.js index 5e86ccc..6bb7f22 100644 --- a/src/presentation/static/js/internal/repo-browser.js +++ b/src/presentation/static/js/internal/repo-browser.js @@ -114,13 +114,13 @@ class RepoBrowser { ); const result = await response.json(); if (result.success) { - alert("Syncing repository..."); + showToast("Syncing repository..."); this.loadRepos(); } else { - alert(result.error || "Failed to sync"); + showToast(result.error || "Failed to sync", "error"); } } catch { - alert("Failed to sync repository"); + showToast("Failed to sync repository", "error"); } } @@ -143,10 +143,10 @@ class RepoBrowser { } this.loadRepos(); } else { - alert(result.error || "Failed to delete"); + showToast(result.error || "Failed to delete", "error"); } } catch { - alert("Failed to delete repository"); + showToast("Failed to delete repository", "error"); } } @@ -235,8 +235,14 @@ class RepoBrowser { } closeModal() { - document.getElementById("add-repo-modal").style.display = "none"; - document.getElementById("add-repo-form").reset(); + const modal = document.getElementById("add-repo-modal"); + if (modal) { + modal.style.display = "none"; + modal.setAttribute("aria-hidden", "true"); + const triggerBtn = document.getElementById("add-repo-btn"); + if (triggerBtn) triggerBtn.focus(); + document.getElementById("add-repo-form").reset(); + } } isWorkerActive(status) { @@ -360,7 +366,7 @@ class RepoBrowser { document.getElementById("repo-branch").value.trim() || "main"; if (!url) { - alert("Please enter a repository"); + showToast("Please enter a repository", "error"); return; } @@ -382,10 +388,10 @@ class RepoBrowser { this.closeModal(); this.loadRepos(); } else { - alert(result.error || "Failed to add repository"); + showToast(result.error || "Failed to add repository", "error"); } } catch { - alert("Failed to add repository"); + showToast("Failed to add repository", "error"); } } @@ -418,7 +424,27 @@ class RepoBrowser { // add-repo modal on("add-repo-btn", "click", () => { const modal = byId("add-repo-modal"); - if (modal) modal.style.display = "block"; + if (modal) { + modal.style.display = "block"; + modal.setAttribute("aria-hidden", "false"); + // Trap focus inside modal + const firstFocusable = modal.querySelector('input[type="text"]'); + if (firstFocusable) { + firstFocusable.focus(); + firstFocusable.select(); + } + // Close on Escape + const handleKeydown = (e) => { + if (e.key === "Escape") { + modal.style.display = "none"; + modal.setAttribute("aria-hidden", "true"); + const triggerBtn = document.getElementById("add-repo-btn"); + if (triggerBtn) triggerBtn.focus(); + document.removeEventListener("keydown", handleKeydown); + } + }; + document.addEventListener("keydown", handleKeydown); + } }); on("modal-cancel-btn", "click", () => this.closeModal()); @@ -443,13 +469,13 @@ class RepoBrowser { }); const result = await response.json(); if (result.success) { - alert("Sync started for all repositories..."); + showToast("Sync started for all repositories..."); self.loadRepos(); } else { - alert(result.error || "Failed to sync all"); + showToast(result.error || "Failed to sync all", "error"); } } catch { - alert("Failed to sync all repositories"); + showToast("Failed to sync all repositories", "error"); } finally { this.disabled = false; this.textContent = "Sync All"; diff --git a/src/presentation/templates/chat/index.html.j2 b/src/presentation/templates/chat/index.html.j2 index ff1f41b..d596e87 100644 --- a/src/presentation/templates/chat/index.html.j2 +++ b/src/presentation/templates/chat/index.html.j2 @@ -1,6 +1,6 @@ {% extends "shared/layout.html.j2" %} -{% block title %}Chat - SigmaHQ RAG{% endblock %} +{% block title %}Chat — SigmaHQ RAG{% endblock %} {% block body_class %}chat-page{% endblock %} @@ -11,7 +11,7 @@ {% block sidebar %}
- +
- + No
@@ -177,9 +177,9 @@
-
@@ -188,9 +188,9 @@
-
@@ -243,9 +243,9 @@
-
@@ -254,9 +254,9 @@
-
@@ -297,7 +297,7 @@

Embedding E5

- Dimension mismatch ??? The active embedding model dimension () differs from the Qdrant collection (). Vectors will be rejected until the collection is recreated. + Dimension mismatch: The active embedding model dimension () differs from the Qdrant collection (). Vectors will be rejected until the collection is recreated.
diff --git a/src/presentation/templates/dashboard/base.html.j2 b/src/presentation/templates/dashboard/base.html.j2 index 46c153b..fa86384 100644 --- a/src/presentation/templates/dashboard/base.html.j2 +++ b/src/presentation/templates/dashboard/base.html.j2 @@ -1,6 +1,6 @@ {% extends "shared/layout.html.j2" %} -{% block title %}Dashboard - Sigmahqrag{% endblock %} +{% block title %}Dashboard — SigmaHQ RAG{% endblock %} {% block sidebar_title %}Dashboard{% endblock %} {% block sidebar_subtitle %}Database overview & debug{% endblock %} diff --git a/src/presentation/templates/data/base.html.j2 b/src/presentation/templates/data/base.html.j2 index ed9615c..44efed7 100644 --- a/src/presentation/templates/data/base.html.j2 +++ b/src/presentation/templates/data/base.html.j2 @@ -1,7 +1,7 @@ {# Data section layout - extends shared layout #} {% extends "shared/layout.html.j2" %} -{% block title %}Data Sources - Sigmahqrag{% endblock %} +{% block title %}Data Sources — SigmaHQ RAG{% endblock %} {% block styles %} {{ super() }} diff --git a/src/presentation/templates/data/github.html.j2 b/src/presentation/templates/data/github.html.j2 index 375db90..f651f50 100644 --- a/src/presentation/templates/data/github.html.j2 +++ b/src/presentation/templates/data/github.html.j2 @@ -1,58 +1,20 @@ {% extends "data/base.html.j2" %} {% from "shared/_dir_browser.html.j2" import dir_browser_card %} +{% from "shared/_repo_section.html.j2" import repo_section %} -{% block title %}GitHub - Data Sources{% endblock %} +{% block title %}GitHub Repositories — SigmaHQ RAG{% endblock %} {% block page_content %}

GitHub Repositories

Manage GitHub repositories for Sigma rule indexing.

-
-
- - - - - -
- -
- - -
- - - - -{% endblock %} +{% repo_section( + intro_text="Click \"Browse\" on a repository to view and select directories for indexing.", + modal_h2="Add GitHub Repository", + url_placeholder="SigmaHQ/sigma", + dir_browser_id="github", + dir_browser_title="Select Directories to Index" +) %} {% block scripts %} {{ super() }} diff --git a/src/presentation/templates/data/local.html.j2 b/src/presentation/templates/data/local.html.j2 index b5fda13..c10e26f 100644 --- a/src/presentation/templates/data/local.html.j2 +++ b/src/presentation/templates/data/local.html.j2 @@ -1,107 +1,10 @@ {% extends "data/base.html.j2" %} -{% block title %}Local Files - Data Sources{% endblock %} +{% block title %}Local Files — SigmaHQ RAG{% endblock %} {% block styles %} {{ super() }} - + {% endblock %} {% block page_content %} @@ -123,7 +26,7 @@
-
+

📁 Drag & Drop files here

or click to browse (supported: .md, .txt, .yml, .yaml, .json, etc.)

@@ -159,181 +62,5 @@ {% endblock %} {% block scripts %} - -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/presentation/templates/data/prompts.html.j2 b/src/presentation/templates/data/prompts.html.j2 index 21a52c6..256e206 100644 --- a/src/presentation/templates/data/prompts.html.j2 +++ b/src/presentation/templates/data/prompts.html.j2 @@ -1,6 +1,6 @@ {% extends "data/base.html.j2" %} -{% block title %}Prompts | Data Sources | SigmaHQ RAG{% endblock %} +{% block title %}Prompts — SigmaHQ RAG{% endblock %} {% block content %}
@@ -35,20 +35,20 @@
- - + +
- - + +
- - + +
- - + +