diff --git a/changelog.md b/changelog.md index 3f85850fc..0a108b9f2 100644 --- a/changelog.md +++ b/changelog.md @@ -88,6 +88,14 @@ 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 diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 114fe7ed6..6bcfc87ec 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -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 ; diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx index 415390498..a6b4b9175 100644 --- a/client/homebrew/homebrew.jsx +++ b/client/homebrew/homebrew.jsx @@ -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 ; + const queryParams = Object.fromEntries(searchParams?.entries() || []); + + return ; }; -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 ( - - - - } /> - } /> - } /> - } /> - } /> - }/> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - ); - } -}); + return ( + + + + } /> + } /> + } /> + } /> + } /> + }/> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +}; module.exports = Homebrew; \ No newline at end of file diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 7e6c03473..b2c21a157 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -5,6 +5,7 @@ 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'); @@ -190,8 +191,9 @@ const EditPage = createClass({ this.setState((prevState)=>({ brew : { ...prevState.brew, - style : newData.style, - text : newData.text + style : newData.style, + text : newData.text, + snippets : newData.snippets } })); }, @@ -247,6 +249,9 @@ const EditPage = createClass({ 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, @@ -256,23 +261,25 @@ const EditPage = createClass({ await updateHistory(this.state.brew).catch(console.error); await versionHistoryGarbageCollection().catch(console.error); - const preSaveSnapshot = { ...this.state.brew }; - //Prepare content to send to server - const brew = { ...this.state.brew }; - brew.text = brew.text.normalize(); - this.savedBrew.text = this.savedBrew.text.normalize(); + 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(this.savedBrew.text, brew.text)); brew.hash = await md5(this.savedBrew.text); - brew.text = undefined; + //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 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 }); @@ -295,8 +302,8 @@ const EditPage = createClass({ shareId : res.body.shareId, version : res.body.version }, - isSaving : false, - unsavedTime : new Date() + isSaving : false, + unsavedTime : new Date() }), ()=>{ this.setState({ unsavedChanges : this.hasChanges() }); }); diff --git a/client/homebrew/pages/errorPage/errors/errorIndex.js b/client/homebrew/pages/errorPage/errors/errorIndex.js index b13b19eb1..c0220b648 100644 --- a/client/homebrew/pages/errorPage/errors/errorIndex.js +++ b/client/homebrew/pages/errorPage/errors/errorIndex.js @@ -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 diff --git a/client/homebrew/utils/versionHistory.js b/client/homebrew/utils/versionHistory.js index ec3bd74e1..f3d6aa97b 100644 --- a/client/homebrew/utils/versionHistory.js +++ b/client/homebrew/utils/versionHistory.js @@ -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(), diff --git a/package-lock.json b/package-lock.json index 04c069685..46d47ebc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebrewery", - "version": "3.19.2", + "version": "3.19.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebrewery", - "version": "3.19.2", + "version": "3.19.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,13 +15,14 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@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.43.0", + "core-js": "^3.44.0", "cors": "^2.8.5", "create-react-class": "^15.7.0", "dedent-tabs": "^0.10.3", @@ -29,6 +30,7 @@ "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", @@ -48,7 +50,7 @@ "marked-variables": "^1.0.2", "markedLegacy": "npm:marked@^0.3.19", "moment": "^2.30.1", - "mongoose": "^8.16.1", + "mongoose": "^8.16.3", "nanoid": "5.1.5", "nconf": "^0.13.0", "react": "^18.3.1", @@ -64,7 +66,7 @@ "devDependencies": { "@stylistic/stylelint-plugin": "^3.1.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-react": "^7.37.5", "globals": "^16.3.0", @@ -75,7 +77,7 @@ "stylelint": "^16.21.1", "stylelint-config-recess-order": "^7.1.0", "stylelint-config-recommended": "^16.0.0", - "supertest": "^7.1.1" + "supertest": "^7.1.3" }, "engines": { "node": "^20.18.x", @@ -2028,6 +2030,12 @@ "@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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", @@ -5298,9 +5306,9 @@ } }, "node_modules/core-js": { - "version": "3.43.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz", - "integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", + "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -6836,6 +6844,12 @@ "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==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -10410,9 +10424,9 @@ } }, "node_modules/mongoose": { - "version": "8.16.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", - "integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.3.tgz", + "integrity": "sha512-p2JOsRQG7j0vXhLpsWw5Slm2VnDeJK8sRyqSyegk5jQujuP9BTOZ1Di9VX/0lYfBhZ2DpAExi51QTd4pIqSgig==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -13716,9 +13730,9 @@ } }, "node_modules/superagent": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz", - "integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.2.tgz", + "integrity": "sha512-vWMq11OwWCC84pQaFPzF/VO3BrjkCeewuvJgt1jfV0499Z1QSAWN4EqfMM5WlFDDX9/oP8JjlDKpblrmEoyu4Q==", "license": "MIT", "dependencies": { "component-emitter": "^1.3.0", @@ -13748,14 +13762,14 @@ } }, "node_modules/supertest": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", - "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.3.tgz", + "integrity": "sha512-ORY0gPa6ojmg/C74P/bDoS21WL6FMXq5I8mawkEz30/zkwdu0gOeqstFy316vHG6OKxqQ+IbGneRemHI8WraEw==", "dev": true, "license": "MIT", "dependencies": { "methods": "^1.1.2", - "superagent": "^10.2.1" + "superagent": "^10.2.2" }, "engines": { "node": ">=14.18.0" diff --git a/package.json b/package.json index dcc0b2027..e15cdf35c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "homebrewery", "description": "Create authentic looking D&D homebrews using only markdown", - "version": "3.19.2", + "version": "3.19.3", "type": "module", "engines": { "npm": "^10.8.x", @@ -72,7 +72,7 @@ "lines": 50 }, "server/homebrew.api.js": { - "statements": 69, + "statements": 60, "branches": 50, "functions": 65, "lines": 70 @@ -88,13 +88,14 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@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.43.0", + "core-js": "^3.44.0", "cors": "^2.8.5", "create-react-class": "^15.7.0", "dedent-tabs": "^0.10.3", @@ -102,6 +103,7 @@ "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", @@ -121,7 +123,7 @@ "marked-variables": "^1.0.2", "markedLegacy": "npm:marked@^0.3.19", "moment": "^2.30.1", - "mongoose": "^8.16.1", + "mongoose": "^8.16.3", "nanoid": "5.1.5", "nconf": "^0.13.0", "react": "^18.3.1", @@ -137,7 +139,7 @@ "devDependencies": { "@stylistic/stylelint-plugin": "^3.1.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-react": "^7.37.5", "globals": "^16.3.0", @@ -148,6 +150,6 @@ "stylelint": "^16.21.1", "stylelint-config-recess-order": "^7.1.0", "stylelint-config-recommended": "^16.0.0", - "supertest": "^7.1.1" + "supertest": "^7.1.3" } } diff --git a/scripts/project.json b/scripts/project.json index 340de077c..7385b674a 100644 --- a/scripts/project.json +++ b/scripts/project.json @@ -28,6 +28,7 @@ "codemirror/addon/hint/show-hint.js", "moment", "superagent", - "@sanity/diff-match-patch" + "@sanity/diff-match-patch", + "fflate" ] } diff --git a/server/forcessl.mw.spec.js b/server/forcessl.mw.spec.js new file mode 100644 index 000000000..e18821e6d --- /dev/null +++ b/server/forcessl.mw.spec.js @@ -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(); + }); + +}); \ No newline at end of file diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 84f639a4d..82d64c1a3 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -11,7 +11,7 @@ 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'; @@ -48,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` @@ -340,34 +354,45 @@ const api = { const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; 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); - if((brewFromServer?.version !== brewFromClient?.version) || (brewFromServer?.hash !== brewFromClient?.hash)) { - if(brewFromClient?.version !== brewFromClient?.version) - console.log(`Version mismatch on brew ${brewFromClient.editId}`); - if(brewFromServer?.hash !== brewFromClient?.hash) { - console.log(`Hash mismatch on brew ${brewFromClient.editId}`); - } + 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 = 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); brew.title = brew.title.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); const googleId = brew.googleId; diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 8bb3a0c0b..0a6d1d452 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -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 + }) + ); + }); + }); }); diff --git a/server/token.js b/server/token.js index 7a23dff4b..feaea8d33 100644 --- a/server/token.js +++ b/server/token.js @@ -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; \ No newline at end of file +export default generateAccessToken; diff --git a/server/token.spec.js b/server/token.spec.js new file mode 100644 index 000000000..24ebb7f7c --- /dev/null +++ b/server/token.spec.js @@ -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'); + }); +}); \ No newline at end of file diff --git a/shared/helpers.js b/shared/helpers.js index 0ca681dfb..e09b0bdc4 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -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 }; diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js index 2f6fcbd9f..0c77e04b9 100644 --- a/shared/naturalcrit/markdown.js +++ b/shared/naturalcrit/markdown.js @@ -98,7 +98,7 @@ const mustacheSpans = { start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match tokenizer(src, tokens) { 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); if(match) { //Find closing delimiter @@ -155,7 +155,7 @@ const mustacheDivs = { start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match tokenizer(src, tokens) { 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); if(match) { //Find closing delimiter @@ -210,7 +210,7 @@ const mustacheInjectInline = { level : 'inline', start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match tokenizer(src, tokens) { - const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g; + const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/g; const match = inlineRegex.exec(src); if(match) { const lastToken = tokens[tokens.length - 1]; @@ -256,7 +256,7 @@ const mustacheInjectBlock = { level : 'block', start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match tokenizer(src, tokens) { - const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym; + const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/ym; const match = inlineRegex.exec(src); if(match) { const lastToken = tokens[tokens.length - 1]; diff --git a/tests/html/safeHTML.test.js b/tests/html/safeHTML.test.js index 51fa1e995..cb5466a48 100644 --- a/tests/html/safeHTML.test.js +++ b/tests/html/safeHTML.test.js @@ -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 = `Click me`; const rendered = safeHTML(source);