0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-05 23:12:39 +00:00

Merge branch 'master' into opengraph_locale

This commit is contained in:
Trevor Buckner
2025-07-17 14:30:16 -04:00
committed by GitHub
17 changed files with 531 additions and 171 deletions

View File

@@ -88,6 +88,14 @@ pre {
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Wednesday 7/09/2025 - v3.19.3
{{taskList
##### calculuschild
* [x] Restoring original saving behavior; will continue investigating why save was failing for some users in background
}}
### Wednesday 7/09/2025 - v3.19.2 ### Wednesday 7/09/2025 - v3.19.2
{{taskList {{taskList

View File

@@ -20,6 +20,8 @@ import HeaderNav from './headerNav/headerNav.jsx';
import { safeHTML } from './safeHTML.js'; import { safeHTML } from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
const INITIAL_CONTENT = dedent` const INITIAL_CONTENT = dedent`
@@ -130,7 +132,7 @@ const BrewRenderer = (props)=>{
const pagesRef = useRef(null); const pagesRef = useRef(null);
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
rawPages = props.text.split('\\page'); rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
} else { } else {
rawPages = props.text.split(PAGEBREAK_REGEX_V3); rawPages = props.text.split(PAGEBREAK_REGEX_V3);
} }
@@ -187,6 +189,7 @@ const BrewRenderer = (props)=>{
let attributes = {}; let attributes = {};
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
const html = MarkdownLegacy.render(pageText); const html = MarkdownLegacy.render(pageText);
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />; return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;

View File

@@ -1,95 +1,75 @@
//╔===--------------- Polyfills --------------===╗// /* eslint-disable camelcase */
import 'core-js/es/string/to-well-formed.js'; import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers
//╚===--------------- ---------------===╝// import './homebrew.less';
import React from 'react';
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
require('./homebrew.less'); import HomePage from './pages/homePage/homePage.jsx';
const React = require('react'); import EditPage from './pages/editPage/editPage.jsx';
const createClass = require('create-react-class'); import UserPage from './pages/userPage/userPage.jsx';
const { StaticRouter:Router } = require('react-router'); import SharePage from './pages/sharePage/sharePage.jsx';
const { Route, Routes, useParams, useSearchParams } = require('react-router'); import NewPage from './pages/newPage/newPage.jsx';
import ErrorPage from './pages/errorPage/errorPage.jsx';
import VaultPage from './pages/vaultPage/vaultPage.jsx';
import AccountPage from './pages/accountPage/accountPage.jsx';
const HomePage = require('./pages/homePage/homePage.jsx'); const WithRoute = ({ el: Element, ...rest })=>{
const EditPage = require('./pages/editPage/editPage.jsx');
const UserPage = require('./pages/userPage/userPage.jsx');
const SharePage = require('./pages/sharePage/sharePage.jsx');
const NewPage = require('./pages/newPage/newPage.jsx');
const ErrorPage = require('./pages/errorPage/errorPage.jsx');
const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
const AccountPage = require('./pages/accountPage/accountPage.jsx');
const WithRoute = (props)=>{
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const queryParams = {}; const queryParams = Object.fromEntries(searchParams?.entries() || []);
for (const [key, value] of searchParams?.entries() || []) {
queryParams[key] = value; return <Element {...rest} {...params} query={queryParams} />;
}
const Element = props.el;
const allProps = {
...props,
...params,
query : queryParams,
el : undefined
};
return <Element {...allProps} />;
}; };
const Homebrew = createClass({ const Homebrew = (props)=>{
displayName : 'Homebrewery', const {
getDefaultProps : function() { url = '',
return { version = '0.0.0',
url : '', account = null,
welcomeText : '', enable_v3 = false,
changelog : '', enable_themes,
version : '0.0.0', config,
account : null, brew = {
enable_v3 : false, title : '',
brew : { text : '',
title : '', shareId : null,
text : '', editId : null,
shareId : null, createdAt : null,
editId : null, updatedAt : null,
createdAt : null, lang : ''
updatedAt : null, },
lang : '' userThemes,
} brews
}; } = props;
},
getInitialState : function() { global.account = account;
global.account = this.props.account; global.version = version;
global.version = this.props.version; global.enable_v3 = enable_v3;
global.enable_v3 = this.props.enable_v3; global.enable_themes = enable_themes;
global.enable_themes = this.props.enable_themes; global.config = config;
global.config = this.props.config;
return {}; return (
}, <Router location={url}>
<div className='homebrew'>
render : function (){ <Routes>
return ( <Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Router location={this.props.url}> <Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
<div className='homebrew'> <Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
<Routes> <Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} /> <Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} /> <Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} /> <Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } /> <Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} /> <Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/> <Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} /> <Route path='/legacy' element={<WithRoute el={HomePage} brew={brew} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} /> <Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
<Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} /> <Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
<Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} /> <Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> </Routes>
<Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} /> </div>
<Route path='/' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> </Router>
<Route path='/*' element={<WithRoute el={HomePage} brew={this.props.brew} />} /> );
</Routes> };
</div>
</Router>
);
}
});
module.exports = Homebrew; module.exports = Homebrew;

