0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-06 01:22:44 +00:00

Merge branch 'master' into pr/3845

This commit is contained in:
Trevor Buckner
2024-12-23 11:37:40 -05:00
21 changed files with 504 additions and 1069 deletions

View File

@@ -70,9 +70,15 @@ jobs:
- run: - run:
name: Test - Hard Breaks name: Test - Hard Breaks
command: npm run test:hard-breaks command: npm run test:hard-breaks
- run:
name: Test - Non-Breaking Spaces
command: npm run test:non-breaking-spaces
- run: - run:
name: Test - Variables name: Test - Variables
command: npm run test:variables command: npm run test:variables
- run:
name: Test - Emojis
command: npm run test:emojis
- run: - run:
name: Test - Routes name: Test - Routes
command: npm run test:route command: npm run test:route
@@ -82,6 +88,9 @@ jobs:
- run: - run:
name: Test - Coverage name: Test - Coverage
command: npm run test:coverage command: npm run test:coverage
- run:
name: Test - Content Negotiation
command: npm run test:content-negotiation
workflows: workflows:
build_and_test: build_and_test:

View File

@@ -1,26 +1,29 @@
<!-- > [!TIP]
Before submitting a Pull Request, please consider the following to speed up reviews: > Before submitting a Pull Request, please consider the following to speed up reviews:
- 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs. > - 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs.
- 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first. > - 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first.
- 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests? > - 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests?
- 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding. > - 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
-->
## Description ## Description
_Describe what your PR accomplishes. Consider walking through the main changes to aid reviewers in following your code, especially if it covers multiple files._
## Related Issues or Discussions ## Related Issues or Discussions
> [!CAUTION]
> If no issue exists yet, create it, and get agreement on the approach (or paste in a previous agreement from chat, etc.) before moving forward. (Experimental PRs are OK without prior discussion, but do not expect to get merged.)
- Closes # - Closes #
## QA Instructions, Screenshots, Recordings ## QA Instructions, Screenshots, Recordings
_Please replace this line with instructions on how to test or view your changes, as well as any before/after _Replace this line with instructions on how to test or view your changes, as well as any before/after
images for UI changes._ screenshots or recordings for UI changes._
### Reviewer Checklist ### Reviewer Checklist
_Please replace the list below with specific features you want reviewers to look at._ _Replace the list below with specific features you want reviewers to look at._
*Reviewers, refer to this list when testing features, or suggest new items * *Reviewers, refer to this list when testing features, or suggest new items *
- [ ] Verify new features are functional - [ ] Verify new features are functional
@@ -32,5 +35,3 @@ _Please replace the list below with specific features you want reviewers to look
- [ ] Feature A handles negative numbers - [ ] Feature A handles negative numbers
- [ ] Identify opportunities for simplification and refactoring - [ ] Identify opportunities for simplification and refactoring
- [ ] Check for code legibility and appropriate comments - [ ] Check for code legibility and appropriate comments
<details><summary>Copy this list</summary>

View File

@@ -115,10 +115,10 @@
color : #D3D3D3; color : #D3D3D3;
accent-color : #D3D3D3; accent-color : #D3D3D3;
&::-webkit-slider-thumb, &::-moz-slider-thumb { &::-webkit-slider-thumb, &::-moz-range-thumb {
width : 5px; width : 5px;
height : 5px; height : 5px;
cursor : pointer; cursor : ew-resize;
outline : none; outline : none;
} }

View File

