mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2025-12-26 11:42:39 +00:00
Merge branch 'master' into pr/3845
This commit is contained in:
@@ -70,9 +70,15 @@ jobs:
|
||||
- run:
|
||||
name: Test - Hard Breaks
|
||||
command: npm run test:hard-breaks
|
||||
- run:
|
||||
name: Test - Non-Breaking Spaces
|
||||
command: npm run test:non-breaking-spaces
|
||||
- run:
|
||||
name: Test - Variables
|
||||
command: npm run test:variables
|
||||
- run:
|
||||
name: Test - Emojis
|
||||
command: npm run test:emojis
|
||||
- run:
|
||||
name: Test - Routes
|
||||
command: npm run test:route
|
||||
@@ -82,6 +88,9 @@ jobs:
|
||||
- run:
|
||||
name: Test - Coverage
|
||||
command: npm run test:coverage
|
||||
- run:
|
||||
name: Test - Content Negotiation
|
||||
command: npm run test:content-negotiation
|
||||
|
||||
workflows:
|
||||
build_and_test:
|
||||
|
||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -1,26 +1,29 @@
|
||||
<!--
|
||||
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.
|
||||
- 🚩 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?
|
||||
- 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
|
||||
-->
|
||||
> [!TIP]
|
||||
> 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.
|
||||
> - 🚩 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?
|
||||
> - 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
|
||||
|
||||
## 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
|
||||
|
||||
> [!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 #
|
||||
|
||||
## QA Instructions, Screenshots, Recordings
|
||||
|
||||
_Please replace this line with instructions on how to test or view your changes, as well as any before/after
|
||||
images for UI changes._
|
||||
_Replace this line with instructions on how to test or view your changes, as well as any before/after
|
||||
screenshots or recordings for UI changes._
|
||||
|
||||
### 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 *
|
||||
- [ ] 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
|
||||
- [ ] Identify opportunities for simplification and refactoring
|
||||
- [ ] Check for code legibility and appropriate comments
|
||||
|
||||
<details><summary>Copy this list</summary>
|
||||
|
||||
@@ -115,10 +115,10 @@
|
||||
color : #D3D3D3;
|
||||
accent-color : #D3D3D3;
|
||||
|
||||
&::-webkit-slider-thumb, &::-moz-slider-thumb {
|
||||
&::-webkit-slider-thumb, &::-moz-range-thumb {
|
||||
width : 5px;
|
||||
height : 5px;
|
||||
cursor : pointer;
|
||||
cursor : ew-resize;
|
||||
outline : none;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,19 +207,11 @@ const Snippetbar = createClass({
|
||||
renderEditorButtons : function(){
|
||||
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'>
|
||||
<div className='historyTools'>
|
||||
return (
|
||||
<div className='editors'>
|
||||
{this.props.view !== 'meta' && <><div className='historyTools'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
onClick={this.toggleHistoryMenu} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
@@ -235,14 +227,21 @@ const Snippetbar = createClass({
|
||||
</div>
|
||||
</div>
|
||||
<div className='codeTools'>
|
||||
{foldButtons}
|
||||
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
<div className={`editorTool foldAll ${this.props.foldCode ? '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} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></>}
|
||||
|
||||
|
||||
<div className='tabs'>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
@@ -259,7 +258,8 @@ const Snippetbar = createClass({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
render : function(){
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
justify-content : flex-end;
|
||||
min-width : 225px;
|
||||
|
||||
&:only-child { margin-left : auto; }
|
||||
&:only-child { margin-left : auto;min-width:unset;}
|
||||
|
||||
>div {
|
||||
display : flex;
|
||||
@@ -38,6 +38,11 @@
|
||||
line-height : @menuHeight;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
&.editorTool:not(.active) {
|
||||
cursor:not-allowed;
|
||||
}
|
||||
|
||||
&:hover,&.selected { background-color : #999999; }
|
||||
&.text {
|
||||
.tooltipLeft('Brew Editor');
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
//╔===--------------- Polyfills --------------===╗//
|
||||
import 'core-js/es/string/to-well-formed.js';
|
||||
//╚===--------------- ---------------===╝//
|
||||
|
||||
require('./homebrew.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const { StaticRouter:Router } = require('react-router-dom/server');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router-dom');
|
||||
const { StaticRouter:Router } = require('react-router');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router');
|
||||
|
||||
const HomePage = require('./pages/homePage/homePage.jsx');
|
||||
const EditPage = require('./pages/editPage/editPage.jsx');
|
||||
|
||||
@@ -381,7 +381,7 @@ const EditPage = createClass({
|
||||
|
||||
**[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(){
|
||||
|
||||
@@ -18,7 +18,18 @@ const errorIndex = (props)=>{
|
||||
'01' : dedent`
|
||||
## 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
|
||||
'02' : dedent`
|
||||
@@ -50,7 +61,7 @@ const errorIndex = (props)=>{
|
||||
- **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
|
||||
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
|
||||
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
|
||||
@@ -172,8 +183,8 @@ const errorIndex = (props)=>{
|
||||
|
||||
**Brew Title:** ${props.brew.brewTitle}`,
|
||||
|
||||
// ####### Admin page error #######
|
||||
'52': dedent`
|
||||
// ####### Admin page error #######
|
||||
'52' : dedent`
|
||||
## Access Denied
|
||||
You need to provide correct administrator credentials to access this page.`,
|
||||
|
||||
|
||||
1100
package-lock.json
generated
1100
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -27,6 +27,7 @@
|
||||
"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:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
|
||||
"test:content-negotiation": "jest \"server/middleware/.*.spec.js\" --verbose",
|
||||
"test:coverage": "jest --coverage --silent --runInBand",
|
||||
"test:dev": "jest --verbose --watch",
|
||||
"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:definition-lists": "jest tests/markdown/definition-lists.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:route": "jest tests/routes/static-pages.test.js --verbose",
|
||||
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
|
||||
@@ -91,9 +93,11 @@
|
||||
"classnames": "^2.5.1",
|
||||
"codemirror": "^5.65.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"core-js": "^3.39.0",
|
||||
"cors": "^2.8.5",
|
||||
"create-react-class": "^15.7.0",
|
||||
"dedent-tabs": "^0.10.3",
|
||||
"dompurify": "^3.2.2",
|
||||
"dompurify": "^3.2.3",
|
||||
"expr-eval": "^2.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-async-handler": "^1.2.0",
|
||||
@@ -111,13 +115,13 @@
|
||||
"marked-smartypants-lite": "^1.0.2",
|
||||
"markedLegacy": "npm:marked@^0.3.19",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.8.4",
|
||||
"mongoose": "^8.9.2",
|
||||
"nanoid": "5.0.9",
|
||||
"nconf": "^0.12.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-frame-component": "^4.1.3",
|
||||
"react-router-dom": "6.28.0",
|
||||
"react-router": "^7.0.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"superagent": "^10.1.1",
|
||||
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
|
||||
@@ -125,15 +129,15 @@
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^3.1.1",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-jest": "^28.9.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-jest": "^28.10.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.13.0",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"jsdom-global": "^3.0.2",
|
||||
"postcss-less": "^6.0.0",
|
||||
"stylelint": "^16.11.0",
|
||||
"stylelint": "^16.12.0",
|
||||
"stylelint-config-recess-order": "^5.1.1",
|
||||
"stylelint-config-recommended": "^14.0.1",
|
||||
"supertest": "^7.0.0"
|
||||
|
||||
@@ -7,11 +7,12 @@ const { pack, watchFile, livereload } = vitreum;
|
||||
import lessTransform from 'vitreum/transforms/less.js';
|
||||
import assetTransform from 'vitreum/transforms/asset.js';
|
||||
import babel from '@babel/core';
|
||||
import babelConfig from '../babel.config.json' with { type : 'json' };
|
||||
import less from 'less';
|
||||
|
||||
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 = {
|
||||
'.js' : (code, filename, opts)=>babelify(code),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {model as HomebrewModel } from './homebrew.model.js';
|
||||
import {model as NotificationModel } from './notifications.model.js';
|
||||
import { model as HomebrewModel } from './homebrew.model.js';
|
||||
import { model as NotificationModel } from './notifications.model.js';
|
||||
import express from 'express';
|
||||
import Moment from 'moment';
|
||||
import zlib from 'zlib';
|
||||
@@ -108,6 +108,9 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Set working directory to project root
|
||||
import { dirname } from 'path';
|
||||
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));
|
||||
process.chdir(`${__dirname}/..`);
|
||||
@@ -26,7 +26,7 @@ import serveCompressedStaticAssets from './static-assets.mv.js';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
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 { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
@@ -47,7 +47,7 @@ const sanitizeBrew = (brew, accessType)=>{
|
||||
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(contentNegotiation);
|
||||
@@ -55,6 +55,40 @@ app.use(bodyParser.json({ limit: '25mb' }));
|
||||
app.use(cookieParser());
|
||||
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
|
||||
app.use((req, res, next)=>{
|
||||
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'));
|
||||
//console.log("Just loaded up JWT from cookie:");
|
||||
//console.log(req.account);
|
||||
} catch (e){}
|
||||
} catch (e){
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
req.config = {
|
||||
@@ -273,7 +309,7 @@ app.get('/user/:username', async (req, res, next)=>{
|
||||
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){
|
||||
const auth = await GoogleActions.authCheck(req.account, res);
|
||||
@@ -312,6 +348,34 @@ app.get('/user/:username', async (req, res, 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
|
||||
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
|
||||
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)=>{
|
||||
const data = {};
|
||||
data.title = 'Account Information Page';
|
||||
|
||||
|
||||
if(!req.account) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
|
||||
const error = new Error('No valid account');
|
||||
@@ -413,7 +477,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
let googleCount = [];
|
||||
if(req.account) {
|
||||
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)
|
||||
.catch((err)=>{
|
||||
@@ -448,8 +512,6 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
return next();
|
||||
}));
|
||||
|
||||
const nodeEnv = config.get('node_env');
|
||||
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
|
||||
// Local only
|
||||
if(isLocalEnvironment){
|
||||
// Login
|
||||
@@ -477,8 +539,8 @@ app.get('/vault', asyncHandler(async(req, res, next)=>{
|
||||
|
||||
//Send rendered page
|
||||
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);
|
||||
if(!page) return;
|
||||
res.send(page);
|
||||
|
||||
@@ -106,12 +106,12 @@ const api = {
|
||||
stub = stub?.toObject();
|
||||
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 isInvited = stub?.invitedAuthors?.includes(req.account?.username);
|
||||
|
||||
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)
|
||||
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' };
|
||||
else
|
||||
@@ -119,13 +119,13 @@ const api = {
|
||||
}
|
||||
|
||||
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(!stubOnly && googleId) {
|
||||
const oAuth2Client = isOwner? GoogleActions.authCheck(req.account, res) : undefined;
|
||||
|
||||
// If there's a google id, get it if requesting the full brew or if no stub found yet
|
||||
if(googleId && (!stubOnly || !stub)) {
|
||||
const oAuth2Client = isOwner ? GoogleActions.authCheck(req.account, res) : undefined;
|
||||
|
||||
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType)
|
||||
.catch((googleError)=>{
|
||||
const reason = googleError.errors?.[0].reason;
|
||||
@@ -467,12 +467,11 @@ const api = {
|
||||
}
|
||||
};
|
||||
|
||||
router.use('/api', checkClientVersion);
|
||||
router.post('/api', asyncHandler(api.newBrew));
|
||||
router.put('/api/:id', 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', asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
|
||||
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.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;
|
||||
@@ -1,10 +1,10 @@
|
||||
import packageJSON from '../../package.json' with { type: "json" };
|
||||
const version = packageJSON.version;
|
||||
import packageJSON from '../../package.json' with { type: 'json' };
|
||||
|
||||
export default (req, res, next)=>{
|
||||
const userVersion = req.get('Homebrewery-Version');
|
||||
const version = packageJSON.version;
|
||||
|
||||
if(userVersion != version) {
|
||||
if(userVersion !== version) {
|
||||
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}.`
|
||||
});
|
||||
@@ -12,3 +12,4 @@ export default (req, res, next)=>{
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default (req, res, next)=>{
|
||||
const isImageRequest = req.get('Accept')?.split(',')
|
||||
?.filter((h)=>!h.includes('q='))
|
||||
?.every((h)=>/image\/.*/.test(h));
|
||||
if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) {
|
||||
if(isImageRequest && !(isLocalEnvironment && req.url?.startsWith('/staticImages'))) {
|
||||
return res.status(406).send({
|
||||
message : 'Request for image at this URL is not supported'
|
||||
});
|
||||
|
||||
41
server/middleware/content-negotiation.spec.js
Normal file
41
server/middleware/content-negotiation.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -11,49 +11,54 @@
|
||||
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
@keyframes sourceMoveAnimation {
|
||||
50% {background-color: red; color: white;}
|
||||
100% {background-color: unset; color: unset;}
|
||||
50% { color : white;background-color : red;}
|
||||
100% { color : unset;background-color : unset;}
|
||||
}
|
||||
|
||||
.codeEditor{
|
||||
@media screen and (pointer : coarse) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
font-family: inherit;
|
||||
text-shadow: none;
|
||||
font-weight: 600;
|
||||
color: grey;
|
||||
}
|
||||
.codeEditor {
|
||||
@media screen and (pointer : coarse) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
font-family : inherit;
|
||||
font-weight : 600;
|
||||
color : grey;
|
||||
text-shadow : none;
|
||||
}
|
||||
|
||||
.sourceMoveFlash .CodeMirror-line{
|
||||
animation-name: sourceMoveAnimation;
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
.CodeMirror-foldgutter {
|
||||
cursor : pointer;
|
||||
border-left : 1px solid #EEEEEE;
|
||||
transition : background 0.1s;
|
||||
&:hover { background : #DDDDDD; }
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 20px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width: 20px;
|
||||
background: linear-gradient(90deg, #858585 15px, #808080 15px);
|
||||
}
|
||||
}
|
||||
.sourceMoveFlash .CodeMirror-line {
|
||||
animation-name : sourceMoveAnimation;
|
||||
animation-duration : 0.4s;
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar {
|
||||
&::-webkit-scrollbar { width : 20px; }
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width : 20px;
|
||||
background : linear-gradient(90deg, #858585 15px, #808080 15px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
//}
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
.emojiPreview {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.2em;
|
||||
font-size : 1.5em;
|
||||
line-height : 1.2em;
|
||||
}
|
||||
@@ -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 ` `.repeat(token.length).concat('');
|
||||
}
|
||||
};
|
||||
|
||||
const definitionListsSingleLine = {
|
||||
name : 'definitionListsSingleLine',
|
||||
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) {
|
||||
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
|
||||
let match;
|
||||
@@ -748,11 +769,12 @@ const tableTerminators = [
|
||||
];
|
||||
|
||||
Marked.use(MarkedVariables());
|
||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
|
||||
mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
|
||||
nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||
Marked.use(mustacheInjectBlock);
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -43,5 +43,6 @@ html,body, #reactRoot{
|
||||
}
|
||||
&:disabled{
|
||||
background-color : @silver !important;
|
||||
cursor:not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
72
tests/markdown/non-breaking-spaces.test.js
Normal file
72
tests/markdown/non-breaking-spaces.test.js
Normal 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> </p>`);
|
||||
});
|
||||
|
||||
test('Double Space', function() {
|
||||
const source = ':>>\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p> </p>`);
|
||||
});
|
||||
|
||||
test('Triple Space', function() {
|
||||
const source = ':>>>\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p> </p>`);
|
||||
});
|
||||
|
||||
test('Many Space', function() {
|
||||
const source = ':>>>>>>>>>>\n\n';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p> </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> \n \n </p>`);
|
||||
});
|
||||
|
||||
test('Pair of inline Spaces', function() {
|
||||
const source = ':>>:>>';
|
||||
const rendered = Markdown.render(source).trim();
|
||||
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p> </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 \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:>\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>`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user