mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2025-12-24 16:22:44 +00:00
Merge branch 'master' into issue_4201
This commit is contained in:
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -5,6 +5,15 @@ updates:
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 99
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
patterns: ["*"]
|
||||
update-types: ["patch", "minor"]
|
||||
prod-dependencies:
|
||||
dependency-type: "production"
|
||||
patterns: ["*"]
|
||||
update-types: ["patch", "minor"]
|
||||
ignore:
|
||||
- dependency-name: eslint
|
||||
versions:
|
||||
|
||||
24
changelog.md
24
changelog.md
@@ -88,6 +88,30 @@ pre {
|
||||
## changelog
|
||||
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
|
||||
|
||||
{{taskList
|
||||
##### calculuschild
|
||||
* [x] Hotfix for saving issues - Please refresh your browser and report if problems continue
|
||||
}}
|
||||
|
||||
### Wednesday 7/09/2025 - v3.19.1
|
||||
|
||||
{{taskList
|
||||
##### calculuschild
|
||||
* [x] Send diffs instead of full file on save - should help with timeout/disconnect errors
|
||||
}}
|
||||
|
||||
\column
|
||||
|
||||
### Thursday 05/22/2025 - v3.19.0
|
||||
|
||||
{{taskList
|
||||
|
||||
@@ -20,6 +20,8 @@ import HeaderNav from './headerNav/headerNav.jsx';
|
||||
import { safeHTML } from './safeHTML.js';
|
||||
|
||||
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 INITIAL_CONTENT = dedent`
|
||||
@@ -130,7 +132,7 @@ const BrewRenderer = (props)=>{
|
||||
const pagesRef = useRef(null);
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
rawPages = props.text.split('\\page');
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
|
||||
} else {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_V3);
|
||||
}
|
||||
@@ -187,6 +189,7 @@ const BrewRenderer = (props)=>{
|
||||
let attributes = {};
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
|
||||
const html = MarkdownLegacy.render(pageText);
|
||||
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
|
||||
@@ -1,95 +1,75 @@
|
||||
//╔===--------------- Polyfills --------------===╗//
|
||||
import 'core-js/es/string/to-well-formed.js';
|
||||
//╚===--------------- ---------------===╝//
|
||||
/* eslint-disable camelcase */
|
||||
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');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const { StaticRouter:Router } = require('react-router');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router');
|
||||
import HomePage from './pages/homePage/homePage.jsx';
|
||||
import EditPage from './pages/editPage/editPage.jsx';
|
||||
import UserPage from './pages/userPage/userPage.jsx';
|
||||
import SharePage from './pages/sharePage/sharePage.jsx';
|
||||
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 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 WithRoute = ({ el: Element, ...rest })=>{
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryParams = {};
|
||||
for (const [key, value] of searchParams?.entries() || []) {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
const Element = props.el;
|
||||
const allProps = {
|
||||
...props,
|
||||
...params,
|
||||
query : queryParams,
|
||||
el : undefined
|
||||
};
|
||||
return <Element {...allProps} />;
|
||||
const queryParams = Object.fromEntries(searchParams?.entries() || []);
|
||||
|
||||
return <Element {...rest} {...params} query={queryParams} />;
|
||||
};
|
||||
|
||||
const Homebrew = createClass({
|
||||
displayName : 'Homebrewery',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
url : '',
|
||||
welcomeText : '',
|
||||
changelog : '',
|
||||
version : '0.0.0',
|
||||
account : null,
|
||||
enable_v3 : false,
|
||||
brew : {
|
||||
title : '',
|
||||
text : '',
|
||||
shareId : null,
|
||||
editId : null,
|
||||
createdAt : null,
|
||||
updatedAt : null,
|
||||
lang : ''
|
||||
}
|
||||
};
|
||||
},
|
||||
const Homebrew = (props)=>{
|
||||
const {
|
||||
url = '',
|
||||
version = '0.0.0',
|
||||
account = null,
|
||||
enable_v3 = false,
|
||||
enable_themes,
|
||||
config,
|
||||
brew = {
|
||||
title : '',
|
||||
text : '',
|
||||
shareId : null,
|
||||
editId : null,
|
||||
createdAt : null,
|
||||
updatedAt : null,
|
||||
lang : ''
|
||||
},
|
||||
userThemes,
|
||||
brews
|
||||
} = props;
|
||||
|
||||
getInitialState : function() {
|
||||
global.account = this.props.account;
|
||||
global.version = this.props.version;
|
||||
global.enable_v3 = this.props.enable_v3;
|
||||
global.enable_themes = this.props.enable_themes;
|
||||
global.config = this.props.config;
|
||||
global.account = account;
|
||||
global.version = version;
|
||||
global.enable_v3 = enable_v3;
|
||||
global.enable_themes = enable_themes;
|
||||
global.config = config;
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
render : function (){
|
||||
return (
|
||||
<Router location={this.props.url}>
|
||||
<div className='homebrew'>
|
||||
<Routes>
|
||||
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
|
||||
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
|
||||
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
|
||||
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } />
|
||||
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
|
||||
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
|
||||
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
|
||||
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
|
||||
<Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
|
||||
<Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} />
|
||||
<Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
|
||||
<Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} />
|
||||
<Route path='/' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
|
||||
<Route path='/*' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Router location={url}>
|
||||
<div className='homebrew'>
|
||||
<Routes>
|
||||
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
|
||||
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
|
||||
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
|
||||
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
|
||||
<Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
|
||||
<Route path='/legacy' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
|
||||
<Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Homebrew;
|
||||
@@ -3,6 +3,9 @@ require('./editPage.less');
|
||||
const React = require('react');
|
||||
const _ = require('lodash');
|
||||
const createClass = require('create-react-class');
|
||||
import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { gzipSync, strToU8 } from 'fflate';
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const { Meta } = require('vitreum/headtags');
|
||||
@@ -47,7 +50,7 @@ const EditPage = createClass({
|
||||
return {
|
||||
brew : this.props.brew,
|
||||
isSaving : false,
|
||||
isPending : false,
|
||||
unsavedChanges : false,
|
||||
alertTrashedGoogleBrew : this.props.brew.trashed,
|
||||
alertLoginToTransfer : false,
|
||||
saveGoogle : this.props.brew.googleId ? true : false,
|
||||
@@ -85,7 +88,7 @@ const EditPage = createClass({
|
||||
});
|
||||
|
||||
window.onbeforeunload = ()=>{
|
||||
if(this.state.isSaving || this.state.isPending){
|
||||
if(this.state.isSaving || this.state.unsavedChanges){
|
||||
return 'You have unsaved changes!';
|
||||
}
|
||||
};
|
||||
@@ -104,9 +107,9 @@ const EditPage = createClass({
|
||||
},
|
||||
componentDidUpdate : function(){
|
||||
const hasChange = this.hasChanges();
|
||||
if(this.state.isPending != hasChange){
|
||||
if(this.state.unsavedChanges != hasChange){
|
||||
this.setState({
|
||||
isPending : hasChange
|
||||
unsavedChanges : hasChange
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -156,9 +159,9 @@ const EditPage = createClass({
|
||||
if(htmlErrors.length) htmlErrors = Markdown.validate(snippet);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, snippets: snippet },
|
||||
isPending : true,
|
||||
htmlErrors : htmlErrors,
|
||||
brew : { ...prevState.brew, snippets: snippet },
|
||||
unsavedChanges : true,
|
||||
htmlErrors : htmlErrors,
|
||||
}), ()=>{if(this.state.autoSave) this.trySave();});
|
||||
},
|
||||
|
||||
@@ -188,20 +191,28 @@ const EditPage = createClass({
|
||||
this.setState((prevState)=>({
|
||||
brew : {
|
||||
...prevState.brew,
|
||||
style : newData.style,
|
||||
text : newData.text
|
||||
style : newData.style,
|
||||
text : newData.text,
|
||||
snippets : newData.snippets
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
trySave : function(immediate=false){
|
||||
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
|
||||
if(this.hasChanges()){
|
||||
if(this.state.isSaving)
|
||||
return;
|
||||
|
||||
if(immediate) {
|
||||
this.debounceSave();
|
||||
} else {
|
||||
this.debounceSave.cancel();
|
||||
this.debounceSave.flush();
|
||||
return;
|
||||
}
|
||||
if(immediate) this.debounceSave.flush();
|
||||
|
||||
if(this.hasChanges())
|
||||
this.debounceSave();
|
||||
else
|
||||
this.debounceSave.cancel();
|
||||
},
|
||||
|
||||
handleGoogleClick : function(){
|
||||
@@ -215,8 +226,7 @@ const EditPage = createClass({
|
||||
confirmGoogleTransfer : !prevState.confirmGoogleTransfer
|
||||
}));
|
||||
this.setState({
|
||||
error : null,
|
||||
isSaving : false
|
||||
error : null
|
||||
});
|
||||
},
|
||||
|
||||
@@ -232,14 +242,16 @@ const EditPage = createClass({
|
||||
toggleGoogleStorage : function(){
|
||||
this.setState((prevState)=>({
|
||||
saveGoogle : !prevState.saveGoogle,
|
||||
isSaving : false,
|
||||
error : null
|
||||
}), ()=>this.save());
|
||||
}), ()=>this.trySave(true));
|
||||
},
|
||||
|
||||
save : async function(){
|
||||
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
|
||||
|
||||
const brewState = this.state.brew; // freeze the current state
|
||||
const preSaveSnapshot = { ...brewState };
|
||||
|
||||
this.setState((prevState)=>({
|
||||
isSaving : true,
|
||||
error : null,
|
||||
@@ -249,15 +261,25 @@ const EditPage = createClass({
|
||||
await updateHistory(this.state.brew).catch(console.error);
|
||||
await versionHistoryGarbageCollection().catch(console.error);
|
||||
|
||||
//Prepare content to send to server
|
||||
const brew = { ...brewState };
|
||||
brew.text = brew.text.normalize('NFC');
|
||||
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.patches = stringifyPatches(makePatches(encodeURI(this.savedBrew.text), encodeURI(brew.text)));
|
||||
brew.hash = await md5(this.savedBrew.text);
|
||||
//brew.text = undefined; - Temporary parallel path
|
||||
brew.textBin = undefined;
|
||||
|
||||
const compressedBrew = gzipSync(strToU8(JSON.stringify(brew)));
|
||||
|
||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||
|
||||
const brew = this.state.brew;
|
||||
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
|
||||
|
||||
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
|
||||
const res = await request
|
||||
.put(`/api/update/${brew.editId}${params}`)
|
||||
.send(brew)
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(compressedBrew)
|
||||
.catch((err)=>{
|
||||
console.log('Error Updating Local Brew');
|
||||
this.setState({ error: err });
|
||||
@@ -265,20 +287,28 @@ const EditPage = createClass({
|
||||
if(!res) return;
|
||||
|
||||
this.savedBrew = {
|
||||
...this.state.brew,
|
||||
...preSaveSnapshot,
|
||||
googleId : res.body.googleId ? res.body.googleId : null,
|
||||
editId : res.body.editId,
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
};
|
||||
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
|
||||
|
||||
this.setState(()=>({
|
||||
brew : this.savedBrew,
|
||||
isPending : false,
|
||||
this.setState((prevState) => ({
|
||||
brew: {
|
||||
...prevState.brew,
|
||||
googleId : res.body.googleId ? res.body.googleId : null,
|
||||
editId : res.body.editId,
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
},
|
||||
isSaving : false,
|
||||
unsavedTime : new Date()
|
||||
}));
|
||||
}), ()=>{
|
||||
this.setState({ unsavedChanges : this.hasChanges() });
|
||||
});
|
||||
|
||||
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
|
||||
},
|
||||
|
||||
renderGoogleDriveIcon : function(){
|
||||
@@ -336,7 +366,7 @@ const EditPage = createClass({
|
||||
}
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
if(this.state.isPending && this.state.autoSaveWarning){
|
||||
if(this.state.unsavedChanges && this.state.autoSaveWarning){
|
||||
this.setAutosaveWarning();
|
||||
const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60);
|
||||
const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
@@ -351,7 +381,7 @@ const EditPage = createClass({
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
// Use trySave(true) instead of save() to use debounced save function
|
||||
if(this.state.isPending){
|
||||
if(this.state.unsavedChanges){
|
||||
return <Nav.item className='save' onClick={()=>this.trySave(true)} color='blue' icon='fas fa-save'>Save Now</Nav.item>;
|
||||
}
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
// 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
|
||||
'50' : dedent`
|
||||
## You are not signed in
|
||||
|
||||
@@ -148,7 +148,6 @@ const NewPage = createClass({
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, snippets: snippet },
|
||||
isPending : true,
|
||||
htmlErrors : htmlErrors,
|
||||
}), ()=>{if(this.state.autoSave) this.trySave();});
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ function parseBrewForStorage(brew, slot = 0) {
|
||||
title : brew.title,
|
||||
text : brew.text,
|
||||
style : brew.style,
|
||||
snippets : brew.snippets,
|
||||
version : brew.version,
|
||||
shareId : brew.shareId,
|
||||
savedAt : brew?.savedAt || new Date(),
|
||||
|
||||
4196
package-lock.json
generated
4196
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
46
package.json
46
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homebrewery",
|
||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||
"version": "3.19.0",
|
||||
"version": "3.19.3",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": "^10.8.x",
|
||||
@@ -72,7 +72,7 @@
|
||||
"lines": 50
|
||||
},
|
||||
"server/homebrew.api.js": {
|
||||
"statements": 70,
|
||||
"statements": 60,
|
||||
"branches": 50,
|
||||
"functions": 65,
|
||||
"lines": 70
|
||||
@@ -84,16 +84,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/plugin-transform-runtime": "^7.28.0",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/runtime": "^7.27.1",
|
||||
"@googleapis/drive": "^12.1.0",
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@dmsnell/diff-match-patch": "^1.1.0",
|
||||
"@googleapis/drive": "^13.0.1",
|
||||
"@sanity/diff-match-patch": "^3.2.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"classnames": "^2.5.1",
|
||||
"codemirror": "^5.65.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"core-js": "^3.42.0",
|
||||
"core-js": "^3.44.0",
|
||||
"cors": "^2.8.5",
|
||||
"create-react-class": "^15.7.0",
|
||||
"dedent-tabs": "^0.10.3",
|
||||
@@ -101,7 +103,9 @@
|
||||
"express": "^5.1.0",
|
||||
"express-async-handler": "^1.2.0",
|
||||
"express-static-gzip": "3.0.0",
|
||||
"fflate": "^0.8.2",
|
||||
"fs-extra": "11.3.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jwt-simple": "^0.5.6",
|
||||
@@ -110,41 +114,41 @@
|
||||
"marked": "15.0.12",
|
||||
"marked-alignment-paragraphs": "^1.0.0",
|
||||
"marked-definition-lists": "^1.0.1",
|
||||
"marked-emoji": "^2.0.0",
|
||||
"marked-emoji": "^2.0.1",
|
||||
"marked-extended-tables": "^2.0.1",
|
||||
"marked-gfm-heading-id": "^4.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.2",
|
||||
"marked-nonbreaking-spaces": "^1.0.1",
|
||||
"marked-smartypants-lite": "^1.0.3",
|
||||
"marked-subsuper-text": "^1.0.3",
|
||||
"markedLegacy": "npm:marked@^0.3.19",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.15.0",
|
||||
"mongoose": "^8.16.3",
|
||||
"nanoid": "5.1.5",
|
||||
"nconf": "^0.13.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-frame-component": "^4.1.3",
|
||||
"react-router": "^7.6.0",
|
||||
"romans": "^3.0.0",
|
||||
"react-router": "^7.6.3",
|
||||
"romans": "^3.1.0",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"superagent": "^10.2.1",
|
||||
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git",
|
||||
"written-number": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^3.1.2",
|
||||
"babel-plugin-transform-import-meta": "^2.3.2",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"@stylistic/stylelint-plugin": "^3.1.3",
|
||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-jest": "^29.0.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"globals": "^16.3.0",
|
||||
"jest": "^30.0.5",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"jsdom-global": "^3.0.2",
|
||||
"postcss-less": "^6.0.0",
|
||||
"stylelint": "^16.19.1",
|
||||
"stylelint-config-recess-order": "^6.0.0",
|
||||
"stylelint": "^16.22.0",
|
||||
"stylelint-config-recess-order": "^7.1.0",
|
||||
"stylelint-config-recommended": "^16.0.0",
|
||||
"supertest": "^7.1.1"
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"codemirror/addon/selection/active-line.js",
|
||||
"codemirror/addon/hint/show-hint.js",
|
||||
"moment",
|
||||
"superagent"
|
||||
"superagent",
|
||||
"@sanity/diff-match-patch",
|
||||
"fflate"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
|
||||
title : req.brew.title || 'Untitled Brew',
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
locale : req.brew.lang,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
|
||||
66
server/forcessl.mw.spec.js
Normal file
66
server/forcessl.mw.spec.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -8,8 +8,10 @@ import Markdown from '../shared/naturalcrit/markdown.js';
|
||||
import yaml from 'js-yaml';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { splitTextStyleAndMetadata,
|
||||
brewSnippetsToJSON } from '../shared/helpers.js';
|
||||
brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
|
||||
import checkClientVersion from './middleware/check-client-version.js';
|
||||
|
||||
|
||||
@@ -46,6 +48,20 @@ const api = {
|
||||
}
|
||||
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 };
|
||||
},
|
||||
//Get array of any of this user's brews tagged with `meta:theme`
|
||||
@@ -337,21 +353,52 @@ const api = {
|
||||
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
|
||||
const brewFromClient = api.excludePropsFromUpdate(req.body);
|
||||
const brewFromServer = req.brew;
|
||||
if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) {
|
||||
splitTextStyleAndMetadata(brewFromServer);
|
||||
|
||||
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 brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` }));
|
||||
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.` }));
|
||||
}
|
||||
|
||||
let brew = _.assign(brewFromServer, brewFromClient);
|
||||
brewFromServer.text = brewFromServer.text.normalize('NFC');
|
||||
brewFromServer.hash = await md5(brewFromServer.text);
|
||||
|
||||
if(brewFromServer?.hash !== brewFromClient?.hash) {
|
||||
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
|
||||
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
|
||||
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.` }));
|
||||
}
|
||||
|
||||
try {
|
||||
const patches = parsePatch(brewFromClient.patches);
|
||||
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
|
||||
const patchedResult = decodeURI(applyPatches(patches, encodeURI(brewFromServer.text))[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);
|
||||
brew.title = brew.title.trim();
|
||||
brew.description = brew.description.trim() || '';
|
||||
brew.text = api.mergeBrewText(brew);
|
||||
|
||||
const googleId = brew.googleId;
|
||||
const { saveToGoogle, removeFromGoogle } = req.query;
|
||||
let afterSave = async ()=>true;
|
||||
|
||||
brew.title = brew.title.trim();
|
||||
brew.description = brew.description.trim() || '';
|
||||
brew.text = api.mergeBrewText(brew);
|
||||
|
||||
if(brew.googleId && removeFromGoogle) {
|
||||
// If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined
|
||||
afterSave = async ()=>{
|
||||
@@ -484,10 +531,10 @@ const api = {
|
||||
};
|
||||
|
||||
router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
|
||||
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew));
|
||||
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew));
|
||||
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
|
||||
|
||||
export default api;
|
||||
export default api;
|
||||
|
||||
@@ -99,18 +99,87 @@ describe('Tests for api', ()=>{
|
||||
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', ()=>{
|
||||
const { id, googleId } = api.getId({
|
||||
params : {
|
||||
id : 'abcdefgh'
|
||||
id : 'abcdefghijkl'
|
||||
},
|
||||
body : {
|
||||
googleId : '12345'
|
||||
googleId : '123456789012345678901234567890123'
|
||||
}
|
||||
});
|
||||
|
||||
expect(id).toEqual('abcdefgh');
|
||||
expect(googleId).toEqual('12345');
|
||||
expect(id).toEqual('abcdefghijkl');
|
||||
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', ()=>{
|
||||
@@ -1052,4 +1121,83 @@ brew`);
|
||||
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
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,21 +5,16 @@ import config from './config.js';
|
||||
const generateAccessToken = (account)=>{
|
||||
const payload = account;
|
||||
|
||||
// When the token was issued
|
||||
payload.issued = (new Date());
|
||||
// Which service issued the Token
|
||||
payload.issuer = config.get('authentication_token_issuer');
|
||||
// Which service is the token intended for
|
||||
payload.audience = config.get('authentication_token_audience');
|
||||
// The signing key for signing the token
|
||||
payload.issued = (new Date()); // When the token was issued
|
||||
payload.issuer = config.get('authentication_token_issuer'); // Which service issued the Token
|
||||
payload.audience = config.get('authentication_token_audience'); // Which service is the token intended for
|
||||
const secret = config.get('authentication_token_secret'); // The signing key for signing the token
|
||||
|
||||
delete payload.password;
|
||||
delete payload._id;
|
||||
|
||||
const secret = config.get('authentication_token_secret');
|
||||
|
||||
const token = jwt.encode(payload, secret);
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
export default generateAccessToken;
|
||||
export default generateAccessToken;
|
||||
|
||||
27
server/token.spec.js
Normal file
27
server/token.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
splitTextStyleAndMetadata,
|
||||
printCurrentBrew,
|
||||
fetchThemeBundle,
|
||||
brewSnippetsToJSON
|
||||
brewSnippetsToJSON,
|
||||
debugTextMismatch
|
||||
};
|
||||
|
||||
@@ -4,6 +4,17 @@ require('jsdom-global')();
|
||||
|
||||
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() {
|
||||
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
|
||||
const rendered = safeHTML(source);
|
||||
|
||||
Reference in New Issue
Block a user