@@ -207,19 +207,11 @@ const Snippetbar = createClass({
renderEditorButtons : function(){ renderEditorButtons : function(){
if(!this.props.showEditButtons) return; if(!this.props.showEditButtons) return;
const foldButtons = <>
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
return <div className='editors'> return (
<div className='historyTools'> <div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} <div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} > onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' /> <i className='fas fa-clock-rotate-left' />
@@ -235,14 +227,21 @@ const Snippetbar = createClass({
</div> </div>
</div> </div>
<div className='codeTools'> <div className='codeTools'>
{foldButtons} <div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`} onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} > onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' /> <i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()} {this.state.themeSelector && this.renderThemeSelector()}
</div> </div>
</div> </div></>}
<div className='tabs'> <div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })} <div className={cx('text', { selected: this.props.view === 'text' })}
@@ -259,7 +258,8 @@ const Snippetbar = createClass({
</div> </div>
</div> </div>
</div>; </div>
)
}, },
render : function(){ render : function(){

View File

@@ -22,7 +22,7 @@
justify-content : flex-end; justify-content : flex-end;
min-width : 225px; min-width : 225px;
&:only-child { margin-left : auto; } &:only-child { margin-left : auto;min-width:unset;}
>div { >div {
display : flex; display : flex;
@@ -38,6 +38,11 @@
line-height : @menuHeight; line-height : @menuHeight;
text-align : center; text-align : center;
cursor : pointer; cursor : pointer;
&.editorTool:not(.active) {
cursor:not-allowed;
}
&:hover,&.selected { background-color : #999999; } &:hover,&.selected { background-color : #999999; }
&.text { &.text {
.tooltipLeft('Brew Editor'); .tooltipLeft('Brew Editor');

View File

@@ -1,8 +1,12 @@
//╔===--------------- Polyfills --------------===╗//
import 'core-js/es/string/to-well-formed.js';
//╚===--------------- ---------------===╝//
require('./homebrew.less'); require('./homebrew.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const { StaticRouter:Router } = require('react-router-dom/server'); const { StaticRouter:Router } = require('react-router');
const { Route, Routes, useParams, useSearchParams } = require('react-router-dom'); const { Route, Routes, useParams, useSearchParams } = require('react-router');
const HomePage = require('./pages/homePage/homePage.jsx'); const HomePage = require('./pages/homePage/homePage.jsx');
const EditPage = require('./pages/editPage/editPage.jsx'); const EditPage = require('./pages/editPage/editPage.jsx');

View File

@@ -381,7 +381,7 @@ const EditPage = createClass({
**[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`; **[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title)}&text=${encodeURIComponent(text)}`; return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
}, },
renderNavbar : function(){ renderNavbar : function(){

View File

@@ -18,7 +18,18 @@ const errorIndex = (props)=>{
'01' : dedent` '01' : dedent`
## An error occurred while retrieving this brew from Google Drive! ## An error occurred while retrieving this brew from Google Drive!
Google reported an error while attempting to retrieve a brew from this link.`, Google is able to see the brew at this link, but reported an error while attempting to retrieve it.
### Refreshing your Google Credentials
This issue is likely caused by an issue with your Google credentials; if you are the owner of this file, the following steps may resolve the issue:
- Go to https://www.naturalcrit.com/login and click logout if present (in small text at the bottom of the page).
- Click "Sign In with Google", which will refresh your Google credentials.
- After completing the sign in process, return to Homebrewery and refresh/reload the page so that it can pick up the updated credentials.
- If this was the source of the issue, it should now be resolved.
If following these steps does not resolve the issue, please let us know!`,
// Google Drive - 404 : brew deleted or access denied // Google Drive - 404 : brew deleted or access denied
'02' : dedent` '02' : dedent`
@@ -50,7 +61,7 @@ const errorIndex = (props)=>{
- **The Google Account may be closed.** Google may have removed the account - **The Google Account may be closed.** Google may have removed the account
due to inactivity or violating a Google policy. Make sure the owner can due to inactivity or violating a Google policy. Make sure the owner can
still access Google Drive normally and upload/download files to it. still access Google Drive normally and upload/download files to it.
:
If the file isn't found, Google Drive usually puts your file in your Trash folder for If the file isn't found, Google Drive usually puts your file in your Trash folder for
30 days. Assuming the trash hasn't been emptied yet, it might be worth checking. 30 days. Assuming the trash hasn't been emptied yet, it might be worth checking.
You can also find the Activity tab on the right side of the Google Drive page, which You can also find the Activity tab on the right side of the Google Drive page, which
@@ -172,8 +183,8 @@ const errorIndex = (props)=>{
**Brew Title:** ${props.brew.brewTitle}`, **Brew Title:** ${props.brew.brewTitle}`,
// ####### Admin page error ####### // ####### Admin page error #######
'52': dedent` '52' : dedent`
## Access Denied ## Access Denied
You need to provide correct administrator credentials to access this page.`, You need to provide correct administrator credentials to access this page.`,

1100
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose", "test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose", "test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
"test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose", "test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
"test:content-negotiation": "jest \"server/middleware/.*.spec.js\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand", "test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch", "test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose", "test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -37,6 +38,7 @@
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace", "test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace", "test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace", "test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace", "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose", "test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
@@ -91,9 +93,11 @@
"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.39.0",
"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",
"dompurify": "^3.2.2", "dompurify": "^3.2.3",
"expr-eval": "^2.0.2", "expr-eval": "^2.0.2",
"express": "^4.21.2", "express": "^4.21.2",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
@@ -111,13 +115,13 @@
"marked-smartypants-lite": "^1.0.2", "marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.8.4", "mongoose": "^8.9.2",
"nanoid": "5.0.9", "nanoid": "5.0.9",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router-dom": "6.28.0", "react-router": "^7.0.2",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.1.1", "superagent": "^10.1.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git" "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
@@ -125,15 +129,15 @@
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.1", "@stylistic/stylelint-plugin": "^3.1.1",
"babel-plugin-transform-import-meta": "^2.2.1", "babel-plugin-transform-import-meta": "^2.2.1",
"eslint": "^9.16.0", "eslint": "^9.17.0",
"eslint-plugin-jest": "^28.9.0", "eslint-plugin-jest": "^28.10.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"globals": "^15.13.0", "globals": "^15.14.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.11.0", "stylelint": "^16.12.0",
"stylelint-config-recess-order": "^5.1.1", "stylelint-config-recess-order": "^5.1.1",
"stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0" "supertest": "^7.0.0"

View File

@@ -7,11 +7,12 @@ const { pack, watchFile, livereload } = vitreum;
import lessTransform from 'vitreum/transforms/less.js'; import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js'; import assetTransform from 'vitreum/transforms/asset.js';
import babel from '@babel/core'; import babel from '@babel/core';
import babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less'; import less from 'less';
const isDev = !!process.argv.find((arg) => arg === '--dev'); const isDev = !!process.argv.find((arg) => arg === '--dev');
const babelify = async (code)=>(await babel.transformAsync(code, { presets: [['@babel/preset-env', { 'exclude': ['proposal-dynamic-import'] }], '@babel/preset-react'], plugins: ['@babel/plugin-transform-runtime'] })).code; const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
const transforms = { const transforms = {
'.js' : (code, filename, opts)=>babelify(code), '.js' : (code, filename, opts)=>babelify(code),

View File

@@ -1,5 +1,5 @@
import {model as HomebrewModel } from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
import {model as NotificationModel } from './notifications.model.js'; import { model as NotificationModel } from './notifications.model.js';
import express from 'express'; import express from 'express';
import Moment from 'moment'; import Moment from 'moment';
import zlib from 'zlib'; import zlib from 'zlib';
@@ -108,6 +108,9 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
req.body = brew; req.body = brew;
// Remove Account from request to prevent Admin user from being added to brew as an Author
req.account = undefined;
return await HomebrewAPI.updateBrew(req, res); return await HomebrewAPI.updateBrew(req, res);
}); });

View File

@@ -2,7 +2,7 @@
// Set working directory to project root // Set working directory to project root
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import packageJSON from './../package.json' with { type: "json" }; import packageJSON from './../package.json' with { type: 'json' };
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`); process.chdir(`${__dirname}/..`);
@@ -26,7 +26,7 @@ import serveCompressedStaticAssets from './static-assets.mv.js';
import sanitizeFilename from 'sanitize-filename'; import sanitizeFilename from 'sanitize-filename';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import templateFn from '../client/template.js'; import templateFn from '../client/template.js';
import {model as HomebrewModel } from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
import { DEFAULT_BREW } from './brewDefaults.js'; import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js'; import { splitTextStyleAndMetadata } from '../shared/helpers.js';
@@ -47,7 +47,7 @@ const sanitizeBrew = (brew, accessType)=>{
return brew; return brew;
}; };
app.set('trust proxy', 1 /* number of proxies between user and server */) app.set('trust proxy', 1 /* number of proxies between user and server */);
app.use('/', serveCompressedStaticAssets(`build`)); app.use('/', serveCompressedStaticAssets(`build`));
app.use(contentNegotiation); app.use(contentNegotiation);
@@ -55,6 +55,40 @@ app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser()); app.use(cookieParser());
app.use(forceSSL); app.use(forceSSL);
import cors from 'cors';
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
const corsOptions = {
origin : (origin, callback)=>{
const allowedOrigins = [
'https://homebrewery.naturalcrit.com',
'https://www.naturalcrit.com',
'https://naturalcrit-stage.herokuapp.com',
'https://homebrewery-stage.herokuapp.com',
];
if(isLocalEnvironment) {
allowedOrigins.push('http://localhost:8000', 'http://localhost:8010');
}
const herokuRegex = /^https:\/\/(?:homebrewery-pr-\d+\.herokuapp\.com|naturalcrit-pr-\d+\.herokuapp\.com)$/; // Matches any Heroku app
if(!origin || allowedOrigins.includes(origin) || herokuRegex.test(origin)) {
callback(null, true);
} else {
console.log(origin, 'not allowed');
callback(new Error('Not allowed by CORS, if you think this is an error, please contact us'));
}
},
methods : ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials : true,
};
app.use(cors(corsOptions));
//Account Middleware //Account Middleware
app.use((req, res, next)=>{ app.use((req, res, next)=>{
if(req.cookies && req.cookies.nc_session){ if(req.cookies && req.cookies.nc_session){
@@ -62,7 +96,9 @@ app.use((req, res, next)=>{
req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
//console.log("Just loaded up JWT from cookie:"); //console.log("Just loaded up JWT from cookie:");
//console.log(req.account); //console.log(req.account);
} catch (e){} } catch (e){
console.log(e);
}
} }
req.config = { req.config = {
@@ -273,7 +309,7 @@ app.get('/user/:username', async (req, res, next)=>{
console.log(err); console.log(err);
}); });
brews.forEach(brew => brew.stubbed = true); //All brews from MongoDB are "stubbed" brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
if(ownAccount && req?.account?.googleId){ if(ownAccount && req?.account?.googleId){
const auth = await GoogleActions.authCheck(req.account, res); const auth = await GoogleActions.authCheck(req.account, res);
@@ -312,6 +348,34 @@ app.get('/user/:username', async (req, res, next)=>{
return next(); return next();
}); });
//Change author name on brews
app.put('/api/user/rename', async (req, res)=>{
const { username, newUsername } = req.body;
const ownAccount = req.account && (req.account.username == newUsername);
if(!username || !newUsername)
return res.status(400).json({ error: 'Username and newUsername are required.' });
if(!ownAccount)
return res.status(403).json({ error: 'Must be logged in to change your username' });
try {
const brews = await HomebrewModel.getByUser(username, true, ['authors']);
const renamePromises = brews.map(async (brew)=>{
const updatedAuthors = brew.authors.map((author)=>author === username ? newUsername : author
);
return HomebrewModel.updateOne(
{ _id: brew._id },
{ $set: { authors: updatedAuthors } }
);
});
await Promise.all(renamePromises);
return res.json({ success: true, message: `Brews for ${username} renamed to ${newUsername}.` });
} catch (error) {
console.error('Error renaming brews:', error);
return res.status(500).json({ error: 'Failed to rename brews.' });
}
});
//Edit Page //Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
@@ -399,7 +463,7 @@ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, r
app.get('/account', asyncHandler(async (req, res, next)=>{ app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {}; const data = {};
data.title = 'Account Information Page'; data.title = 'Account Information Page';
if(!req.account) { if(!req.account) {
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
const error = new Error('No valid account'); const error = new Error('No valid account');
@@ -413,7 +477,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
let googleCount = []; let googleCount = [];
if(req.account) { if(req.account) {
if(req.account.googleId) { if(req.account.googleId) {
auth = await GoogleActions.authCheck(req.account, res, false) auth = await GoogleActions.authCheck(req.account, res, false);
googleCount = await GoogleActions.listGoogleBrews(auth) googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{ .catch((err)=>{
@@ -448,8 +512,6 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
return next(); return next();
})); }));
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
// Local only // Local only
if(isLocalEnvironment){ if(isLocalEnvironment){
// Login // Login
@@ -477,8 +539,8 @@ app.get('/vault', asyncHandler(async(req, res, next)=>{
//Send rendered page //Send rendered page
app.use(asyncHandler(async (req, res, next)=>{ app.use(asyncHandler(async (req, res, next)=>{
if (!req.route) return res.redirect('/'); // Catch-all for invalid routes if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
const page = await renderPage(req, res); const page = await renderPage(req, res);
if(!page) return; if(!page) return;
res.send(page); res.send(page);

View File

@@ -106,12 +106,12 @@ const api = {
stub = stub?.toObject(); stub = stub?.toObject();
googleId ??= stub?.googleId; googleId ??= stub?.googleId;
const isOwner = stub?.authors?.length === 0 || stub?.authors?.[0] === req.account?.username; const isOwner = (accessType == 'edit' && (!stub || stub?.authors?.length === 0)) || stub?.authors?.[0] === req.account?.username;
const isAuthor = stub?.authors?.includes(req.account?.username); const isAuthor = stub?.authors?.includes(req.account?.username);
const isInvited = stub?.invitedAuthors?.includes(req.account?.username); const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
if(accessType === 'edit' && !(isOwner || isAuthor || isInvited)) { if(accessType === 'edit' && !(isOwner || isAuthor || isInvited)) {
const accessError = { name: 'Access Error', status: 401, authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId }; const accessError = { name: 'Access Error', status: 401, authors: stub?.authors, brewTitle: stub?.title, shareId: stub?.shareId };
if(req.account) if(req.account)
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' }; throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' };
else else
@@ -119,13 +119,13 @@ const api = {
} }
if(stub?.lock?.locked && accessType != 'edit') { if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title }; throw { HBErrorCode: '51', code: stub?.lock.code, message: stub?.lock.shareMessage, brewId: stub?.shareId, brewTitle: stub?.title };
} }
// If there is a google id, try to find the google brew // If there's a google id, get it if requesting the full brew or if no stub found yet
if(!stubOnly && googleId) { if(googleId && (!stubOnly || !stub)) {
const oAuth2Client = isOwner? GoogleActions.authCheck(req.account, res) : undefined; const oAuth2Client = isOwner ? GoogleActions.authCheck(req.account, res) : undefined;
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType) const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType)
.catch((googleError)=>{ .catch((googleError)=>{
const reason = googleError.errors?.[0].reason; const reason = googleError.errors?.[0].reason;
@@ -467,12 +467,11 @@ const api = {
} }
}; };
router.use('/api', checkClientVersion); router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.post('/api', asyncHandler(api.newBrew)); router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/:id', 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/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api; export default api;

View File

@@ -1,10 +1,10 @@
import packageJSON from '../../package.json' with { type: "json" }; import packageJSON from '../../package.json' with { type: 'json' };
const version = packageJSON.version;
export default (req, res, next)=>{ export default (req, res, next)=>{
const userVersion = req.get('Homebrewery-Version'); const userVersion = req.get('Homebrewery-Version');
const version = packageJSON.version;
if(userVersion != version) { if(userVersion !== version) {
return res.status(412).send({ return res.status(412).send({
message : `Client version ${userVersion} is out of date. Please save your changes elsewhere and refresh to pick up client version ${version}.` message : `Client version ${userVersion} is out of date. Please save your changes elsewhere and refresh to pick up client version ${version}.`
}); });
@@ -12,3 +12,4 @@ export default (req, res, next)=>{
next(); next();
}; };

View File

@@ -6,7 +6,7 @@ export default (req, res, next)=>{
const isImageRequest = req.get('Accept')?.split(',') const isImageRequest = req.get('Accept')?.split(',')
?.filter((h)=>!h.includes('q=')) ?.filter((h)=>!h.includes('q='))
?.every((h)=>/image\/.*/.test(h)); ?.every((h)=>/image\/.*/.test(h));
if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) { if(isImageRequest && !(isLocalEnvironment && req.url?.startsWith('/staticImages'))) {
return res.status(406).send({ return res.status(406).send({
message : 'Request for image at this URL is not supported' message : 'Request for image at this URL is not supported'
}); });

View File

@@ -0,0 +1,41 @@
import contentNegotiationMiddleware from './content-negotiation.js';
describe('content-negotiation-middleware', ()=>{
let request;
let response;
let next;
beforeEach(()=>{
request = {
get : function(key) {
return this[key];
}
};
response = {
status : jest.fn(()=>response),
send : jest.fn(()=>{})
};
next = jest.fn();
});
it('should return 406 on image request', ()=>{
contentNegotiationMiddleware({
Accept : 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
...request
}, response);
expect(response.status).toHaveBeenLastCalledWith(406);
expect(response.send).toHaveBeenCalledWith({
message : 'Request for image at this URL is not supported'
});
});
it('should call next on non-image request', ()=>{
contentNegotiationMiddleware({
Accept : 'text,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
...request
}, response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -11,49 +11,54 @@
@import (less) './themes/fonts/iconFonts/fontAwesome.less'; @import (less) './themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation { @keyframes sourceMoveAnimation {
50% {background-color: red; color: white;} 50% { color : white;background-color : red;}
100% {background-color: unset; color: unset;} 100% { color : unset;background-color : unset;}
} }
.codeEditor{ .codeEditor {
@media screen and (pointer : coarse) { @media screen and (pointer : coarse) {
font-size : 16px; font-size : 16px;
} }
.CodeMirror-foldmarker { .CodeMirror-foldmarker {
font-family: inherit; font-family : inherit;
text-shadow: none; font-weight : 600;
font-weight: 600; color : grey;
color: grey; text-shadow : none;
} }
.sourceMoveFlash .CodeMirror-line{ .CodeMirror-foldgutter {
animation-name: sourceMoveAnimation; cursor : pointer;
animation-duration: 0.4s; border-left : 1px solid #EEEEEE;
} transition : background 0.1s;
&:hover { background : #DDDDDD; }
}
.CodeMirror-vscrollbar { .sourceMoveFlash .CodeMirror-line {
&::-webkit-scrollbar { animation-name : sourceMoveAnimation;
width: 20px; animation-duration : 0.4s;
} }
&::-webkit-scrollbar-thumb {
width: 20px; .CodeMirror-vscrollbar {
background: linear-gradient(90deg, #858585 15px, #808080 15px); &::-webkit-scrollbar { width : 20px; }
} &::-webkit-scrollbar-thumb {
} width : 20px;
background : linear-gradient(90deg, #858585 15px, #808080 15px);
}
}
//.cm-tab { //.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right; // background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
//} //}
//.cm-trailingspace { //.cm-trailingspace {
// .cm-space { // .cm-space {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right; // background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
// } // }
//} //}
} }
.emojiPreview { .emojiPreview {
font-size: 1.5em; font-size : 1.5em;
line-height: 1.2em; line-height : 1.2em;
} }

View File

@@ -391,10 +391,31 @@ const forcedParagraphBreaks = {
} }
}; };
const nonbreakingSpaces = {
name : 'nonbreakingSpaces',
level : 'inline',
start(src) { return src.match(/:>+/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /:(>+)/ym;
const match = regex.exec(src);
if(match?.length) {
return {
type : 'nonbreakingSpaces', // Should match "name" above
raw : match[0], // Text to consume from the source
length : match[1].length,
text : ''
};
}
},
renderer(token) {
return `&nbsp;`.repeat(token.length).concat('');
}
};
const definitionListsSingleLine = { const definitionListsSingleLine = {
name : 'definitionListsSingleLine', name : 'definitionListsSingleLine',
level : 'block', level : 'block',
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym; const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
let match; let match;
@@ -748,11 +769,12 @@ const tableTerminators = [
]; ];
Marked.use(MarkedVariables()); Marked.use(MarkedVariables());
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts, Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
mustacheSpans, mustacheDivs, mustacheInjectInline] }); nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use(mustacheInjectBlock); Marked.use(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false }); Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions)); Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }),
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
function cleanUrl(href) { function cleanUrl(href) {
try { try {

View File

@@ -43,5 +43,6 @@ html,body, #reactRoot{
} }
&:disabled{ &:disabled{
background-color : @silver !important; background-color : @silver !important;
cursor:not-allowed;
} }
} }

View File

@@ -0,0 +1,72 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js';
describe('Non-Breaking Spaces', ()=>{
test('Single Space', function() {
const source = ':>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;</p>`);
});
test('Double Space', function() {
const source = ':>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;</p>`);
});
test('Triple Space', function() {
const source = ':>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;</p>`);
});
test('Many Space', function() {
const source = ':>>>>>>>>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Multiple sets of Spaces', function() {
const source = ':>>>\n:>>>\n:>>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;</p>`);
});
test('Pair of inline Spaces', function() {
const source = ':>>:>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Space directly between two paragraphs', function() {
const source = 'Line 1\n:>>\nLine 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1\n&nbsp;&nbsp;\nLine 2</p>`);
});
test('Ignored inside a code block', function() {
const source = '```\n\n:>\n\n```\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:&gt;\n</code></pre>`);
});
test('I am actually a single-line definition list!', function() {
const source = 'Term ::> Definition 1\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>> Definition 1</dd>\n</dl>`);
});
test('I am actually a definition list!', function() {
const source = 'Term\n::> Definition 1\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd></dl>`);
});
test('I am actually a two-term definition list!', function() {
const source = 'Term\n::> Definition 1\n::>> Definition 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd>\n<dd>>> Definition 2</dd></dl>`);
});
});