View File

@@ -5,6 +5,7 @@ const _ = require('lodash');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch'; import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch';
import { md5 } from 'hash-wasm'; import { md5 } from 'hash-wasm';
import { gzipSync, strToU8 } from 'fflate';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
@@ -190,8 +191,9 @@ const EditPage = createClass({
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { brew : {
...prevState.brew, ...prevState.brew,
style : newData.style, style : newData.style,
text : newData.text text : newData.text,
snippets : newData.snippets
} }
})); }));
}, },
@@ -247,6 +249,9 @@ const EditPage = createClass({
save : async function(){ save : async function(){
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel(); if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
const brewState = this.state.brew; // freeze the current state
const preSaveSnapshot = { ...brewState };
this.setState((prevState)=>({ this.setState((prevState)=>({
isSaving : true, isSaving : true,
error : null, error : null,
@@ -256,23 +261,25 @@ const EditPage = createClass({
await updateHistory(this.state.brew).catch(console.error); await updateHistory(this.state.brew).catch(console.error);
await versionHistoryGarbageCollection().catch(console.error); await versionHistoryGarbageCollection().catch(console.error);
const preSaveSnapshot = { ...this.state.brew };
//Prepare content to send to server //Prepare content to send to server
const brew = { ...this.state.brew }; const brew = { ...brewState };
brew.text = brew.text.normalize(); brew.text = brew.text.normalize('NFC');
this.savedBrew.text = this.savedBrew.text.normalize(); this.savedBrew.text = this.savedBrew.text.normalize('NFC');
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
brew.patches = stringifyPatches(makePatches(this.savedBrew.text, brew.text)); brew.patches = stringifyPatches(makePatches(this.savedBrew.text, brew.text));
brew.hash = await md5(this.savedBrew.text); brew.hash = await md5(this.savedBrew.text);
brew.text = undefined; //brew.text = undefined; - Temporary parallel path
brew.textBin = undefined; brew.textBin = undefined;
const compressedBrew = gzipSync(strToU8(JSON.stringify(brew)));
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`; const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
const res = await request const res = await request
.put(`/api/update/${brew.editId}${params}`) .put(`/api/update/${brew.editId}${params}`)
.send(brew) .set('Content-Encoding', 'gzip')
.set('Content-Type', 'application/json')
.send(compressedBrew)
.catch((err)=>{ .catch((err)=>{
console.log('Error Updating Local Brew'); console.log('Error Updating Local Brew');
this.setState({ error: err }); this.setState({ error: err });
@@ -295,8 +302,8 @@ const EditPage = createClass({
shareId : res.body.shareId, shareId : res.body.shareId,
version : res.body.version version : res.body.version
}, },
isSaving : false, isSaving : false,
unsavedTime : new Date() unsavedTime : new Date()
}), ()=>{ }), ()=>{
this.setState({ unsavedChanges : this.hasChanges() }); this.setState({ unsavedChanges : this.hasChanges() });
}); });

View File

@@ -176,6 +176,26 @@ const errorIndex = (props)=>{
If the selected brew is your document, you may designate it as a theme by adding the \`theme:meta\` tag.`, If the selected brew is your document, you may designate it as a theme by adding the \`theme:meta\` tag.`,
// ID validation error
'11' : dedent`
## No Homebrewery document could be found.
The server could not locate the Homebrewery document. The Brew ID failed the validation check.
:
**Brew ID:** ${props.brew.brewId}`,
// Google ID validation error
'12' : dedent`
## No Google document could be found.
The server could not locate the Google document. The Google ID failed the validation check.
:
**Brew ID:** ${props.brew.brewId}`,
//account page when account is not defined //account page when account is not defined
'50' : dedent` '50' : dedent`
## You are not signed in ## You are not signed in

View File

@@ -42,6 +42,7 @@ function parseBrewForStorage(brew, slot = 0) {
title : brew.title, title : brew.title,
text : brew.text, text : brew.text,
style : brew.style, style : brew.style,
snippets : brew.snippets,
version : brew.version, version : brew.version,
shareId : brew.shareId, shareId : brew.shareId,
savedAt : brew?.savedAt || new Date(), savedAt : brew?.savedAt || new Date(),

84
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"version": "3.19.2", "version": "3.19.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "homebrewery", "name": "homebrewery",
"version": "3.19.2", "version": "3.19.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -15,13 +15,14 @@
"@babel/preset-env": "^7.28.0", "@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.27.1",
"@babel/runtime": "^7.27.6", "@babel/runtime": "^7.27.6",
"@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^13.0.1", "@googleapis/drive": "^13.0.1",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.43.0", "core-js": "^3.44.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
@@ -29,6 +30,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.0",
"fflate": "^0.8.2",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -47,7 +49,7 @@
"marked-subsuper-text": "^1.0.3", "marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.16.1", "mongoose": "^8.16.3",
"nanoid": "5.1.5", "nanoid": "5.1.5",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -63,7 +65,7 @@
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.3", "@stylistic/stylelint-plugin": "^3.1.3",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.30.1", "eslint": "^9.31.0",
"eslint-plugin-jest": "^29.0.1", "eslint-plugin-jest": "^29.0.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0", "globals": "^16.3.0",
@@ -74,7 +76,7 @@
"stylelint": "^16.21.1", "stylelint": "^16.21.1",
"stylelint-config-recess-order": "^7.1.0", "stylelint-config-recess-order": "^7.1.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"supertest": "^7.1.1" "supertest": "^7.1.3"
}, },
"engines": { "engines": {
"node": "^20.18.x", "node": "^20.18.x",
@@ -1888,6 +1890,12 @@
"@csstools/css-tokenizer": "^3.0.1" "@csstools/css-tokenizer": "^3.0.1"
} }
}, },
"node_modules/@dmsnell/diff-match-patch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz",
"integrity": "sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==",
"license": "Apache-2.0"
},
"node_modules/@dual-bundle/import-meta-resolve": { "node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@@ -2050,10 +2058,11 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.30.1", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -5039,10 +5048,11 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.43.0", "version": "3.44.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz",
"integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==", "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
@@ -5952,18 +5962,19 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.30.1", "version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0", "@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0", "@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0", "@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.30.1", "@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.1", "@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@@ -6129,6 +6140,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint/node_modules/escape-string-regexp": { "node_modules/eslint/node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -6643,6 +6667,11 @@
"node": "^12.20 || >= 14.13" "node": "^12.20 || >= 14.13"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -10161,9 +10190,10 @@
} }
}, },
"node_modules/mongoose": { "node_modules/mongoose": {
"version": "8.16.1", "version": "8.16.3",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.3.tgz",
"integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", "integrity": "sha512-p2JOsRQG7j0vXhLpsWw5Slm2VnDeJK8sRyqSyegk5jQujuP9BTOZ1Di9VX/0lYfBhZ2DpAExi51QTd4pIqSgig==",
"license": "MIT",
"dependencies": { "dependencies": {
"bson": "^6.10.4", "bson": "^6.10.4",
"kareem": "2.6.3", "kareem": "2.6.3",
@@ -13289,9 +13319,9 @@
} }
}, },
"node_modules/superagent": { "node_modules/superagent": {
"version": "10.2.1", "version": "10.2.2",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.2.tgz",
"integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==", "integrity": "sha512-vWMq11OwWCC84pQaFPzF/VO3BrjkCeewuvJgt1jfV0499Z1QSAWN4EqfMM5WlFDDX9/oP8JjlDKpblrmEoyu4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"component-emitter": "^1.3.0", "component-emitter": "^1.3.0",
@@ -13321,14 +13351,14 @@
} }
}, },
"node_modules/supertest": { "node_modules/supertest": {
"version": "7.1.1", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.3.tgz",
"integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", "integrity": "sha512-ORY0gPa6ojmg/C74P/bDoS21WL6FMXq5I8mawkEz30/zkwdu0gOeqstFy316vHG6OKxqQ+IbGneRemHI8WraEw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"methods": "^1.1.2", "methods": "^1.1.2",
"superagent": "^10.2.1" "superagent": "^10.2.2"
}, },
"engines": { "engines": {
"node": ">=14.18.0" "node": ">=14.18.0"

View File

@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.19.2", "version": "3.19.3",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": "^10.8.x", "npm": "^10.8.x",
@@ -72,7 +72,7 @@
"lines": 50 "lines": 50
}, },
"server/homebrew.api.js": { "server/homebrew.api.js": {
"statements": 69, "statements": 60,
"branches": 50, "branches": 50,
"functions": 65, "functions": 65,
"lines": 70 "lines": 70
@@ -88,13 +88,14 @@
"@babel/preset-env": "^7.28.0", "@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.27.1",
"@babel/runtime": "^7.27.6", "@babel/runtime": "^7.27.6",
"@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^13.0.1", "@googleapis/drive": "^13.0.1",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.43.0", "core-js": "^3.44.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
@@ -102,6 +103,7 @@
"express": "^5.1.0", "express": "^5.1.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.0",
"fflate": "^0.8.2",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -120,7 +122,7 @@
"marked-subsuper-text": "^1.0.3", "marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.16.1", "mongoose": "^8.16.3",
"nanoid": "5.1.5", "nanoid": "5.1.5",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -136,7 +138,7 @@
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.3", "@stylistic/stylelint-plugin": "^3.1.3",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.30.1", "eslint": "^9.31.0",
"eslint-plugin-jest": "^29.0.1", "eslint-plugin-jest": "^29.0.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0", "globals": "^16.3.0",
@@ -147,6 +149,6 @@
"stylelint": "^16.21.1", "stylelint": "^16.21.1",
"stylelint-config-recess-order": "^7.1.0", "stylelint-config-recess-order": "^7.1.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"supertest": "^7.1.1" "supertest": "^7.1.3"
} }
} }

View File

@@ -28,6 +28,7 @@
"codemirror/addon/hint/show-hint.js", "codemirror/addon/hint/show-hint.js",
"moment", "moment",
"superagent", "superagent",
"@sanity/diff-match-patch" "@sanity/diff-match-patch",
"fflate"
] ]
} }

