Update WuppiMini

This commit is contained in:
2024-02-24 20:27:20 +01:00
parent dc0795d138
commit 1a66d02cb0
5 changed files with 316 additions and 472 deletions

View File

@@ -11,7 +11,7 @@ pages:
cd ./src/WuppiMini cd ./src/WuppiMini
npm install npm install
node ./index.js html node ./index.js html
cp ./index.js ./index.html ../../public/WuppiMini/ cp ./index.js ./index.html ./node_modules/SpaccDotWeb/SpaccDotWeb.Server.js ../../public/WuppiMini/
artifacts: artifacts:
paths: paths:
- public - public

View File

@@ -1 +1,2 @@
/index.html /index.html
/SpaccDotWeb.Server.js

View File

@@ -1,5 +1,4 @@
// NodeJS requirements, for server deployment: // search "Copyright" in this file for licensing info
// `npm install mime-types parse-multipart-data`
// configuration // configuration
const appName = 'WuppìMini'; const appName = 'WuppìMini';
@@ -21,15 +20,18 @@ const appTerms = `
const suggestedTags = [ 'fromWuppiMini' ]; const suggestedTags = [ 'fromWuppiMini' ];
const corsProxies = [ 'corsproxy.io', 'corsproxy.org' ]; const corsProxies = [ 'corsproxy.io', 'corsproxy.org' ];
let fs, path, mime, multipart, crypto; const SpaccDotWebServer = require('SpaccDotWeb/SpaccDotWeb.Server.js');
let isEnvServer = (typeof(window) === 'undefined'); let crypto;
let isEnvBrowser = !isEnvServer; let isEnvServer = SpaccDotWebServer.envIsNode;
let isEnvBrowser = SpaccDotWebServer.envIsBrowser;
const httpCodes = { success: [200,201] }; const httpCodes = { success: [200,201] };
const strings = { const strings = {
csrfErrorHtml: `<p class="notice error">Authorization token mismatch. Please try resubmitting.</p>`, csrfErrorHtml: `<p class="notice error">Authorization token mismatch. Please try resubmitting.</p>`,
upstreamDisallowedHtml: `<p class="notice error">Upstream destination is not allowed from backend.</p>`, upstreamDisallowedHtml: `<p class="notice error">Upstream destination is not allowed from backend.</p>`,
}; };
const appPager = (content, title) => `${title ? `<h2>${title}</h2>` : ''}${content}`;
const newHtmlPage = (content, title) => `<!DOCTYPE html><html><head> const newHtmlPage = (content, title) => `<!DOCTYPE html><html><head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@@ -85,7 +87,7 @@ div#app {
margin-right: auto; margin-right: auto;
padding: 0px 8px; padding: 0px 8px;
} }
div#transitioner { div#transition {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
position: absolute; position: absolute;
@@ -108,15 +110,15 @@ p.notice.error {
border-color: #de0000; border-color: #de0000;
} }
</style> </style>
</head><body> </head><body><!--
${isEnvBrowser ? `<div id="transitioner"></div>` : ''} -->${isEnvBrowser ? `<div id="transition"></div>` : ''}<!--
<div id="header"> --><div id="header"><!--
<h1><a href="/">${appName}</a></h1><!-- --><h1><a href="/">${appName}</a></h1><!--
--><a href="/info">Info</a><!-- --><a href="/info">Info</a><!--
--><a href="/settings">Settings</a> --><a href="/settings">Settings</a><!--
</div> --></div><!--
<div id="app">${content}</div> --><div id="app">${appPager(content, title)}</div><!--
</body></html>`; --></body></html>`;
const A = (href) => `<a href="${href}">${href}</a>` const A = (href) => `<a href="${href}">${href}</a>`
@@ -135,62 +137,6 @@ const checkUpstreamAllowed = (url) => {
return true; return true;
} }
const redirectTo = (url, res) => {
if (isEnvServer) {
res.statusCode = 302;
res.setHeader('Location', url);
res.end();
} else if (isEnvBrowser) {
location.hash = url;
}
};
const setPageContent = (res, content, title) => {
const titleHtml = (title ? `<h2>${title}</h2>` : '');
if (isEnvServer) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(newHtmlPage((titleHtml + content), title));
} else if (isEnvBrowser) {
if (title) {
document.title = title;
}
document.querySelector('div#app').innerHTML = (titleHtml + content);
//for (const srcElem of document.querySelectorAll('[src^="/res/"]')) {
// srcElem.src = resFilesData[srcElem.getAttribute('src')];
//}
//for (const linkElem of document.querySelectorAll('link[rel="stylesheet"][href^="/res/"]')) {
// linkElem.href = resFilesData[linkElem.getAttribute('href')];
//}
for (const aElem of document.querySelectorAll('a[href^="/"]')) {
aElem.href = `#${aElem.getAttribute('href')}`;
}
for (const formElem of document.querySelectorAll('form')) {
const submitElem = formElem.querySelector('input[type="submit"]');
formElem.onsubmit = (event) => {
event.preventDefault();
const formData = (new FormData(formElem));
formData.append(submitElem.getAttribute('name'), (submitElem.value || 'Submit'));
handleRequest({
method: (formElem.getAttribute('method') || 'GET'),
url: (formElem.getAttribute('action') || location.hash.slice(1)),// + '?' + (new URLSearchParams(new FormData(formElem))),
headers: {
"content-type": (formElem.getAttribute('enctype') || "application/x-www-form-urlencoded"),
},
body: formData,
//body: `${(new URLSearchParams(new FormData(formElem))).toString()}&${submitElem.name}=${submitElem.value || 'Submit'}`,
})
};
//submitElem.type = 'button';
//submitElem.value = (submitElem.getAttribute('value') || 'Submit');
//submitElem.onclick = () => handleRequest({
// method: (formElem.getAttribute('method') || 'GET'),
// url: (formElem.getAttribute('action') || location.hash.slice(1)) + '?' + (new URLSearchParams(new FormData(formElem))),
//});
}
document.querySelector('div#transitioner').style.display = 'none';
}
};
// the below anti-CSRF routines do useful work only on the server, that kind of attack is not possible with the client app // the below anti-CSRF routines do useful work only on the server, that kind of attack is not possible with the client app
const makeFormCsrf = (accountString) => { const makeFormCsrf = (accountString) => {
@@ -208,72 +154,10 @@ const genCsrfToken = (accountString, time) => (isEnvServer && time && crypto.scr
const matchCsrfToken = (bodyParams, accountString) => (isEnvServer ? bodyParams.formToken === genCsrfToken(accountString, bodyParams.formTime) : true); const matchCsrfToken = (bodyParams, accountString) => (isEnvServer ? bodyParams.formToken === genCsrfToken(accountString, bodyParams.formTime) : true);
// try to use the built-in cookie API, fallback to a Storage-based wrapper in case it fails (for example on file:///)
const clientCookieApi = (isEnvBrowser && (document.cookie || (!document.cookie && (document.cookie = '_=_') && document.cookie) ? (set) => (set ? (document.cookie = set) : document.cookie) : (set) => {
if (set) {
let api = sessionStorage;
const tokens = set.split(';');
const [key, ...rest] = tokens[0].split('=');
for (let token of tokens) {
token = token.trim();
if (['expires', 'max-age'].includes(token.split('=')[0].toLowerCase())) {
api = localStorage;
break;
}
}
api.setItem(`${appName}/${key}`, rest.join('='));
} else /*(get)*/ {
let items = '';
for (const item of Object.entries({ ...localStorage, ...sessionStorage })) {
items += (item.join('=') + ';').slice(appName.length + 1);
}
return items.slice(0, -1);
}
}));
const getCookie = (req, keyQuery) => {
if (isEnvServer) {
if (keyQuery) {
// get a specific cookie
for (const cookie of (req.headers?.cookie?.split(';') || [])) {
const [key, ...rest] = cookie.split('=');
if (key === keyQuery) {
return rest.join('=');
}
}
} else {
// get all cookies
return req.headers?.cookie?.join(';');
}
} else if (isEnvBrowser) {
const cookies = clientCookieApi();
if (keyQuery) {
for (const cookie of cookies.split(';')) {
const [key, ...rest] = cookie.split('=');
if (key === keyQuery) {
return rest.join('=');
}
}
} else {
return cookies;
}
}
}
// TODO warn if the browser has cookies disabled when running on server side
const setCookies = (res, list) => {
if (isEnvServer) {
res.setHeader('Set-Cookie', list);
} else if (isEnvBrowser) {
for (const cookie of list) {
clientCookieApi(cookie);
}
}
}
const corsProxyIfNeed = (need) => (isEnvBrowser /*&& need*/ ? `https://${corsProxies[~~(Math.random() * corsProxies.length)]}?` : ''); const corsProxyIfNeed = (need) => (isEnvBrowser /*&& need*/ ? `https://${corsProxies[~~(Math.random() * corsProxies.length)]}?` : '');
const handleRequest = async (req, res={}) => { /*const handleRequest = async (req, res={}) => {
// TODO warn if the browser has cookies disabled when running on server side
// to check if we can save cookies: // to check if we can save cookies:
// first check if any cookie is saved, if it is then we assume to be good // first check if any cookie is saved, if it is then we assume to be good
// if none is present, redirect to another endpoint that should set a "flag cookie" and redirect to a second one that checks if the flag is present // if none is present, redirect to another endpoint that should set a "flag cookie" and redirect to a second one that checks if the flag is present
@@ -283,59 +167,59 @@ const handleRequest = async (req, res={}) => {
//if (!getCookie(req)) { //if (!getCookie(req)) {
// return redirectTo('/thecookieflagthingy', res); // return redirectTo('/thecookieflagthingy', res);
//} //}
if (isEnvBrowser && document.querySelector('div#transitioner')) {
document.querySelector('div#transitioner').style.display = 'block';
}
const section = req.url.split('/')[1].split('?')[0].toLowerCase();
const urlParams = new URLSearchParams(req.url.split('?')[1]);
const bodyParams = {};
if (req.method === 'HEAD') { if (req.method === 'HEAD') {
req.method = 'GET'; req.method = 'GET';
} else if (req.method === 'POST') { };
try { };*/
if (isEnvServer) {
req.body = Buffer.alloc(0); // todo handle optional options field(s)
req.on('data', (data) => { const accountDataFromString = (accountString) => {
req.body = Buffer.concat([req.body, data]); const tokens = accountString.split(',');
if (req.body.length > 4e6) { // 4 MB upload limit is (fair enough?) return { instance: tokens[0], username: tokens[1], password: tokens.slice(2).join(',') };
req.connection.destroy();
} }
})
await new Promise((resolve) => req.on('end', () => resolve())); const makeFragmentLoggedIn = (accountString) => {
const accountData = accountDataFromString(accountString);
return `<p>Logged in as <i>${accountData.username} @ ${A(accountData.instance)}</i>.</p>`;
} }
const contentMime = req.headers['content-type'].split(';')[0];
if (isEnvServer && contentMime === 'application/x-www-form-urlencoded') { const main = () => {
for (const [key, value] of (new URLSearchParams(req.body.toString())).entries()) { if (SpaccDotWebServer.envIsNode && process.argv[2] !== 'html') {
bodyParams[key] = value; resFiles = [__filename.split(require('path').sep).slice(-1)[0], ...resFiles];
} };
} else if (isEnvServer && contentMime === 'multipart/form-data') {
for (const param of multipart.parse(req.body, req.headers['content-type'].split(';')[1].split('boundary=')[1])) { const server = SpaccDotWebServer.setup({
bodyParams[param.name] = (param.type && param.filename !== undefined ? param : param.data.toString()); appName: appName,
} staticPrefix: '/res/',
} else if (isEnvBrowser && ['application/x-www-form-urlencoded', 'multipart/form-data'].includes(contentMime)) { staticFiles: resFiles,
for (const [key, value] of req.body) { appPager: appPager,
bodyParams[key] = value; htmlPager: newHtmlPage,
bodyParams[key].filename = bodyParams[key].name; });
}
} if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') {
} catch(err) { server.writeStaticHtml();
console.log(err);
req.connection?.destroy();
}
}
if (!section)
{
if (getCookie(req, 'account')) {
return redirectTo('/compose', res);
} else { } else {
return redirectTo('/info', res); if (SpaccDotWebServer.envIsNode) {
} crypto = require('crypto');
} console.log('Running Server...');
else if (section === 'info' && req.method === 'GET') };
{ server.initServer({
res.statusCode = 200; port: serverPort,
return setPageContent(res, ` address: '0.0.0.0',
${!getCookie(req, 'account') ? `<p>You must login first. Go to <a href="/settings">Settings</a> to continue.</p>` : ''} maxBodyUploadSize: 4e6, // 4 MB
endpoints: [ endpointRoot, endpointInfo, endpointCompose, endpointSettings, endpointCatch ],
});
};
};
const endpointRoot = [ (ctx) => (!ctx.urlSections[0]), (ctx) => ctx.redirectTo(ctx.getCookie('account') ? '/compose' : '/info') ];
const endpointCatch = [ (ctx) => true, (ctx) => ctx.renderPage('', (ctx.response.statusCode = 404)) ];
const endpointInfo = [ (ctx) => (ctx.urlSections[0] === 'info' && ctx.request.method === 'GET'), (ctx) => {
ctx.response.statusCode = 200;
ctx.renderPage(`
${!ctx.getCookie('account') ? `<p>You must login first. Go to <a href="/settings">Settings</a> to continue.</p>` : ''}
<h3>About</h3> <h3>About</h3>
<p> <p>
${appName} (temporary name?) is a minimalist, basic HTML-based frontend, designed for quickly and efficiently publishing to social media and content management services (note that only WordPress is currently supported). ${appName} (temporary name?) is a minimalist, basic HTML-based frontend, designed for quickly and efficiently publishing to social media and content management services (note that only WordPress is currently supported).
@@ -378,6 +262,11 @@ const handleRequest = async (req, res={}) => {
</p> </p>
${isEnvServer ? `<h3>Terms of Use and Privacy Policy</h3>${appTerms}` : ''} ${isEnvServer ? `<h3>Terms of Use and Privacy Policy</h3>${appTerms}` : ''}
<h3>Changelog</h3> <h3>Changelog</h3>
<h4>2024-02-24</h3>
<ul>
<li>Allow uploading posts as published or draft, via 2 distinct buttons.</li>
<li>Migrated fancy portable-server-codebase to <a href="https://gitlab.com/SpaccInc/SpaccDotWeb">SpaccDotWeb</a> for code reuse and slimming down of the application core.</li>
</ul>
<h4>2024-02-12</h3> <h4>2024-02-12</h3>
<ul> <ul>
<li>First working client-side version of the current app, without backend server (still a bit buggy).</li> <li>First working client-side version of the current app, without backend server (still a bit buggy).</li>
@@ -386,7 +275,7 @@ const handleRequest = async (req, res={}) => {
<h4>2024-02-10</h4> <h4>2024-02-10</h4>
<ul> <ul>
<li>Add "remember me" login option.</li> <li>Add "remember me" login option.</li>
<!--<li>Add "suggested tags" publishing option, will automatically add this list of tags to the post: [${suggestedTags}].</li>--> <li>Add "suggested tags" publishing option, will automatically add this list of tags to the post: [${suggestedTags}].</li>
</ul> </ul>
<h4>2024-02-09</h4> <h4>2024-02-09</h4>
<ul> <ul>
@@ -396,47 +285,48 @@ const handleRequest = async (req, res={}) => {
<li>Tested on New and Old 3DS.</li> <li>Tested on New and Old 3DS.</li>
</ul> </ul>
`); `);
} } ];
else if (section === 'compose' && ['GET', 'POST'].includes(req.method))
{ const endpointCompose = [ (ctx) => (ctx.urlSections[0] === 'compose' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => {
let noticeHtml = ''; let noticeHtml = '';
const accountString = getCookie(req, 'account'); const accountString = ctx.getCookie('account');
if (!accountString) { if (!accountString) {
return redirectTo('/', res); return ctx.redirectTo('/');
} }
res.statusCode = 200; ctx.response.statusCode = 200;
if (req.method === 'POST' && bodyParams.publish) { const postUploadStatus = ((ctx.bodyParameters?.publish && 'publish') || (ctx.bodyParameters?.draft && 'draft'));
if (!matchCsrfToken(bodyParams, accountString)) { if (ctx.request.method === 'POST' && postUploadStatus) {
res.statusCode = 401; if (!matchCsrfToken(ctx.bodyParameters, accountString)) {
ctx.response.statusCode = 401;
noticeHtml = strings.csrfErrorHtml; noticeHtml = strings.csrfErrorHtml;
} }
const isThereAnyFile = ((bodyParams.file?.data?.length || bodyParams.file?.size) > 0); const isThereAnyFile = ((ctx.bodyParameters.file?.data?.length || ctx.bodyParameters.file?.size) > 0);
if (!bodyParams.text?.trim() && !isThereAnyFile) { if (!ctx.bodyParameters.text?.trim() && !isThereAnyFile) {
res.statusCode = 500; ctx.response.statusCode = 500;
noticeHtml = `<p class="notice error">Post content is empty. Please write some text or upload a media.</p>`; noticeHtml = `<p class="notice error">Post content is empty. Please write some text or upload a media.</p>`;
} }
const account = accountDataFromString(accountString); const account = accountDataFromString(accountString);
if (!checkUpstreamAllowed(account.instance)) { if (!checkUpstreamAllowed(account.instance)) {
res.statusCode = 500; ctx.response.statusCode = 500;
noticeHtml = strings.upstreamDisallowedHtml; noticeHtml = strings.upstreamDisallowedHtml;
} }
let mediaData; let mediaData;
try { try {
// there is a media to upload first // there is a media to upload first
if (httpCodes.success.includes(res.statusCode) && isThereAnyFile) { if (httpCodes.success.includes(ctx.response.statusCode) && isThereAnyFile) {
const mediaReq = await fetch(`${corsProxyIfNeed(account.cors)}${account.instance}/wp-json/wp/v2/media`, { headers: { const mediaReq = await fetch(`${corsProxyIfNeed(account.cors)}${account.instance}/wp-json/wp/v2/media`, { headers: {
Authorization: `Basic ${btoa(account.username + ':' + account.password)}`, Authorization: `Basic ${btoa(account.username + ':' + account.password)}`,
"Content-Type": bodyParams.file.type, "Content-Type": ctx.bodyParameters.file.type,
"Content-Disposition": `attachment; filename=${bodyParams.file.filename}`, "Content-Disposition": `attachment; filename=${ctx.bodyParameters.file.filename}`,
}, method: "POST", body: (bodyParams.file.data || bodyParams.file) }); }, method: "POST", body: (ctx.bodyParameters.file.data || ctx.bodyParameters.file) });
mediaData = await mediaReq.json(); mediaData = await mediaReq.json();
if (!httpCodes.success.includes(mediaReq.status)) { if (!httpCodes.success.includes(mediaReq.status)) {
noticeHtml = `<p class="notice error">Upstream server responded with error ${res.statusCode = mediaReq.status}: ${JSON.stringify(mediaData)}</p>`; noticeHtml = `<p class="notice error">Upstream server responded with error ${ctx.response.statusCode = mediaReq.status}: ${JSON.stringify(mediaData)}</p>`;
} }
} }
// upload actual post if nothing has errored before // upload actual post if nothing has errored before
if (httpCodes.success.includes(res.statusCode)) { if (httpCodes.success.includes(ctx.response.statusCode)) {
const tagsHtml = (bodyParams.tags === 'on' ? ` const tagsHtml = (ctx.bodyParameters.tags === 'on' ? `
<!-- wp:paragraph --> <!-- wp:paragraph -->
<p> #${suggestedTags.join(' #')} </p> <p> #${suggestedTags.join(' #')} </p>
<!-- /wp:paragraph --> <!-- /wp:paragraph -->
@@ -450,17 +340,17 @@ const handleRequest = async (req, res={}) => {
Authorization: `Basic ${btoa(account.username + ':' + account.password)}`, Authorization: `Basic ${btoa(account.username + ':' + account.password)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, method: "POST", body: JSON.stringify({ }, method: "POST", body: JSON.stringify({
status: "publish", status: postUploadStatus,
featured_media: mediaData?.id, featured_media: mediaData?.id,
title: bodyParams.title, title: ctx.bodyParameters.title,
content: (bodyParams.html === 'on' ? `${bodyParams.text}${tagsHtml}${figureHtml}` : ` content: (ctx.bodyParameters.html === 'on' ? `${ctx.bodyParameters.text}${tagsHtml}${figureHtml}` : `
${bodyParams.text?.trim() ? ` ${ctx.bodyParameters.text?.trim() ? `
<!-- wp:paragraph --> <!-- wp:paragraph -->
<p>${bodyParams.text.replaceAll('\r\n', '<br/>')}</p> <p>${ctx.bodyParameters.text.replaceAll('\r\n', '<br/>')}</p>
<!-- /wp:paragraph --> <!-- /wp:paragraph -->
` : ''} ` : ''}
${tagsHtml} ${tagsHtml}
${bodyParams.text?.trim() && mediaData?.id && mediaData?.source_url ? ` ${ctx.bodyParameters.text?.trim() && mediaData?.id && mediaData?.source_url ? `
<!-- wp:paragraph --> <!-- wp:paragraph -->
<p></p> <p></p>
<!-- /wp:paragraph --> <!-- /wp:paragraph -->
@@ -469,75 +359,74 @@ ${figureHtml}`.trim()),
}) }); }) });
const postData = await postReq.json(); const postData = await postReq.json();
if (httpCodes.success.includes(postReq.status)) { if (httpCodes.success.includes(postReq.status)) {
noticeHtml = `<p class="notice success">Post uploaded! ${A(postData.link)}</p>`; noticeHtml = `<p class="notice success">${postUploadStatus === 'publish' ? 'Post published' : ''}${postUploadStatus === 'draft' ? 'Draft uploaded' : ''}! ${A(postData.link)}</p>`;
} else { } else {
noticeHtml = `<p class="notice error">Upstream server responded with error ${res.statusCode = postReq.status}: ${JSON.stringify(postData)}</p>`; noticeHtml = `<p class="notice error">Upstream server responded with error ${ctx.response.statusCode = postReq.status}: ${JSON.stringify(postData)}</p>`;
} }
} }
} catch (err) { } catch (err) {
console.log(err); console.log(err);
res.statusCode = 500; ctx.response.statusCode = 500;
// display only generic error from server-side, for security // display only generic error from server-side, for security
noticeHtml = `<p class="notice error">${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}</p>`; noticeHtml = `<p class="notice error">${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}</p>`;
} }
// TODO handle media upload success but post fail, either delete the remote media or find a way to reuse it when the user probably retries posting // TODO handle media upload success but post fail, either delete the remote media or find a way to reuse it when the user probably retries posting
} }
return setPageContent(res, `${noticeHtml} ctx.renderPage(`${noticeHtml}
${makeFragmentLoggedIn(accountString)} ${makeFragmentLoggedIn(accountString)}
<form method="POST" enctype="multipart/form-data">${makeFormCsrf(accountString)} <form method="POST" enctype="multipart/form-data">${makeFormCsrf(accountString)}
<input type="text" name="title" placeholder="Post Title" value="${bodyParams.title && res.statusCode !== 200 ? bodyParams.title : ''}"/> <input type="text" name="title" placeholder="Post Title" value="${ctx.bodyParameters.title && ctx.response.statusCode !== 200 ? ctx.bodyParameters.title : ''}"/>
<input type="file" accept="image/jpeg,image/gif,image/png,image/webp,image/bmp" name="file"/> <input type="file" accept="image/jpeg,image/gif,image/png,image/webp,image/bmp" name="file"/>
<textarea name="text" rows="10" placeholder="What's on your mind?">${bodyParams.text && res.statusCode !== 200 ? bodyParams.text : ''}</textarea> <textarea name="text" rows="10" placeholder="What's on your mind?">${ctx.bodyParameters.text && ctx.response.statusCode !== 200 ? ctx.bodyParameters.text : ''}</textarea>
<label><input type="checkbox" name="html" ${bodyParams.html === 'on' && res.statusCode !== 200 ? 'checked="true"' : ''}/> Raw HTML mode</label> <!-- TODO: fix the turn off-on on submit of these checkboxes... -->
<label><input type="checkbox" name="tags" ${req.method === 'GET' || (bodyParams.tags === 'on' && res.statusCode !== 200) ? 'checked="true"' : ''}/> Include suggested tags</label> <label><input type="checkbox" name="html" ${ctx.bodyParameters.html === 'on' && ctx.response.statusCode !== 200 ? 'checked="true"' : ''}/> Raw HTML mode</label>
<!--<input type="submit" name="draft" value="Save Draft"/>--> <label><input type="checkbox" name="tags" ${ctx.request.method === 'GET' || (ctx.bodyParameters.tags === 'on' && ctx.response.statusCode !== 200) ? 'checked="true"' : ''}/> Include suggested tags</label>
<input type="submit" name="draft" value="Upload Draft"/>
<input type="submit" name="publish" value="Publish!"/> <input type="submit" name="publish" value="Publish!"/>
</form> </form>
`, 'Compose Post'); `, 'Compose Post');
} } ];
else if (section === 'settings' && ['GET', 'POST'].includes(req.method))
{ const endpointSettings = [ (ctx) => (ctx.urlSections[0] === 'settings' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => {
let noticeHtml = ''; let noticeHtml = '';
const accountString = getCookie(req, 'account'); const accountString = ctx.getCookie('account');
res.statusCode = 200; ctx.response.statusCode = 200;
if (req.method === 'POST') { if (ctx.request.method === 'POST') {
if (accountString && !matchCsrfToken(bodyParams, accountString)) { if (accountString && !matchCsrfToken(ctx.bodyParameters, accountString)) {
res.statusCode = 401; ctx.response.statusCode = 401;
noticeHtml = strings.csrfErrorHtml; noticeHtml = strings.csrfErrorHtml;
} }
if (res.statusCode === 200 && bodyParams.login) { if (ctx.response.statusCode === 200 && ctx.bodyParameters.login) {
bodyParams.instance = bodyParams.instance.trim(); ctx.bodyParameters.instance = ctx.bodyParameters.instance.trim();
if (!checkUpstreamAllowed(bodyParams.instance)) { if (!checkUpstreamAllowed(ctx.bodyParameters.instance)) {
res.statusCode = 500; ctx.response.statusCode = 500;
noticeHtml = strings.upstreamDisallowedHtml; noticeHtml = strings.upstreamDisallowedHtml;
} }
try { try {
const upstreamReq = await fetch(`${corsProxyIfNeed(bodyParams.cors === 'on')}${bodyParams.instance}/wp-json/wp/v2/users?context=edit`, { headers: { const upstreamReq = await fetch(`${corsProxyIfNeed(ctx.bodyParameters.cors === 'on')}${ctx.bodyParameters.instance}/wp-json/wp/v2/users?context=edit`, { headers: {
Authorization: `Basic ${btoa(bodyParams.username + ':' + bodyParams.password)}`, Authorization: `Basic ${btoa(ctx.bodyParameters.username + ':' + ctx.bodyParameters.password)}`,
} }); } });
const upstreamData = await upstreamReq.json(); const upstreamData = await upstreamReq.json();
if (upstreamReq.status === 200) { if (upstreamReq.status === 200) {
let cookieFlags = (bodyParams.remember === 'on' ? `; max-age=${365*24*60*60}` : ''); let cookieFlags = (ctx.bodyParameters.remember === 'on' ? `; max-age=${365*24*60*60}` : '');
setCookies(res, [ ctx.setCookie(`account=${ctx.bodyParameters.instance},${ctx.bodyParameters.username},${ctx.bodyParameters.password}${cookieFlags}`); // TODO: add cookie renewal procedure
`account=${bodyParams.instance},${bodyParams.username},${bodyParams.password}${cookieFlags}` return ctx.redirectTo('/');
]); // TODO: add cookie renewal procedure
return redirectTo('/', res);
} else { } else {
res.statusCode = upstreamReq.status; ctx.response.statusCode = upstreamReq.status;
noticeHtml = `<p class="notice error">Upstream server responded with error ${upstreamReq.status}: ${JSON.stringify(upstreamData)}</p>`; noticeHtml = `<p class="notice error">Upstream server responded with error ${upstreamReq.status}: ${JSON.stringify(upstreamData)}</p>`;
} }
} catch (err) { } catch (err) {
console.log(err); console.log(err);
res.statusCode = 500; ctx.response.statusCode = 500;
// display only generic error from server-side, for security // display only generic error from server-side, for security
noticeHtml = `<p class="notice error">${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}</p>`; noticeHtml = `<p class="notice error">${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}</p>`;
} }
} else if (res.statusCode === 200 && bodyParams.logout) { } else if (ctx.response.statusCode === 200 && ctx.bodyParameters.logout) {
setCookies(res, [`account=`]); ctx.setCookie(`account=`);
return redirectTo('/', res); return ctx.redirectTo('/');
} }
} }
return setPageContent(res, `${noticeHtml} ctx.renderPage(`${noticeHtml}
${accountString ? ` ${accountString ? `
<h3>Current Account</h3> <h3>Current Account</h3>
${makeFragmentLoggedIn(accountString)} ${makeFragmentLoggedIn(accountString)}
@@ -553,11 +442,11 @@ ${figureHtml}`.trim()),
</option> </option>
</select> </select>
<label><i>Note: For WordPress.org you must use an "application password" (<code>/wp-admin/profile.php#application-passwords-section</code>)</i></label> <label><i>Note: For WordPress.org you must use an "application password" (<code>/wp-admin/profile.php#application-passwords-section</code>)</i></label>
<input type="url" name="instance" placeholder="Site/Instance URL" value="${bodyParams.instance || ''}" required="true"/> <input type="url" name="instance" placeholder="Site/Instance URL" value="${ctx.bodyParameters.instance || ''}" required="true"/>
<input type="text" name="username" placeholder="Username" value="${bodyParams.username || ''}" required="true"/> <input type="text" name="username" placeholder="Username" value="${ctx.bodyParameters.username || ''}" required="true"/>
<input type="password" name="password" placeholder="Password" value="${bodyParams.password || ''}" required="true"/> <input type="password" name="password" placeholder="Password" value="${ctx.bodyParameters.password || ''}" required="true"/>
<!--${isEnvBrowser ? `<label><input type="checkbox" name="cors" ${bodyParams.cors === 'on' && res.statusCode !== 200 ? 'checked="true"' : ''}/> Site disallows CORS, use proxy</label>` : ''}--> <!--${isEnvBrowser ? `<label><input type="checkbox" name="cors" ${ctx.bodyParameters.cors === 'on' && ctx.response.statusCode !== 200 ? 'checked="true"' : ''}/> Site disallows CORS, use proxy</label>` : ''}-->
<label><input type="checkbox" name="remember" ${req.method === 'POST' && bodyParams.remember !== 'on' ? '' : 'checked="true"'}/> Remember me</label> <label><input type="checkbox" name="remember" ${ctx.request.method === 'POST' && ctx.bodyParameters.remember !== 'on' ? '' : 'checked="true"'}/> Remember me</label>
<input type="submit" name="login" value="Login and Save"/> <input type="submit" name="login" value="Login and Save"/>
</form>` : ''} </form>` : ''}
<!--${true ? ` <!--${true ? `
@@ -571,62 +460,6 @@ ${figureHtml}`.trim()),
</form> </form>
` : ''}--> ` : ''}-->
`, 'Settings'); `, 'Settings');
} } ];
else if (section === 'res' && req.method === 'GET' && isEnvServer)
{
// serve static files if it exists and is allowed
const resPath = req.url.split('/res/').slice(1).join('/res/');
const filePath = (__dirname + path.sep + resPath);
if (resFiles.includes(resPath) && fs.existsSync(filePath)) {
res.setHeader('Content-Type', mime.lookup(filePath));
return res.end(fs.readFileSync(filePath));
} else {
return setPageContent(res, '', (res.statusCode = 404));
}
}
else
{
return setPageContent(res, '', (res.statusCode = 404));
}
return setPageContent(res, '', (res.statusCode = 500));
};
// todo handle optional options field(s) main();
const accountDataFromString = (accountString) => {
const tokens = accountString.split(',');
return { instance: tokens[0], username: tokens[1], password: tokens.slice(2).join(',') };
}
const makeFragmentLoggedIn = (accountString) => {
const accountData = accountDataFromString(accountString);
return `<p>Logged in as <i>${accountData.username} @ ${A(accountData.instance)}</i>.</p>`;
}
if (isEnvServer) {
fs = require('fs');
path = require('path');
mime = require('mime-types');
multipart = require('parse-multipart-data');
crypto = require("crypto");
const scriptName = __filename.split(path.sep).slice(-1)[0];
if (process.argv[2] === 'html') {
// dump the base html for static usage
isEnvBrowser = true;
fs.writeFileSync(`${__dirname}${path.sep}${scriptName.split('.').slice(0, -1).join('.')}.html`, newHtmlPage(`
<!-- <script> window.resFilesData = { ${resFiles.map((file) => `"${file}": "${''}"`)} }; </script> -->
<script src="${scriptName}"></script>
`));
} else {
console.log('Running Server...')
resFiles = [scriptName, ...resFiles];
require('http').createServer(handleRequest).listen(serverPort, '0.0.0.0');
}
} else if (isEnvBrowser) {
location.hash ||= '/';
const navigatePage = () => handleRequest({ url: location.hash.slice(1), method: "GET" });
window.onhashchange = () => {
location.hash ||= '/';
navigatePage();
};
navigatePage();
}

View File

@@ -6,7 +6,8 @@
"": { "": {
"dependencies": { "dependencies": {
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0" "parse-multipart-data": "^1.5.0",
"SpaccDotWeb": "gitlab:SpaccInc/SpaccDotWeb"
} }
}, },
"node_modules/mime-db": { "node_modules/mime-db": {
@@ -32,6 +33,14 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz", "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.5.0.tgz",
"integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw==" "integrity": "sha512-ck5zaMF0ydjGfejNMnlo5YU2oJ+pT+80Jb1y4ybanT27j+zbVP/jkYmCrUGsEln0Ox/hZmuvgy8Ra7AxbXP2Mw=="
},
"node_modules/SpaccDotWeb": {
"version": "indev",
"resolved": "git+ssh://git@gitlab.com/SpaccInc/SpaccDotWeb.git#618842883d76136ef78d439addee571c68862dde",
"dependencies": {
"mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0"
}
} }
} }
} }

View File

@@ -1,6 +1,7 @@
{ {
"dependencies": { "dependencies": {
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0" "parse-multipart-data": "^1.5.0",
"SpaccDotWeb": "gitlab:SpaccInc/SpaccDotWeb"
} }
} }