From 20678ba4207c40dbce51dae813db29e580a77a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Losada=20Hern=C3=A1ndez?= Date: Fri, 30 Jan 2026 12:41:14 +0100 Subject: [PATCH] dev base (kinda stable) --- client/entry-client-admin.jsx | 12 + client/entry-client-homebrew.jsx | 13 + client/entry-server-admin.jsx | 2 +- client/template.js | 85 ++- package-lock.json | 6 +- package.json | 6 +- scripts/compileAssets.js | 90 +++ server.js | 57 +- server/app.js | 1047 +++++++++++++++--------------- vite.config.js | 1 + 10 files changed, 755 insertions(+), 564 deletions(-) create mode 100644 client/entry-client-admin.jsx create mode 100644 client/entry-client-homebrew.jsx create mode 100644 scripts/compileAssets.js diff --git a/client/entry-client-admin.jsx b/client/entry-client-admin.jsx new file mode 100644 index 000000000..958e8a007 --- /dev/null +++ b/client/entry-client-admin.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { hydrateRoot } from 'react-dom/client'; +import Admin from './admin.jsx'; + +import './admin/admin.less' + +window.start_app = (props) => { + hydrateRoot( + document.getElementById('reactRoot'), + + ) +} diff --git a/client/entry-client-homebrew.jsx b/client/entry-client-homebrew.jsx new file mode 100644 index 000000000..380b4107d --- /dev/null +++ b/client/entry-client-homebrew.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import Homebrew from './homebrew/homebrew.jsx' + +// CSS MUST be imported here +import './homebrew/homebrew.less' // or wherever your CSS lives + +window.start_app = (props) => { + hydrateRoot( + document.getElementById('reactRoot'), + + ) +} diff --git a/client/entry-server-admin.jsx b/client/entry-server-admin.jsx index 58b537aea..a3cb697ea 100644 --- a/client/entry-server-admin.jsx +++ b/client/entry-server-admin.jsx @@ -1,4 +1,4 @@ import { renderToString } from 'react-dom/server'; -import Admin from './admin.jsx'; +import Admin from './admin/admin.jsx'; export default (props) => renderToString(); \ No newline at end of file diff --git a/client/template.js b/client/template.js index c8601d7ee..62f13bf88 100644 --- a/client/template.js +++ b/client/template.js @@ -1,33 +1,66 @@ -const template = async function(name, title='', props = {}){ +import fs from "fs"; + +const isProd = process.env.NODE_ENV === "production"; + +const template = async function ({ vite, url }, name, title = "", props = {}) { const ogTags = []; const ogMeta = props.ogMeta ?? {}; - Object.entries(ogMeta).forEach(([key, value])=>{ - if(!value) return; - const tag = ``; - ogTags.push(tag); + + Object.entries(ogMeta).forEach(([key, value]) => { + if (!value) return; + ogTags.push(``); }); - const ogMetaTags = ogTags.join('\n'); - const ssrModule = await import(`../build/entry-server-${name}/bundle.js`); + const ogMetaTags = ogTags.join("\n"); - return ` - - - - - - - ${ogMetaTags} - - ${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'} - - -
${ssrModule.default(props)}
- - - - - `; + // ---------------- + // PROD + // ---------------- + if (isProd) { + const ssrModule = await import(`../build/entry-server-${name}/bundle.js`); + + return ` + + + + + + + ${ogMetaTags} + + ${title.length ? `${title} - The Homebrewery` : "The Homebrewery - NaturalCrit"} + + +
${ssrModule.default(props)}
+ + + +`; + } + + // ---------------- + // DEV + // ---------------- + const { default: render } = await vite.ssrLoadModule(`/client/entry-server-${name}.jsx`); + + let html = ` + + + + ${ogMetaTags} + ${title.length ? `${title} - The Homebrewery` : "The Homebrewery - NaturalCrit"} + + +
${render(props)}
+ + + + + + +`; + + return vite.transformIndexHtml(url, html); }; -export default template; \ No newline at end of file +export default template; diff --git a/package-lock.json b/package-lock.json index 7246983f3..57b2e5fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5268,9 +5268,9 @@ } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 067785d6c..0ea12fb9d 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "url": "git://github.com/naturalcrit/homebrewery.git" }, "scripts": { - "viteDev": "node scripts/dev.js", + "viteDev1": "node scripts/dev.js", + "viteDev2": "vite dev", "viteDevAdmin": "vite --config vite.config.js --ssr client/admin/admin.jsx", - "viteBuild": "vite build", + "viteBuild": "vite build && node scripts/compileAssets.js", "viteStart": "vite preview --outDir build", "start": "node server.js", + "compileAssets": "node scripts/compileAssets.js --dev", "dev": "node --experimental-require-module scripts/dev.js", "quick": "node --experimental-require-module scripts/quick.js", "build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js", diff --git a/scripts/compileAssets.js b/scripts/compileAssets.js new file mode 100644 index 000000000..19c2c3cac --- /dev/null +++ b/scripts/compileAssets.js @@ -0,0 +1,90 @@ +import fs from "fs-extra"; +import less from "less"; +const isDev = !!process.argv.find((arg) => arg === "--dev"); + +const compileAssets = async () => { + await fs.copy("./client/homebrew/favicon.ico", "./build/assets/favicon.ico"); + + //v==----------------------------- COMPILE THEMES --------------------------------==v// + + // Update list of all Theme files + const themes = { Legacy: {}, V3: {} }; + + let themeFiles = fs.readdirSync("./themes/Legacy"); + for (const dir of themeFiles) { + const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString()); + themeData.path = dir; + themes.Legacy[dir] = themeData; + //fs.copy(`./themes/Legacy/${dir}/dropdownTexture.png`, `./build/themes/Legacy/${dir}/dropdownTexture.png`); + const src = `./themes/Legacy/${dir}/style.less`; + ((outputDirectory) => { + less.render( + fs.readFileSync(src).toString(), + { + compress: !isDev, + }, + function (e, output) { + fs.outputFile(outputDirectory, output.css); + }, + ); + })(`./build/themes/Legacy/${dir}/style.css`); + } + + themeFiles = fs.readdirSync("./themes/V3"); + for (const dir of themeFiles) { + const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString()); + themeData.path = dir; + themes.V3[dir] = themeData; + fs.copy(`./themes/V3/${dir}/dropdownTexture.png`, `./build/themes/V3/${dir}/dropdownTexture.png`); + fs.copy(`./themes/V3/${dir}/dropdownPreview.png`, `./build/themes/V3/${dir}/dropdownPreview.png`); + const src = `./themes/V3/${dir}/style.less`; + ((outputDirectory) => { + less.render( + fs.readFileSync(src).toString(), + { + compress: !isDev, + }, + function (e, output) { + fs.outputFile(outputDirectory, output.css); + }, + ); + })(`./build/themes/V3/${dir}/style.css`); + } + + await fs.outputFile("./themes/themes.json", JSON.stringify(themes, null, 2)); + + // await less.render(lessCode, { + // compress : !dev, + // sourceMap : (dev ? { + // sourceMapFileInline: true, + // outputSourceFiles: true + // } : false), + // }) + + // Move assets + await fs.copy("./themes/fonts", "./build/fonts"); + await fs.copy("./themes/assets", "./build/assets"); + await fs.copy("./client/icons", "./build/icons"); + + //v==---------------------------MOVE CM EDITOR THEMES -----------------------------==v// + + const editorThemesBuildDir = "./build/homebrew/cm-themes"; + await fs.copy("./node_modules/codemirror/theme", editorThemesBuildDir); + await fs.copy("./themes/codeMirror/customThemes", editorThemesBuildDir); + const editorThemeFiles = fs.readdirSync(editorThemesBuildDir); + + const editorThemeFile = "./themes/codeMirror/editorThemes.json"; + if (fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile); + const stream = fs.createWriteStream(editorThemeFile, { flags: "a" }); + stream.write('[\n"default"'); + + for (const themeFile of editorThemeFiles) { + stream.write(`,\n"${themeFile.slice(0, -4)}"`); + } + stream.write("\n]\n"); + stream.end(); + + await fs.copy("./themes/codeMirror", "./build/homebrew/codeMirror"); +}; + +compileAssets(); diff --git a/server.js b/server.js index fe5a9a363..d2dea2de5 100644 --- a/server.js +++ b/server.js @@ -1,20 +1,47 @@ -import DB from './server/db.js'; -import server from './server/app.js'; -import config from './server/config.js'; +import DB from "./server/db.js"; +import createApp from "./server/app.js"; +import config from "./server/config.js"; +import { createServer as createViteServer } from "vite"; -DB.connect(config).then(()=>{ - // Ensure that we have successfully connected to the database - // before launching server - const PORT = process.env.PORT || config.get('web_port') || 8000; - server.listen(PORT, ()=>{ - const reset = '\x1b[0m'; // Reset to default style - const bright = '\x1b[1m'; // Bright (bold) style - const cyan = '\x1b[36m'; // Cyan color - const underline = '\x1b[4m'; // Underlined style +const isProd = process.env.NODE_ENV === "production"; + +async function start() { + let vite; + + //==== Create Vite dev server only in development ====// + if (!isProd) { + vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "custom", + logLevel: 'error', + }); + + } + + //==== Connect to the database ====// + await DB.connect(config).catch((err) => { + console.error("Database connection failed:", err); + process.exit(1); + }); + + //==== Create the Express app ====// + const app = await createApp(vite); + + //==== Start listening ====// + const PORT = process.env.PORT || config.get("web_port") || 8000; + app.listen(PORT, () => { + const reset = "\x1b[0m"; // Reset to default style + const bright = "\x1b[1m"; // Bright (bold) style + const cyan = "\x1b[36m"; // Cyan color + const underline = "\x1b[4m"; // Underlined style console.log(`\n\tserver started at: ${new Date().toLocaleString()}`); console.log(`\tserver on port: ${PORT}`); - console.log(`\t${bright + cyan}Open in browser: ${reset}${underline + bright + cyan}http://localhost:${PORT}${reset}\n\n`); - + console.log( + `\t${bright + cyan}Open in browser: ${reset}${underline + bright + cyan}http://localhost:${PORT}${reset}\n\n`, + ); }); -}); +} + +//==== Start the server ====// +start(); diff --git a/server/app.js b/server/app.js index 2965128c4..89c577c8b 100644 --- a/server/app.js +++ b/server/app.js @@ -14,7 +14,6 @@ import express from 'express'; import config from './config.js'; import fs from 'fs-extra'; -const app = express(); import api from './homebrew.api.js'; const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api; @@ -24,7 +23,7 @@ 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 template from '../client/template.js'; import { model as HomebrewModel } from './homebrew.model.js'; import { DEFAULT_BREW } from './brewDefaults.js'; @@ -37,599 +36,613 @@ import cookieParser from 'cookie-parser'; import forceSSL from './forcessl.mw.js'; import dbCheck from './middleware/dbCheck.js'; - -const sanitizeBrew = (brew, accessType)=>{ - brew._id = undefined; - brew.__v = undefined; - if(accessType !== 'edit' && accessType !== 'shareAuthor') { - brew.editId = undefined; - } - return brew; -}; - -app.set('trust proxy', 1 /* number of proxies between user and server */); - -app.use('/', serveCompressedStaticAssets(`build`)); -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); +export default async function createApp(vite) { + const app = express(); -const corsOptions = { - origin : (origin, callback)=>{ + const nodeEnv = config.get('node_env'); + const isProd = nodeEnv === 'production'; + const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); - 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')); + const sanitizeBrew = (brew, accessType)=>{ + brew._id = undefined; + brew.__v = undefined; + if(accessType !== 'edit' && accessType !== 'shareAuthor') { + brew.editId = undefined; } - }, - methods : ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - credentials : true, -}; + return brew; + }; -app.use(cors(corsOptions)); + app.set('trust proxy', 1 /* number of proxies between user and server */); -//Account Middleware -app.use((req, res, next)=>{ - if(req.cookies && req.cookies.nc_session){ - try { - req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); + app.use(vite.middlewares); + + app.use('/', serveCompressedStaticAssets('build')); + app.use(contentNegotiation); + app.use(bodyParser.json({ limit: '25mb' })); + app.use(cookieParser()); + app.use(forceSSL); + + + 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)=>{ + if(req.cookies && req.cookies.nc_session){ + try { + 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){ - console.log(e); + } catch (e){ + console.log(e); + } } - } - req.config = { - google_client_id : config.get('google_client_id'), - google_client_secret : config.get('google_client_secret') - }; - return next(); -}); - -app.use(homebrewApi); -app.use(adminApi); -app.use(vaultApi); - -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);}; - -const defaultMetaTags = { - site_name : 'The Homebrewery - Make your Homebrew content look legit!', - title : 'The Homebrewery', - description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', - image : `${config.get('publicUrl')}/thumbnail.png`, - type : 'website' -}; - -//Robots.txt -app.get('/robots.txt', (req, res)=>{ - return res.sendFile(`robots.txt`, { root: process.cwd() }); -}); - -//Home page -app.get('/', (req, res, next)=>{ - req.brew = { - text : welcomeText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Home page Legacy -app.get('/legacy', (req, res, next)=>{ - req.brew = { - text : welcomeTextLegacy, - renderer : 'legacy', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage (Legacy)', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Legacy/Other Document -> v3 Migration Guide -app.get('/migrate', (req, res, next)=>{ - req.brew = { - text : migrateText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'v3 Migration Guide', - description : 'A brief guide to converting Legacy documents to the v3 renderer.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Changelog page -app.get('/changelog', async (req, res, next)=>{ - req.brew = { - title : 'Changelog', - text : changelogText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Changelog', - description : 'Development changelog.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//FAQ page -app.get('/faq', async (req, res, next)=>{ - req.brew = { - title : 'FAQ', - text : faqText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'FAQ', - description : 'Frequently Asked Questions' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Source page -app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - - const replaceStrings = { '&': '&', '<': '<', '>': '>' }; - let text = brew.text; - for (const replaceStr in replaceStrings) { - text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); - } - text = `
${text}
`; - res.status(200).send(text); -}); - -//Download brew source page -app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - sanitizeBrew(brew, 'share'); - const prefix = 'HB - '; - - const encodeRFC3986ValueChars = (str)=>{ - return ( - encodeURIComponent(str) - .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) - ); - }; - - let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); - if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; - res.set({ - 'Cache-Control' : 'no-cache', - 'Content-Type' : 'text/plain', - 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` + req.config = { + google_client_id : config.get('google_client_id'), + google_client_secret : config.get('google_client_secret') + }; + return next(); }); - res.status(200).send(brew.text); -}); -//Serve brew metadata -app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - sanitizeBrew(brew, 'share'); + app.use(homebrewApi); + app.use(adminApi); + app.use(vaultApi); - const fields = ['title', 'pageCount', 'description', 'authors', 'lang', + 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);}; + + const defaultMetaTags = { + site_name : 'The Homebrewery - Make your Homebrew content look legit!', + title : 'The Homebrewery', + description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', + image : `${config.get('publicUrl')}/thumbnail.png`, + type : 'website' + }; + + //Robots.txt + app.get('/robots.txt', (req, res)=>{ + return res.sendFile(`robots.txt`, { root: process.cwd() }); + }); + + //Home page + app.get('/', (req, res, next)=>{ + req.brew = { + text : welcomeText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Home page Legacy + app.get('/legacy', (req, res, next)=>{ + req.brew = { + text : welcomeTextLegacy, + renderer : 'legacy', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage (Legacy)', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Legacy/Other Document -> v3 Migration Guide + app.get('/migrate', (req, res, next)=>{ + req.brew = { + text : migrateText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'v3 Migration Guide', + description : 'A brief guide to converting Legacy documents to the v3 renderer.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Changelog page + app.get('/changelog', async (req, res, next)=>{ + req.brew = { + title : 'Changelog', + text : changelogText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Changelog', + description : 'Development changelog.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //FAQ page + app.get('/faq', async (req, res, next)=>{ + req.brew = { + title : 'FAQ', + text : faqText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'FAQ', + description : 'Frequently Asked Questions' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); + }); + + //Source page + app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + + const replaceStrings = { '&': '&', '<': '<', '>': '>' }; + let text = brew.text; + for (const replaceStr in replaceStrings) { + text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); + } + text = `
${text}
`; + res.status(200).send(text); + }); + + //Download brew source page + app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + const prefix = 'HB - '; + + const encodeRFC3986ValueChars = (str)=>{ + return ( + encodeURIComponent(str) + .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) + ); + }; + + let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); + if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; + res.set({ + 'Cache-Control' : 'no-cache', + 'Content-Type' : 'text/plain', + 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` + }); + res.status(200).send(brew.text); + }); + + //Serve brew metadata + app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + + const fields = ['title', 'pageCount', 'description', 'authors', 'lang', 'published', 'views', 'shareId', 'createdAt', 'updatedAt', 'lastViewed', 'thumbnail', 'tags' - ]; + ]; - const metadata = fields.reduce((acc, field)=>{ + const metadata = fields.reduce((acc, field)=>{ if(brew[field] !== undefined) acc[field] = brew[field]; return acc; - }, {}); - res.status(200).json(metadata); -}); + }, {}); + res.status(200).json(metadata); + }); -//Serve brew styling -app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); + //Serve brew styling + app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); -//User Page -app.get('/user/:username', dbCheck, async (req, res, next)=>{ - const ownAccount = req.account && (req.account.username == req.params.username); + //User Page + app.get('/user/:username', dbCheck, async (req, res, next)=>{ + const ownAccount = req.account && (req.account.username == req.params.username); - req.ogMeta = { ...defaultMetaTags, - title : `${req.params.username}'s Collection`, - description : 'View my collection of homebrew on the Homebrewery.' + req.ogMeta = { ...defaultMetaTags, + title : `${req.params.username}'s Collection`, + description : 'View my collection of homebrew on the Homebrewery.' // type : could be 'profile'? - }; + }; - const fields = [ - 'googleId', - 'title', - 'pageCount', - 'description', - 'authors', - 'lang', - 'published', - 'views', - 'shareId', - 'editId', - 'createdAt', - 'updatedAt', - 'lastViewed', - 'thumbnail', - 'tags' - ]; + const fields = [ + 'googleId', + 'title', + 'pageCount', + 'description', + 'authors', + 'lang', + 'published', + 'views', + 'shareId', + 'editId', + 'createdAt', + 'updatedAt', + 'lastViewed', + 'thumbnail', + 'tags' + ]; - let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) + let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) .catch((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){ - const auth = await GoogleActions.authCheck(req.account, res); - let googleBrews = await GoogleActions.listGoogleBrews(auth) + if(ownAccount && req?.account?.googleId){ + const auth = await GoogleActions.authCheck(req.account, res); + let googleBrews = await GoogleActions.listGoogleBrews(auth) .catch((err)=>{ 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.pageCount = googleBrews[match].pageCount; - brew.renderer = googleBrews[match].renderer; - brew.version = googleBrews[match].version; - brew.webViewLink = googleBrews[match].webViewLink; - googleBrews.splice(match, 1); + // 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.pageCount = googleBrews[match].pageCount; + brew.renderer = googleBrews[match].renderer; + brew.version = googleBrews[match].version; + brew.webViewLink = googleBrews[match].webViewLink; + googleBrews.splice(match, 1); + } } + + //Remaining unstubbed google brews display current user as author + googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); + brews = _.concat(brews, googleBrews); } - - //Remaining unstubbed google brews display current user as author - googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); - brews = _.concat(brews, googleBrews); } - } - req.brews = _.map(brews, (brew)=>{ + req.brews = _.map(brews, (brew)=>{ // Clean up brew data - brew.title = brew.title?.trim(); - brew.description = brew.description?.trim(); - return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); + brew.title = brew.title?.trim(); + brew.description = brew.description?.trim(); + return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); + }); + + return next(); }); - return next(); -}); + //Change author name on brews + app.put('/api/user/rename', dbCheck, async (req, res)=>{ + const { username, newUsername } = req.body; + const ownAccount = req.account && (req.account.username == newUsername); -//Change author name on brews -app.put('/api/user/rename', dbCheck, 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); - 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; - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - locale : req.brew.lang, - type : 'article' - }; - - sanitizeBrew(req.brew, 'edit'); - splitTextStyleAndMetadata(req.brew); - res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. - return next(); -})); - -//New Page from ID -app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ - sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - const brew = { - shareId : req.brew.shareId, - title : `CLONE - ${req.brew.title}`, - text : req.brew.text, - style : req.brew.style, - renderer : req.brew.renderer, - theme : req.brew.theme, - tags : req.brew.tags, - snippets : req.brew.snippets - }; - req.brew = _.defaults(brew, DEFAULT_BREW); - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//New Page -app.get('/new', asyncHandler(async(req, res, next)=>{ - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//Share Page -app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ - const { brew } = req; - req.ogMeta = { ...defaultMetaTags, - title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`, - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - // increase visitor view count, do not include visits by author(s) - if(!brew.authors.includes(req.account?.username)){ - if(req.params.id.length > 12 && !brew._id) { - const googleId = brew.googleId; - const shareId = brew.shareId; - await GoogleActions.increaseView(googleId, shareId, 'share', brew) - .catch((err)=>{next(err);}); - } else { - await HomebrewModel.increaseView({ shareId: brew.shareId }); + 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.' }); } - }; + }); - brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - return next(); -})); + //Edit Page + app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ + req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; -//Account Page -app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{ - const data = {}; - data.title = 'Account Information Page'; + req.userThemes = await(getUsersBrewThemes(req.account?.username)); - if(!req.account) { - res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); - const error = new Error('No valid account'); - error.status = 401; - error.HBErrorCode = '50'; - error.page = data.title; - return next(error); - }; + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + locale : req.brew.lang, + type : 'article' + }; - let auth; - let googleCount = []; - if(req.account) { - if(req.account.googleId) { - auth = await GoogleActions.authCheck(req.account, res, false); + sanitizeBrew(req.brew, 'edit'); + splitTextStyleAndMetadata(req.brew); + res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. + return next(); + })); - googleCount = await GoogleActions.listGoogleBrews(auth) + //New Page from ID + app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ + sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + const brew = { + shareId : req.brew.shareId, + title : `CLONE - ${req.brew.title}`, + text : req.brew.text, + style : req.brew.style, + renderer : req.brew.renderer, + theme : req.brew.theme, + tags : req.brew.tags, + snippets : req.brew.snippets + }; + req.brew = _.defaults(brew, DEFAULT_BREW); + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); + })); + + //New Page + app.get('/new', asyncHandler(async(req, res, next)=>{ + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); + })); + + //Share Page + app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ + const { brew } = req; + req.ogMeta = { ...defaultMetaTags, + title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`, + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + // increase visitor view count, do not include visits by author(s) + if(!brew.authors.includes(req.account?.username)){ + if(req.params.id.length > 12 && !brew._id) { + const googleId = brew.googleId; + const shareId = brew.shareId; + await GoogleActions.increaseView(googleId, shareId, 'share', brew) + .catch((err)=>{next(err);}); + } else { + await HomebrewModel.increaseView({ shareId: brew.shareId }); + } + }; + + brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + return next(); + })); + + //Account Page + app.get('/account', dbCheck, 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'); + error.status = 401; + error.HBErrorCode = '50'; + error.page = data.title; + return next(error); + }; + + let auth; + let googleCount = []; + if(req.account) { + if(req.account.googleId) { + 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 } }; - const mongoCount = await HomebrewModel.countDocuments(query) + const query = { authors: req.account.username, googleId: { $exists: false } }; + const mongoCount = await HomebrewModel.countDocuments(query) .catch((err)=>{ console.log(err); return 0; }); - data.accountDetails = { - username : req.account.username, - issued : req.account.issued, - googleId : Boolean(req.account.googleId), - authCheck : Boolean(req.account.googleId && auth?.credentials.access_token), - mongoCount : mongoCount, - googleCount : googleCount?.length + data.accountDetails = { + username : req.account.username, + issued : req.account.issued, + googleId : Boolean(req.account.googleId), + authCheck : Boolean(req.account.googleId && auth?.credentials.access_token), + mongoCount : mongoCount, + googleCount : googleCount?.length + }; + } + + req.brew = data; + + req.ogMeta = { ...defaultMetaTags, + title : `Account Page`, + description : null }; - } - req.brew = data; + return next(); + })); - req.ogMeta = { ...defaultMetaTags, - title : `Account Page`, - description : null - }; - - return next(); -})); - -// Local only -if(isLocalEnvironment){ + // Local only + if(isLocalEnvironment){ // Login - app.post('/local/login', (req, res)=>{ - const username = req.body.username; - if(!username) return; + app.post('/local/login', (req, res)=>{ + const username = req.body.username; + if(!username) return; - const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); - return res.json(payload); - }); -} + const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); + return res.json(payload); + }); + } -// 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')); + // 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, - title : 'The Vault', - description : 'Search for Brews' - }; - return next(); -})); + //Vault Page + app.get('/vault', asyncHandler(async(req, res, next)=>{ + req.ogMeta = { ...defaultMetaTags, + title : 'The Vault', + description : 'Search for Brews' + }; + return next(); + })); -//Send rendered page -app.use(asyncHandler(async (req, res, next)=>{ - if(!req.route) return res.redirect('/'); // Catch-all for invalid routes + //Send rendered page + app.use(asyncHandler(async (req, res, next)=>{ + if(!req.route) return res.redirect('/'); // Catch-all for invalid routes - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -})); + const page = await renderPage(req, res); + if(!page) return; + res.send(page); + })); -//Render the page -const renderPage = async (req, res)=>{ + //Render the page + const renderPage = async (req, res)=>{ // Create configuration object - const configuration = { - local : isLocalEnvironment, - publicUrl : config.get('publicUrl') ?? '', - baseUrl : `${req.protocol}://${req.get('host')}`, - environment : nodeEnv, - deployment : config.get('heroku_app_name') ?? '' + const configuration = { + local : isLocalEnvironment, + publicUrl : config.get('publicUrl') ?? '', + baseUrl : `${req.protocol}://${req.get('host')}`, + environment : nodeEnv, + deployment : config.get('heroku_app_name') ?? '' + }; + const props = { + version : version, + url : req.customUrl || req.originalUrl, + brew : req.brew, + brews : req.brews, + googleBrews : req.googleBrews, + account : req.account, + config : configuration, + ogMeta : req.ogMeta, + userThemes : req.userThemes + }; + const title = req.brew ? req.brew.title : ''; + + const page = await template( + isProd ? {} : { vite, url: req.originalUrl }, + 'homebrew', + title, + props + ).catch((err)=>{ + console.error(err); + }); + + return page; }; - const props = { - version : version, - url : req.customUrl || req.originalUrl, - brew : req.brew, - brews : req.brews, - googleBrews : req.googleBrews, - account : req.account, - config : configuration, - ogMeta : req.ogMeta, - userThemes : req.userThemes + + //v=====----- Error-Handling Middleware -----=====v// + //Format Errors as plain objects so all fields will appear in the string sent + const formatErrors = (key, value)=>{ + if(value instanceof Error) { + const error = {}; + Object.getOwnPropertyNames(value).forEach(function (key) { + error[key] = value[key]; + }); + return error; + } + return value; }; - const title = req.brew ? req.brew.title : ''; - const page = await templateFn('homebrew', title, props) - .catch((err)=>{ - console.log(err); - }); - return page; -}; -//v=====----- Error-Handling Middleware -----=====v// -//Format Errors as plain objects so all fields will appear in the string sent -const formatErrors = (key, value)=>{ - if(value instanceof Error) { - const error = {}; - Object.getOwnPropertyNames(value).forEach(function (key) { - error[key] = value[key]; - }); - return error; - } - return value; -}; + const getPureError = (error)=>{ + return JSON.parse(JSON.stringify(error, formatErrors)); + }; -const getPureError = (error)=>{ - return JSON.parse(JSON.stringify(error, formatErrors)); -}; + app.use(async (err, req, res, next)=>{ + err.originalUrl = req.originalUrl; + console.error(err); -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; - } + res.status(err.status || err.response?.status || 500).send(err); + return; + } - // console.log('non-API error'); - const status = err.status || err.code || 500; + // console.log('non-API error'); + const status = err.status || err.code || 500; - req.ogMeta = { ...defaultMetaTags, - title : 'Error Page', - description : 'Something went wrong!' - }; - req.brew = { - ...err, - title : 'Error - Something went wrong!', - text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', - status : status, - HBErrorCode : err.HBErrorCode ?? '00', - pureError : getPureError(err) - }; - req.customUrl= '/error'; + req.ogMeta = { ...defaultMetaTags, + title : 'Error Page', + description : 'Something went wrong!' + }; + req.brew = { + ...err, + title : 'Error - Something went wrong!', + text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', + status : status, + HBErrorCode : err.HBErrorCode ?? '00', + pureError : getPureError(err) + }; + req.customUrl= '/error'; - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -}); + const page = await renderPage(req, res); + if(!page) return; + res.send(page); + }); -app.use((req, res)=>{ - if(!res.headersSent) { - console.error('Headers have not been sent, responding with a server error.', req.url); - res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); - } -}); -//^=====--------------------------------------=====^// + app.use((req, res)=>{ + if(!res.headersSent) { + console.error('Headers have not been sent, responding with a server error.', req.url); + res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); + } + }); + //^=====--------------------------------------=====^// -export default app; + return app; +} diff --git a/vite.config.js b/vite.config.js index 4405fcafd..ff2c1444e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -23,6 +23,7 @@ export default defineConfig({ }, }, server: { + port:8000, fs: { allow: ["."], },