diff --git a/.circleci/config.yml b/.circleci/config.yml
index f18f84943..d405486b5 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -76,6 +76,9 @@ jobs:
- run:
name: Test - Routes
command: npm run test:route
+ - run:
+ name: Test - HTML sanitization
+ command: npm run test:safehtml
- run:
name: Test - Coverage
command: npm run test:coverage
diff --git a/client/admin/brewUtils/brewLookup/brewLookup.jsx b/client/admin/brewUtils/brewLookup/brewLookup.jsx
index 50a2f2015..e5b585ced 100644
--- a/client/admin/brewUtils/brewLookup/brewLookup.jsx
+++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx
@@ -1,3 +1,5 @@
+require('./brewLookup.less');
+
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
@@ -12,22 +14,43 @@ const BrewLookup = createClass({
},
getInitialState() {
return {
- query : '',
- foundBrew : null,
- searching : false,
- error : null
+ query : '',
+ foundBrew : null,
+ searching : false,
+ error : null,
+ scriptCount : 0
};
},
handleChange(e){
this.setState({ query: e.target.value });
},
lookup(){
- this.setState({ searching: true, error: null });
+ this.setState({ searching: true, error: null, scriptCount: 0 });
request.get(`/admin/lookup/${this.state.query}`)
- .then((res)=>this.setState({ foundBrew: res.body }))
+ .then((res)=>{
+ const foundBrew = res.body;
+ const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
+ this.setState({
+ foundBrew : foundBrew,
+ scriptCount : scriptCheck?.length || 0,
+ });
+ })
.catch((err)=>this.setState({ error: err }))
- .finally(()=>this.setState({ searching: false }));
+ .finally(()=>{
+ this.setState({
+ searching : false
+ });
+ });
+ },
+
+ async cleanScript(){
+ if(!this.state.foundBrew?.shareId) return;
+
+ await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
+ .catch((err)=>{ this.setState({ error: err }); return; });
+
+ this.lookup();
},
renderFoundBrew(){
@@ -46,12 +69,23 @@ const BrewLookup = createClass({
Share Link
/share/{brew.shareId}
+ Created Time
+ {brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}
+
Last Updated
{Moment(brew.updatedAt).fromNow()}
Num of Views
{brew.views}
+
+ SCRIPT tags detected
+ {this.state.scriptCount}
+ {this.state.scriptCount > 0 &&
+
+
+
+ }
;
},
diff --git a/client/admin/brewUtils/brewLookup/brewLookup.less b/client/admin/brewUtils/brewLookup/brewLookup.less
new file mode 100644
index 000000000..da15e3a64
--- /dev/null
+++ b/client/admin/brewUtils/brewLookup/brewLookup.less
@@ -0,0 +1,6 @@
+.brewLookup {
+ .cleanButton {
+ display : inline-block;
+ width : 100%;
+ }
+}
\ No newline at end of file
diff --git a/client/components/Anchored.jsx b/client/components/Anchored.jsx
new file mode 100644
index 000000000..4c7a225e4
--- /dev/null
+++ b/client/components/Anchored.jsx
@@ -0,0 +1,91 @@
+import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
+import './Anchored.less';
+
+// Anchored is a wrapper component that must have as children an and a component.
+// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
+// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
+// **The Anchor Positioning API is not available in Firefox yet**
+// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
+
+
+const Anchored = ({ children })=>{
+ const [visible, setVisible] = useState(false);
+ const [anchorId, setAnchorId] = useState(null);
+ const boxRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ // promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
+ useEffect(()=>{
+ if(triggerRef.current){
+ setAnchorId(triggerRef.current.id);
+ }
+ }, []);
+
+ // close box on outside click or Escape key
+ useEffect(()=>{
+ const handleClickOutside = (evt)=>{
+ if(
+ boxRef.current &&
+ !boxRef.current.contains(evt.target) &&
+ triggerRef.current &&
+ !triggerRef.current.contains(evt.target)
+ ) {
+ setVisible(false);
+ }
+ };
+
+ const handleEscapeKey = (evt)=>{
+ if(evt.key === 'Escape') setVisible(false);
+ };
+
+ window.addEventListener('click', handleClickOutside);
+ window.addEventListener('keydown', handleEscapeKey);
+
+ return ()=>{
+ window.removeEventListener('click', handleClickOutside);
+ window.removeEventListener('keydown', handleEscapeKey);
+ };
+ }, []);
+
+ const toggleVisibility = ()=>setVisible((prev)=>!prev);
+
+ // Map children to inject necessary props
+ const mappedChildren = Children.map(children, (child)=>{
+ if(child.type === AnchoredTrigger) {
+ return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
+ }
+ if(child.type === AnchoredBox) {
+ return cloneElement(child, { ref: boxRef, visible, anchorId });
+ }
+ return child;
+ });
+
+ return <>{mappedChildren}>;
+};
+
+// forward ref for AnchoredTrigger
+const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
+
+));
+
+// forward ref for AnchoredBox
+const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
+
+ {children}
+
+));
+
+export { Anchored, AnchoredTrigger, AnchoredBox };
diff --git a/client/components/Anchored.less b/client/components/Anchored.less
new file mode 100644
index 000000000..4f0e2fa8f
--- /dev/null
+++ b/client/components/Anchored.less
@@ -0,0 +1,13 @@
+
+
+.anchored-box {
+ position:absolute;
+ @supports (inset-block-start: anchor(bottom)){
+ inset-block-start: anchor(bottom);
+ }
+ justify-self: anchor-center;
+ visibility: hidden;
+ &.active {
+ visibility: visible;
+ }
+}
\ No newline at end of file
diff --git a/client/components/dialog.jsx b/client/components/dialog.jsx
index 9e88afa33..0cdda2dee 100644
--- a/client/components/dialog.jsx
+++ b/client/components/dialog.jsx
@@ -1,25 +1,25 @@
// Dialog box, for popups and modal blocking messages
-import React from "react";
+import React from 'react';
const { useRef, useEffect } = React;
-function Dialog({ dismisskeys, closeText = 'Close', blocking = false, ...rest }) {
+function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null);
useEffect(()=>{
- if (dismisskeys.length !== 0) {
+ if(dismisskeys.length !== 0) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
}, [dialogRef.current, dismisskeys]);
- const dismiss = () => {
- dismisskeys.forEach(key => {
- if (key) {
+ const dismiss = ()=>{
+ dismisskeys.forEach((key)=>{
+ if(key) {
localStorage.setItem(key, 'true');
}
});
dialogRef.current?.close();
};
-
+
return (