0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2025-12-28 22:12:39 +00:00

Merge branch 'master' into addLockRoutes-#3326

This commit is contained in:
G.Ambatte
2025-03-27 17:43:21 +13:00
committed by GitHub
166 changed files with 8599 additions and 7186 deletions

View File

@@ -1,10 +1,17 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
const HomebrewModel = require('./homebrew.model.js').model;
const router = require('express').Router();
const Moment = require('moment');
//const render = require('vitreum/steps/render');
const templateFn = require('../client/template.js');
const zlib = require('zlib');
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';
import templateFn from '../client/template.js';
import HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
@@ -23,7 +30,7 @@ const mw = {
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
return next();
}
return res.status(401).send('Access denied');
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
}
};
@@ -67,23 +74,8 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
});
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
HomebrewModel.findOne({
$or : [
{ editId: { $regex: req.params.id, $options: 'i' } },
{ shareId: { $regex: req.params.id, $options: 'i' } },
]
}).exec()
.then((brew)=>{
if(!brew) // No document found
return res.status(404).json({ error: 'Document not found' });
else
return res.json(brew);
})
.catch((err)=>{
console.error(err);
return res.status(500).json({ error: 'Internal Server Error' });
});
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew);
});
/* Find 50 brews that aren't compressed yet */
@@ -101,6 +93,40 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
});
});
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
const brew = req.brew;
const properties = ['text', 'description', 'title'];
properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
});
splitTextStyleAndMetadata(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);
});
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`);
const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews);
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
@@ -123,7 +149,6 @@ router.put('/admin/compress/:id', (req, res)=>{
});
});
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try {
const totalBrewsCount = await HomebrewModel.countDocuments({});
@@ -280,6 +305,37 @@ router.put('/api/lock/review/remove/:id', mw.adminOnly, async (req, res)=>{
} catch (error) {
console.error(error);
return res.json({ status: 'ERROR', detail: `Unable to remove request for review on brew ID ${req.params.id}`, error });
// ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{
try {
const notifications = await NotificationModel.getAll();
return res.json(notifications);
} catch (error) {
console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
} catch (error) {
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
} catch (error) {
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
@@ -288,7 +344,10 @@ router.get('/admin', mw.adminOnly, (req, res)=>{
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>res.sendStatus(500));
.catch((err)=>{
console.log(err);
res.sendStatus(500);
});
});
module.exports = router;
export default router;

117
server/admin.api.spec.js Normal file
View File

@@ -0,0 +1,117 @@
import supertest from 'supertest';
import HBApp from './app.js';
import { model as NotificationModel } from './notifications.model.js';
// Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
describe('Tests for admin api', ()=>{
afterEach(()=>{
jest.resetAllMocks();
});
describe('Notifications', ()=>{
it('should return list of all notifications', async ()=>{
const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find')
.mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue(testNotifications) };
});
const response = await app
.get('/admin/notification/all')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual(testNotifications);
});
it('should add a new notification', async ()=>{
const inputNotification = {
title : 'Test Notification',
text : 'This is a test notification',
startAt : new Date().toISOString(),
stopAt : new Date().toISOString(),
dismissKey : 'testKey'
};
const savedNotification = {
...inputNotification,
_id : expect.any(String),
createdAt : expect.any(String),
startAt : inputNotification.startAt,
stopAt : inputNotification.stopAt,
};
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
return Promise.resolve(this);
});
const response = await app
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
expect(response.status).toBe(201);
expect(response.body).toEqual(savedNotification);
});
it('should handle error adding a notification without dismissKey', async ()=>{
const inputNotification = {
title : 'Test Notification',
text : 'This is a test notification',
startAt : new Date().toISOString(),
stopAt : new Date().toISOString()
};
//Change 'save' function to just return itself instead of actually interacting with the database
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
return Promise.resolve(this);
});
const response = await app
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Dismiss key is required!' });
});
it('should delete a notification based on its dismiss key', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce((key)=>{
return { exec: jest.fn().mockResolvedValue(key) };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' });
});
it('should handle error deleting a notification that doesnt exist', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue() };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' });
});
});
});

View File

@@ -1,24 +1,40 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
// Set working directory to project root
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import packageJSON from './../package.json' with { type: 'json' };
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`);
const version = packageJSON.version;
import _ from 'lodash';
import jwt from 'jwt-simple';
import express from 'express';
import config from './config.js';
import fs from 'fs-extra';
const _ = require('lodash');
const jwt = require('jwt-simple');
const express = require('express');
const yaml = require('js-yaml');
const app = express();
const config = require('./config.js');
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
const asyncHandler = require('express-async-handler');
const templateFn = require('./../client/template.js');
import api from './homebrew.api.js';
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api;
import adminApi from './admin.api.js';
import vaultApi from './vault.api.js';
import GoogleActions from './googleActions.js';
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';
const { DEFAULT_BREW } = require('./brewDefaults.js');
import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
//==== Middleware Imports ====//
import contentNegotiation from './middleware/content-negotiation.js';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
const sanitizeBrew = (brew, accessType)=>{
@@ -30,11 +46,45 @@ const sanitizeBrew = (brew, accessType)=>{
return brew;
};
app.set('trust proxy', 1 /* number of proxies between user and server */);
app.use('/', serveCompressedStaticAssets(`build`));
app.use(require('./middleware/content-negotiation.js'));
app.use(require('body-parser').json({ limit: '25mb' }));
app.use(require('cookie-parser')());
app.use(require('./forcessl.mw.js'));
app.use(contentNegotiation);
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',
];
const localNetworkRegex = /^http:\/\/(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+):\d+$/;
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) || (isLocalEnvironment && localNetworkRegex.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)=>{
@@ -43,7 +93,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 = {
@@ -54,15 +106,14 @@ app.use((req, res, next)=>{
});
app.use(homebrewApi);
app.use(require('./admin.api.js'));
app.use(require('./vault.api.js'));
app.use(adminApi);
app.use(vaultApi);
const HomebrewModel = require('./homebrew.model.js').model;
const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
const changelogText = require('fs').readFileSync('changelog.md', 'utf8');
const faqText = require('fs').readFileSync('faq.md', 'utf8');
const welcomeText = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = fs.readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
const changelogText = fs.readFileSync('changelog.md', 'utf8');
const faqText = fs.readFileSync('faq.md', 'utf8');
String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
@@ -255,6 +306,8 @@ app.get('/user/:username', async (req, res, next)=>{
console.log(err);
});
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);
let googleBrews = await GoogleActions.listGoogleBrews(auth)
@@ -262,12 +315,12 @@ app.get('/user/:username', async (req, res, next)=>{
console.error(err);
});
// If stub matches file from Google, use Google metadata over stub metadata
if(googleBrews && googleBrews.length > 0) {
for (const brew of brews.filter((brew)=>brew.googleId)) {
const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
if(match !== -1) {
brew.googleId = googleBrews[match].googleId;
brew.stubbed = true;
brew.pageCount = googleBrews[match].pageCount;
brew.renderer = googleBrews[match].renderer;
brew.version = googleBrews[match].version;
@@ -276,6 +329,7 @@ app.get('/user/:username', async (req, res, next)=>{
}
}
//Remaining unstubbed google brews display current user as author
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
brews = _.concat(brews, googleBrews);
}
@@ -291,6 +345,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;
@@ -392,22 +474,12 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
let googleCount = [];
if(req.account) {
if(req.account.googleId) {
try {
auth = await GoogleActions.authCheck(req.account, res, false);
} catch (e) {
auth = undefined;
console.log('Google auth check failed!');
console.log(e);
}
if(auth.credentials.access_token) {
try {
googleCount = await GoogleActions.listGoogleBrews(auth);
} catch (e) {
googleCount = undefined;
console.log('List Google files failed!');
console.log(e);
}
}
auth = await GoogleActions.authCheck(req.account, res, false);
googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{
console.error(err);
});
}
const query = { authors: req.account.username, googleId: { $exists: false } };
@@ -421,7 +493,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
username : req.account.username,
issued : req.account.issued,
googleId : Boolean(req.account.googleId),
authCheck : Boolean(req.account.googleId && auth.credentials.access_token),
authCheck : Boolean(req.account.googleId && auth?.credentials.access_token),
mongoCount : mongoCount,
googleCount : googleCount?.length
};
@@ -437,8 +509,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
@@ -451,6 +521,10 @@ if(isLocalEnvironment){
});
}
// Add Static Local Paths
app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages'));
app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts'));
//Vault Page
app.get('/vault', asyncHandler(async(req, res, next)=>{
req.ogMeta = { ...defaultMetaTags,
@@ -475,11 +549,12 @@ const renderPage = async (req, res)=>{
const configuration = {
local : isLocalEnvironment,
publicUrl : config.get('publicUrl') ?? '',
baseUrl : `${req.protocol}://${req.get('host')}`,
environment : nodeEnv,
history : config.get('historyConfig') ?? {}
deployment : config.get('heroku_app_name') ?? ''
};
const props = {
version : require('./../package.json').version,
version : version,
url : req.customUrl || req.originalUrl,
brew : req.brew,
brews : req.brews,
@@ -520,7 +595,7 @@ app.use(async (err, req, res, next)=>{
err.originalUrl = req.originalUrl;
console.error(err);
if(err.originalUrl?.startsWith('/api/')) {
if(err.originalUrl?.startsWith('/api')) {
// console.log('API error');
res.status(err.status || err.response?.status || 500).send(err);
return;
@@ -556,6 +631,4 @@ app.use((req, res)=>{
});
//^=====--------------------------------------=====^//
module.exports = {
app : app
};
export default app;

View File

@@ -1,4 +1,4 @@
const _ = require('lodash');
import _ from 'lodash';
// Default properties for newly-created brews
const DEFAULT_BREW = {
@@ -32,7 +32,7 @@ const DEFAULT_BREW_LOAD = _.defaults(
},
DEFAULT_BREW);
module.exports = {
export {
DEFAULT_BREW,
DEFAULT_BREW_LOAD
};

View File

@@ -1,5 +1,7 @@
module.exports = require('nconf')
.argv()
.env({ lowerCase: true })
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
.file('defaults', { file: 'config/default.json' });
import nconf from 'nconf';
export default nconf
.argv()
.env({ lowerCase: true })
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
.file('defaults', { file: 'config/default.json' });

View File

@@ -5,7 +5,7 @@
// reused by both the main application and all tests which require database
// connection.
const Mongoose = require('mongoose');
import Mongoose from 'mongoose';
const getMongoDBURL = (config)=>{
return config.get('mongodb_uri') ||
@@ -31,7 +31,7 @@ const connect = async (config)=>{
.catch((error)=>handleConnectionError(error));
};
module.exports = {
connect : connect,
disconnect : disconnect
export default {
connect,
disconnect
};

View File

@@ -1,4 +1,4 @@
module.exports = (req, res, next)=>{
export default (req, res, next)=>{
if(process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'docker') return next();
if(req.header('x-forwarded-proto') !== 'https') {
return res.redirect(302, `https://${req.get('Host')}${req.url}`);

View File

@@ -1,8 +1,9 @@
/* eslint-disable max-lines */
const googleDrive = require('@googleapis/drive');
const { nanoid } = require('nanoid');
const token = require('./token.js');
const config = require('./config.js');
import googleDrive from '@googleapis/drive';
import { nanoid } from 'nanoid';
import token from './token.js';
import config from './config.js';
let serviceAuth;
if(!config.get('service_account')){
@@ -25,6 +26,15 @@ if(!config.get('service_account')){
const defaultAuth = serviceAuth || config.get('google_api_key');
const retryConfig = {
retry : 3, // Number of retry attempts
retryDelay : 100, // Initial delay in milliseconds
retryDelayMultiplier : 2, // Multiplier for exponential backoff
maxRetryDelay : 32000, // Maximum delay in milliseconds
httpMethodsToRetry : ['PATCH'], // Only retry PATCH requests
statusCodesToRetry : [[429, 429]], // Only retry on 429 status code
};
const GoogleActions = {
authCheck : (account, res, updateTokens=true)=>{
@@ -50,7 +60,7 @@ const GoogleActions = {
account.googleRefreshToken = tokens.refresh_token;
}
account.googleAccessToken = tokens.access_token;
const JWTToken = token.generateAccessToken(account);
const JWTToken = token(account);
//Save updated token to cookie
//res.cookie('nc_session', JWTToken, { maxAge: 1000*60*60*24*365, path: '/', sameSite: 'lax' });
@@ -63,7 +73,7 @@ const GoogleActions = {
getGoogleFolder : async (auth)=>{
const drive = googleDrive.drive({ version: 'v3', auth });
fileMetadata = {
const fileMetadata = {
'name' : 'Homebrewery',
'mimeType' : 'application/vnd.google-apps.folder'
};
@@ -112,9 +122,7 @@ const GoogleActions = {
})
.catch((err)=>{
console.log(`Error Listing Google Brews`);
console.error(err);
throw (err);
//TODO: Should break out here, but continues on for some reason.
});
fileList.push(...obj.data.files);
NextPageToken = obj.data.nextPageToken;
@@ -147,7 +155,7 @@ const GoogleActions = {
return brews;
},
updateGoogleBrew : async (brew)=>{
updateGoogleBrew : async (brew, userIp)=>{
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
await drive.files.update({
@@ -168,11 +176,14 @@ const GoogleActions = {
media : {
mimeType : 'text/plain',
body : brew.text
}
},
headers : {
'X-Forwarded-For' : userIp, // Set the X-Forwarded-For header
},
retryConfig
})
.catch((err)=>{
console.log('Error saving to google');
console.error(err);
throw (err);
});
@@ -211,7 +222,6 @@ const GoogleActions = {
})
.catch((err)=>{
console.log('Error while creating new Google brew');
console.error(err);
throw (err);
});
@@ -231,8 +241,8 @@ const GoogleActions = {
return obj.data.id;
},
getGoogleBrew : async (id, accessId, accessType)=>{
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
getGoogleBrew : async (auth = defaultAuth, id, accessId, accessType)=>{
const drive = googleDrive.drive({ version: 'v3', auth: auth });
const obj = await drive.files.get({
fileId : id,
@@ -335,4 +345,4 @@ const GoogleActions = {
}
};
module.exports = GoogleActions;
export default GoogleActions;

View File

@@ -1,18 +1,20 @@
/* eslint-disable max-lines */
const _ = require('lodash');
const HomebrewModel = require('./homebrew.model.js').model;
const router = require('express').Router();
const zlib = require('zlib');
const GoogleActions = require('./googleActions.js');
const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
import _ from 'lodash';
import { model as HomebrewModel } from './homebrew.model.js';
import express from 'express';
import zlib from 'zlib';
import GoogleActions from './googleActions.js';
import Markdown from '../shared/naturalcrit/markdown.js';
import yaml from 'js-yaml';
import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js';
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
const router = express.Router();
const Themes = require('../themes/themes.json');
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js';
import Themes from '../themes/themes.json' with { type: 'json' };
const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined;
@@ -85,66 +87,68 @@ const api = {
// Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{
// Get relevant IDs for the brew
const { id, googleId } = api.getId(req);
let { id, googleId } = api.getId(req);
const accessMap = {
edit : { editId: id },
share : { shareId: id },
admin : { $or: [{ editId: id }, { shareId: id }] }
};
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
let stub = await HomebrewModel.get(accessMap[accessType])
.catch((err)=>{
if(googleId) {
if(googleId)
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
} else {
else
console.warn(err);
}
});
stub = stub?.toObject();
googleId ??= stub?.googleId;
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 };
if(req.account)
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' };
else
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04' };
}
if(stub?.lock && accessType != 'edit') {
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 || stub?.googleId)) {
let googleError;
const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType)
.catch((err)=>{
googleError = err;
// 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;
if(reason == 'notFound')
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
else
throw { ...googleError, HBErrorCode: '01' };
});
// Throw any error caught while attempting to retrieve Google brew.
if(googleError) {
const reason = googleError.errors?.[0].reason;
if(reason == 'notFound') {
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
} else {
throw { ...googleError, HBErrorCode: '01' };
}
}
// Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew
stub = stub ? _.assign({ ...api.excludeStubProps(stub), stubbed: true }, api.excludeGoogleProps(googleBrew)) : googleBrew;
}
const authorsExist = stub?.authors?.length > 0;
const isAuthor = stub?.authors?.includes(req.account?.username);
const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
if(accessType === 'edit' && (authorsExist && !(isAuthor || isInvited))) {
const accessError = { name: 'Access Error', status: 401 };
if(req.account){
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
}
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
}
// If after all of that we still don't have a brew, throw an exception
if(!stub && !stubOnly) {
if(!stub)
throw { name: 'BrewLoad Error', message: 'Brew not found', status: 404, HBErrorCode: '05', accessType: accessType, brewId: id };
}
// Clean up brew: fill in missing fields with defaults / fix old invalid values
if(stub) {
stub.tags = stub.tags || undefined; // Clear empty strings
stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
}
stub.tags = stub.tags || undefined; // Clear empty strings
stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = stub ?? {};
req.brew = stub;
next();
};
},
@@ -177,6 +181,7 @@ const api = {
`${text}`;
return text;
},
getGoodBrewTitle : (text)=>{
const tokens = Markdown.marked.lexer(text);
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
@@ -242,11 +247,8 @@ const api = {
let googleId, saved;
if(saveToGoogle) {
googleId = await api.newGoogleBrew(req.account, newHomebrew, res)
.catch((err)=>{
console.error(err);
res.status(err?.status || err?.response?.status || 500).send(err?.message || err);
});
googleId = await api.newGoogleBrew(req.account, newHomebrew, res);
if(!googleId) return;
api.excludeStubProps(newHomebrew);
newHomebrew.googleId = googleId;
@@ -278,6 +280,8 @@ const api = {
let currentTheme;
const completeStyles = [];
const completeSnippets = [];
let themeName;
let themeAuthor;
while (req.params.id) {
//=== User Themes ===//
@@ -291,6 +295,10 @@ const api = {
currentTheme = req.brew;
splitTextStyleAndMetadata(currentTheme);
if(!currentTheme.tags.some((tag)=>tag === 'meta:theme' || tag === 'meta:Theme'))
throw { brewId: req.params.id, name: 'Invalid Theme Selected', message: 'Selected theme does not have the meta:theme tag', status: 422, HBErrorCode: '10' };
themeName ??= currentTheme.title;
themeAuthor ??= currentTheme.authors?.[0];
// If there is anything in the snippets or style members, append them to the appropriate array
if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
@@ -298,9 +306,9 @@ const api = {
req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer;
}
} else {
//=== Static Themes ===//
else {
themeName ??= req.params.id;
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets);
@@ -313,7 +321,9 @@ const api = {
const returnObj = {
// Reverse the order of the arrays so they are listed oldest parent to youngest child.
styles : completeStyles.reverse(),
snippets : completeSnippets.reverse()
snippets : completeSnippets.reverse(),
name : themeName,
author : themeAuthor
};
res.setHeader('Content-Type', 'application/json');
@@ -351,19 +361,13 @@ const api = {
brew.googleId = undefined;
} else if(!brew.googleId && saveToGoogle) {
// If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res)
.catch((err)=>{
console.error(err);
res.status(err.status || err.response.status).send(err.message || err);
});
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res);
if(!brew.googleId) return;
} else if(brew.googleId) {
// If the google id exists and no other actions are being performed, update the google brew
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew))
.catch((err)=>{
console.error(err);
res.status(err?.response?.status || 500).send(err);
});
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
if(!updated) return;
}
@@ -473,12 +477,11 @@ const api = {
}
};
router.use('/api', require('./middleware/check-client-version.js'));
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));
module.exports = api;
export default api;

View File

@@ -1,5 +1,7 @@
/* eslint-disable max-lines */
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
describe('Tests for api', ()=>{
let api;
let google;
@@ -36,8 +38,9 @@ describe('Tests for api', ()=>{
}
});
google = require('./googleActions.js');
model = require('./homebrew.model.js').model;
google = require('./googleActions.js').default;
model = require('./homebrew.model.js').model;
api = require('./homebrew.api').default;
jest.mock('./googleActions.js');
google.authCheck = jest.fn(()=>'client');
@@ -54,8 +57,6 @@ describe('Tests for api', ()=>{
setHeader : jest.fn(()=>{})
};
api = require('./homebrew.api');
hbBrew = {
text : `brew text`,
style : 'hello yes i am css',
@@ -297,7 +298,7 @@ describe('Tests for api', ()=>{
expect(next).toHaveBeenCalled();
expect(api.getId).toHaveBeenCalledWith(req);
expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
expect(google.getGoogleBrew).toHaveBeenCalledWith(undefined, '2', '1', 'share');
});
it('access is denied to a locked brew', async()=>{
@@ -560,16 +561,6 @@ brew`);
views : 0
});
});
it('should handle google error', async()=>{
google.newGoogleBrew = jest.fn(()=>{
throw 'err';
});
await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.send).toHaveBeenCalledWith('err');
});
});
describe('deleteGoogleBrew', ()=>{
@@ -585,7 +576,7 @@ brew`);
describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -596,6 +587,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'],
snippets : []
});
@@ -603,9 +596,9 @@ brew`);
it('should return Theme Bundle for nested User Themes', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style' }
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style', tags: ['meta:theme'], authors: ['authorName'] }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -616,6 +609,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : [
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
@@ -632,6 +627,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
name : '5ePHB',
author : undefined,
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`
@@ -645,9 +642,9 @@ brew`);
it('should return Theme Bundle for nested User and Static Themes together', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style' }
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style', tags: ['meta:theme'], authors: ['authorName'] }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -658,6 +655,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`,
@@ -674,9 +673,9 @@ brew`);
});
});
it('should fail for an invalid Theme in the chain', async()=>{
it('should fail for a missing Theme in the chain', async()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style' },
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -695,6 +694,27 @@ brew`);
name : 'ThemeLoad Error',
status : 404 });
});
it('should fail for a User Theme not tagged with meta:theme', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
let err;
await api.getThemeBundle(req, res)
.catch((e)=>err = e);
expect(err).toEqual({
HBErrorCode : '10',
brewId : 'userThemeAID',
message : 'Selected theme does not have the meta:theme tag',
name : 'Invalid Theme Selected',
status : 422 });
});
});
describe('deleteBrew', ()=>{
@@ -979,4 +999,57 @@ brew`);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('Split Text, Style, and Metadata', ()=>{
it('basic splitting', async ()=>{
const testBrew = {
text : '```metadata\n' +
'title: title\n' +
'description: description\n' +
'tags: [ \'tag a\' , \'tag b\' ]\n' +
'systems: [ test system ]\n' +
'renderer: legacy\n' +
'theme: 5ePHB\n' +
'lang: en\n' +
'\n' +
'```\n' +
'\n' +
'```css\n' +
'style\n' +
'style\n' +
'style\n' +
'```\n' +
'\n' +
'text\n'
};
splitTextStyleAndMetadata(testBrew);
// Metadata
expect(testBrew.title).toEqual('title');
expect(testBrew.description).toEqual('description');
expect(testBrew.tags).toEqual(['tag a', 'tag b']);
expect(testBrew.systems).toEqual(['test system']);
expect(testBrew.renderer).toEqual('legacy');
expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en');
// Style
expect(testBrew.style).toEqual('style\nstyle\nstyle');
// Text
expect(testBrew.text).toEqual('text\n');
});
it('convert tags string to array', async ()=>{
const testBrew = {
text : '```metadata\n' +
'tags: tag a\n' +
'```\n\n'
};
splitTextStyleAndMetadata(testBrew);
// Metadata
expect(testBrew.tags).toEqual(['tag a']);
});
});
});

View File

@@ -1,7 +1,8 @@
const mongoose = require('mongoose');
const { nanoid } = require('nanoid');
const _ = require('lodash');
const zlib = require('zlib');
import mongoose from 'mongoose';
import { nanoid } from 'nanoid';
import _ from 'lodash';
import zlib from 'zlib';
const HomebrewSchema = mongoose.Schema({
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
@@ -46,7 +47,7 @@ HomebrewSchema.statics.get = async function(query, fields=null){
const brew = await Homebrew.findOne(query, fields).orFail()
.catch((error)=>{throw 'Can not find brew';});
if(!_.isNil(brew.textBin)) { // Uncompress zipped text field
unzipped = zlib.inflateRawSync(brew.textBin);
const unzipped = zlib.inflateRawSync(brew.textBin);
brew.text = unzipped.toString();
}
return brew;
@@ -64,7 +65,7 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
module.exports = {
schema : HomebrewSchema,
model : Homebrew,
export {
HomebrewSchema as schema,
Homebrew as model
};

View File

@@ -1,8 +1,10 @@
module.exports = (req, res, next)=>{
const userVersion = req.get('Homebrewery-Version');
const version = require('../../package.json').version;
import packageJSON from '../../package.json' with { type: 'json' };
if(userVersion != version) {
export default (req, res, next)=>{
const userVersion = req.get('Homebrewery-Version');
const version = packageJSON.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}.`
});
@@ -10,3 +12,4 @@ module.exports = (req, res, next)=>{
next();
};

View File

@@ -1,12 +1,16 @@
module.exports = (req, res, next)=>{
import config from '../config.js';
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
export default (req, res, next)=>{
const isImageRequest = req.get('Accept')?.split(',')
?.filter((h)=>!h.includes('q='))
?.every((h)=>/image\/.*/.test(h));
if(isImageRequest) {
if(isImageRequest && !(isLocalEnvironment && req.url?.startsWith('/staticImages'))) {
return res.status(406).send({
message : 'Request for image at this URL is not supported'
});
}
next();
};
};

View File

@@ -1,41 +1,41 @@
const contentNegotiationMiddleware = require('./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();
});
});
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

@@ -0,0 +1,62 @@
import mongoose from 'mongoose';
import _ from 'lodash';
const NotificationSchema = new mongoose.Schema({
dismissKey : { type: String, unique: true, required: true },
title : { type: String, default: '' },
text : { type: String, default: '' },
createdAt : { type: Date, default: Date.now },
startAt : { type: Date, default: Date.now },
stopAt : { type: Date, default: Date.now },
}, { versionKey: false });
NotificationSchema.statics.addNotification = async function(data) {
if(!data.dismissKey) throw { message: 'Dismiss key is required!' };
const defaults = {
title : '',
text : '',
startAt : new Date(),
stopAt : new Date(),
};
const notificationData = _.defaults(data, defaults);
try {
const newNotification = new this(notificationData);
const savedNotification = await newNotification.save();
return savedNotification;
} catch (err) {
throw { message: err.message || 'Error saving notification' };
}
};
NotificationSchema.statics.deleteNotification = async function(dismissKey) {
if(!dismissKey) throw { message: 'Dismiss key is required!' };
try {
const deletedNotification = await this.findOneAndDelete({ dismissKey }).exec();
if(!deletedNotification) {
throw { message: 'Notification not found' };
}
return deletedNotification;
} catch (err) {
throw { message: err.message || 'Error deleting notification' };
}
};
NotificationSchema.statics.getAll = async function() {
try {
const notifications = await this.find().exec();
return notifications;
} catch (err) {
throw { message: err.message || 'Error retrieving notifications' };
}
};
const Notification = mongoose.model('Notification', NotificationSchema);
export {
NotificationSchema as schema,
Notification as model
};

View File

@@ -1,4 +1,4 @@
const expressStaticGzip = require('express-static-gzip');
import expressStaticGzip from 'express-static-gzip';
// Serve brotli-compressed static files if available
const customCacheControlHandler=(response, path)=>{
@@ -28,4 +28,4 @@ const init=(pathToAssets)=>{
} });
};
module.exports = init;
export default init;

View File

@@ -1,7 +1,5 @@
const jwt = require('jwt-simple');
// Load configuration values
const config = require('./config.js');
import jwt from 'jwt-simple';
import config from './config.js';
// Generate an Access Token for the given User ID
const generateAccessToken = (account)=>{
@@ -24,6 +22,4 @@ const generateAccessToken = (account)=>{
return token;
};
module.exports = {
generateAccessToken : generateAccessToken
};
export default generateAccessToken;

View File

@@ -1,6 +1,6 @@
const express = require('express');
const asyncHandler = require('express-async-handler');
const HomebrewModel = require('./homebrew.model.js').model;
import express from 'express';
import asyncHandler from 'express-async-handler';
import { model as HomebrewModel } from './homebrew.model.js';
const router = express.Router();
@@ -29,7 +29,7 @@ const rendererConditions = (legacy, v3)=>{
return {}; // If all renderers selected, renderer field not needed in query for speed
};
const sortConditions = (sort, dir) => {
const sortConditions = (sort, dir)=>{
return { [sort]: dir === 'asc' ? 1 : -1 };
};
@@ -106,4 +106,4 @@ const findTotal = async (req, res)=>{
router.get('/api/vault/total', asyncHandler(findTotal));
router.get('/api/vault', asyncHandler(findBrews));
module.exports = router;
export default router;