View File

@@ -0,0 +1,66 @@
import forceSSL from './forcessl.mw';
describe('Tests for ForceSSL middleware', ()=>{
let originalEnv;
let nextFn;
let req = {};
let res = {};
beforeEach(()=>{
originalEnv = process.env.NODE_ENV;
nextFn = jest.fn();
req = {
header : ()=>{ return 'http'; },
get : ()=>{ return 'test'; },
url : 'URL'
};
res = {
redirect : jest.fn()
};
});
afterEach(()=>{
process.env.NODE_ENV = originalEnv;
jest.clearAllMocks();
});
it('should not redirect when NODE_ENV is set to local', ()=>{
process.env.NODE_ENV = 'local';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should not redirect when NODE_ENV is set to docker', ()=>{
process.env.NODE_ENV = 'docker';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should redirect with 302 when header is not HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
forceSSL(req, res, nextFn);
expect(res.redirect).toHaveBeenCalledWith(302, 'https://testURL');
expect(nextFn).not.toHaveBeenCalled();
});
it('should not redirect when header is HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
req.header = ()=>{ return 'https'; };
forceSSL(req, res, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
});

View File

@@ -11,7 +11,7 @@ import { nanoid } from 'nanoid';
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch'; import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
import { md5 } from 'hash-wasm'; import { md5 } from 'hash-wasm';
import { splitTextStyleAndMetadata, import { splitTextStyleAndMetadata,
brewSnippetsToJSON } from '../shared/helpers.js'; brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js'; import checkClientVersion from './middleware/check-client-version.js';
@@ -48,6 +48,20 @@ const api = {
} }
id = id.slice(googleId.length); id = id.slice(googleId.length);
} }
// ID Validation Checks
// Homebrewery ID
// Typically 12 characters, but the DB shows a range of 7 to 14 characters
if(!id.match(/^[A-Za-z0-9_-]{7,14}$/)){
throw { name: 'ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '11', brewId: id };
}
// Google ID
// Typically 33 characters, old format is 44 - always starts with a 1
// Managed by Google, may change outside of our control, so any length between 33 and 44 is acceptable
if(googleId && !googleId.match(/^1(?:[A-Za-z0-9+\/]{32,43})$/)){
throw { name: 'Google ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '12', brewId: id };
}
return { id, googleId }; return { id, googleId };
}, },
//Get array of any of this user's brews tagged with `meta:theme` //Get array of any of this user's brews tagged with `meta:theme`
@@ -340,34 +354,45 @@ const api = {
const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromClient = api.excludePropsFromUpdate(req.body);
const brewFromServer = req.brew; const brewFromServer = req.brew;
splitTextStyleAndMetadata(brewFromServer); splitTextStyleAndMetadata(brewFromServer);
brewFromServer.text = brewFromServer.text.normalize(); if(brewFromServer?.version !== brewFromClient?.version){
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
}
brewFromServer.text = brewFromServer.text.normalize('NFC');
brewFromServer.hash = await md5(brewFromServer.text); brewFromServer.hash = await md5(brewFromServer.text);
if((brewFromServer?.version !== brewFromClient?.version) || (brewFromServer?.hash !== brewFromClient?.hash)) { if(brewFromServer?.hash !== brewFromClient?.hash) {
if(brewFromClient?.version !== brewFromClient?.version) console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
console.log(`Version mismatch on brew ${brewFromClient.editId}`); //debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
if(brewFromServer?.hash !== brewFromClient?.hash) {
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
}
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
} }
try {
const patches = parsePatch(brewFromClient.patches);
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
const patchedResult = applyPatches(patches, brewFromServer.text, { allowExceedingIndices: true })[0];
if(patchedResult != brewFromClient.text)
throw("Patches did not apply cleanly, text mismatch detected");
// brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) {
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
console.error('Failed to apply patches:', {
patches : brewFromClient.patches,
brewId : brewFromClient.editId || 'unknown',
error : err
});
// While running in parallel, don't throw the error upstream.
// throw err; // rethrow to preserve the 500 behavior
}
let brew = _.assign(brewFromServer, brewFromClient); let brew = _.assign(brewFromServer, brewFromClient);
brew.title = brew.title.trim(); brew.title = brew.title.trim();
brew.description = brew.description.trim() || ''; brew.description = brew.description.trim() || '';
try {
const patches = parsePatch(brewFromClient.patches);
brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) {
console.error('Failed to apply patches:', {
patches: brewFromClient.patches,
brewId: brew.editId || 'unknown'
});
throw err; // rethrow to preserve the 500 behavior
}
brew.text = api.mergeBrewText(brew); brew.text = api.mergeBrewText(brew);
const googleId = brew.googleId; const googleId = brew.googleId;

View File

@@ -99,18 +99,87 @@ describe('Tests for api', ()=>{
expect(googleId).toBeUndefined(); expect(googleId).toBeUndefined();
}); });
it('should throw if id is too short', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcd'
}
});
} catch (e) {
err = e;
};
expect(err).toEqual({ HBErrorCode: '11', brewId: 'abcd', message: 'Invalid ID', name: 'ID Error', status: 404 });
});
it('should return id and google id from request body', ()=>{ it('should return id and google id from request body', ()=>{
const { id, googleId } = api.getId({ const { id, googleId } = api.getId({
params : { params : {
id : 'abcdefgh' id : 'abcdefghijkl'
}, },
body : { body : {
googleId : '12345' googleId : '123456789012345678901234567890123'
} }
}); });
expect(id).toEqual('abcdefgh'); expect(id).toEqual('abcdefghijkl');
expect(googleId).toEqual('12345'); expect(googleId).toEqual('123456789012345678901234567890123');
});
it('should throw invalid - google id right length but does not match pattern', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '012345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too short (32 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '12345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too long (45 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '123456789012345678901234567890123456789012345'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
}); });
it('should return 12-char id and google id from params', ()=>{ it('should return 12-char id and google id from params', ()=>{
@@ -1052,4 +1121,83 @@ brew`);
expect(testBrew.tags).toEqual(['tag a']); expect(testBrew.tags).toEqual(['tag a']);
}); });
}); });
describe('updateBrew', ()=>{
it('should return error on version mismatch', async ()=>{
const brewFromClient = { version: 1 };
const brewFromServer = { version: 1000, text: '' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
it('should return error on hash mismatch', async ()=>{
const brewFromClient = { version: 1, hash: '1234' };
const brewFromServer = { version: 1, text: 'test' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
// Commenting this one out for now, since we are no longer throwing this error while we monitor
// it('should return error on applying patches', async ()=>{
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
// const req = {
// brew : brewFromServer,
// body : brewFromClient,
// };
// let err;
// try {
// await api.updateBrew(req, res);
// } catch (e) {
// err = e;
// }
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
// });
it('should save brew, no ID', async ()=>{
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
model.save = jest.fn((brew)=>{return brew;});
const req = {
brew : brewFromServer,
body : brewFromClient,
query : { saveToGoogle: false, removeFromGoogle: false }
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
_id : '1',
description : 'Test Description',
hash : '098f6bcd4621d373cade4e832627b4f6',
title : 'Test Title',
version : 2
})
);
});
});
}); });

View File

@@ -5,21 +5,16 @@ import config from './config.js';
const generateAccessToken = (account)=>{ const generateAccessToken = (account)=>{
const payload = account; const payload = account;
// When the token was issued payload.issued = (new Date()); // When the token was issued
payload.issued = (new Date()); payload.issuer = config.get('authentication_token_issuer'); // Which service issued the Token
// Which service issued the Token payload.audience = config.get('authentication_token_audience'); // Which service is the token intended for
payload.issuer = config.get('authentication_token_issuer'); const secret = config.get('authentication_token_secret'); // The signing key for signing the token
// Which service is the token intended for
payload.audience = config.get('authentication_token_audience');
// The signing key for signing the token
delete payload.password; delete payload.password;
delete payload._id; delete payload._id;
const secret = config.get('authentication_token_secret');
const token = jwt.encode(payload, secret); const token = jwt.encode(payload, secret);
return token; return token;
}; };
export default generateAccessToken; export default generateAccessToken;

27
server/token.spec.js Normal file
View File

@@ -0,0 +1,27 @@
import { expect, jest } from '@jest/globals';
import config from './config.js';
import generateAccessToken from './token';
describe('Tests for Token', ()=>{
it('Get token', ()=>{
// Mock the Config module, so we aren't grabbing actual secrets for testing
jest.mock('./config.js');
config.get = jest.fn((param)=>{
// The requested key name will be reflected to the output
return param;
});
const account = {};
const token = generateAccessToken(account);
// If these tests fail, the config mock has failed
expect(account).toHaveProperty('issuer', 'authentication_token_issuer');
expect(account).toHaveProperty('audience', 'authentication_token_audience');
// Because the inputs are fixed, this JWT key should be static
expect(typeof token).toBe('string');
});
});

View File

@@ -139,9 +139,45 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{
})); }));
}; };
const debugTextMismatch = (clientTextRaw, serverTextRaw, label) => {
const clientText = clientTextRaw?.normalize('NFC') || '';
const serverText = serverTextRaw?.normalize('NFC') || '';
const clientBuffer = Buffer.from(clientText, 'utf8');
const serverBuffer = Buffer.from(serverText, 'utf8');
if (clientBuffer.equals(serverBuffer)) {
console.log(`${label} text matches byte-for-byte.`);
return;
}
console.warn(`${label} text mismatch detected.`);
console.log(`Client length: ${clientBuffer.length}`);
console.log(`Server length: ${serverBuffer.length}`);
// Byte-level diff
for (let i = 0; i < Math.min(clientBuffer.length, serverBuffer.length); i++) {
if (clientBuffer[i] !== serverBuffer[i]) {
console.log(`Byte mismatch at offset ${i}: client=0x${clientBuffer[i].toString(16)} server=0x${serverBuffer[i].toString(16)}`);
break;
}
}
// Char-level diff
for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) {
if (clientText[i] !== serverText[i]) {
console.log(`Char mismatch at index ${i}:`);
console.log(` Client: '${clientText[i]}' (U+${clientText.charCodeAt(i).toString(16).toUpperCase()})`);
console.log(` Server: '${serverText[i]}' (U+${serverText.charCodeAt(i).toString(16).toUpperCase()})`);
break;
}
}
}
export { export {
splitTextStyleAndMetadata, splitTextStyleAndMetadata,
printCurrentBrew, printCurrentBrew,
fetchThemeBundle, fetchThemeBundle,
brewSnippetsToJSON brewSnippetsToJSON,
debugTextMismatch
}; };

View File

@@ -185,7 +185,7 @@ const mustacheSpans = {
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g; const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g;
const match = completeSpan.exec(src); const match = completeSpan.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -242,7 +242,7 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm; const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm;
const match = completeBlock.exec(src); const match = completeBlock.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -297,7 +297,7 @@ const mustacheInjectInline = {
level : 'inline', level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -343,7 +343,7 @@ const mustacheInjectBlock = {
level : 'block', level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];

View File

@@ -4,6 +4,17 @@ require('jsdom-global')();
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML'; import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML';
test('Exit if no document', function() {
const doc = document;
document = undefined;
const result = safeHTML('');
document = doc;
expect(result).toBe(null);
});
test('Javascript via href', function() { test('Javascript via href', function() {
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`; const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
const rendered = safeHTML(source); const rendered = safeHTML(source);