// search "Copyright" in this file for licensing info // configuration const appName = 'WuppìMini'; const serverPort = 8135; const detailedLogging = true; const serverLanUpstreams = false; const serverPlaintextUpstreams = false; let resFiles = [ 'package.json', 'package-lock.json' ]; const appTerms = `

(These terms apply to the server-hosted version of the app only.)
This service is offered for free, in the hope that it can be useful, but without any warranty.
For the service to be able to publish your posts, your content is transmitted to our server, which then forwards it to the server of the service you specified at the time of login, operating on your behalf with the credentials you provided.
Usage of the service might be automatically monitored, and the metadata generated by you might be archived for analytics, debugging, or legal reasons, for as long as we see fit. For every web request, this could include: your IP address, your user agent, the time of request, the requested URL. On any request to an upstream server, this could include: the requested URL on the upstream server, your username hash. On request for posting content, this could include: the hash of each text field's content of your post, the metadata of your uploaded files (filename hash, content hash, content length, mime type). Your content itself, and all normal data, is never stored.
You are forbidden from using the service in any way that is damaging to the service itself or our infrastructure, or that is illegal in the jurisdiction this server is hosted in (Italy, Europe).
We reserve the right to ban you from using the service at any time, for any reason, and without any explanation or prior warning.
By continuing with the usage of this site, you declare to understand and agree to these terms.
If you don't agree with these terms, discontinue usage of this site immediately, and instead get the source code to host it yourself, find another instance, or use the local, client-side version.

`; const suggestedTags = [ 'fromWuppiMini' ]; const corsProxies = [ 'corsproxy.io', 'corsproxy.org' ]; const SpaccDotWebServer = require('SpaccDotWeb/SpaccDotWeb.Server.js'); let crypto; let isEnvServer = SpaccDotWebServer.envIsNode; let isEnvBrowser = SpaccDotWebServer.envIsBrowser; const httpCodes = { success: [200,201] }; const strings = { csrfErrorHtml: `

Authorization token mismatch. Please try resubmitting.

`, upstreamDisallowedHtml: `

Upstream destination is not allowed from backend.

`, }; const appPager = (content, title) => `${title ? `

${title}

` : ''}${content}`; const newHtmlPage = (content, title) => ` ${title ? `${title} — ` : ''}${appName} ${isEnvBrowser ? `
` : ''}
${appPager(content, title)}
`; const A = (href) => `${href}` const Log = (type, msg) => ((type !== 'D' || detailedLogging) && console.log(`${type}: ${msg}`)); const checkUpstreamAllowed = (url) => { const [protocol, ...rest] = url.split('://'); const domain = rest[0].split('/')[0].trim(); if (isEnvServer && ( (!serverLanUpstreams && (domain === 'localhost' || !isNaN(domain.replaceAll('.', '').replaceAll(':', '')))) || (!serverPlaintextUpstreams && protocol.toLowerCase() !== 'https') )) { return false; } return true; } // 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) => { if (!isEnvServer) { return ''; } const time = Date.now().toString(); return (accountString ? ` ` : ''); }; const genCsrfToken = (accountString, time) => (isEnvServer && time && crypto.scryptSync(accountString, time, 32).toString('base64')); const matchCsrfToken = (bodyParams, accountString) => (isEnvServer ? bodyParams.formToken === genCsrfToken(accountString, bodyParams.formTime) : true); const corsProxyIfNeed = (need) => (isEnvBrowser /*&& need*/ ? `https://${corsProxies[~~(Math.random() * corsProxies.length)]}?` : ''); /*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: // 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 the check is successful we return to where we were before, otherwise we show a cookie warning //if (!getCookie(req, '_')) { // flag //} //if (!getCookie(req)) { // return redirectTo('/thecookieflagthingy', res); //} if (req.method === 'HEAD') { req.method = 'GET'; }; };*/ // todo handle optional options field(s) 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 `

Logged in as ${accountData.username} @ ${A(accountData.instance)}.

`; } const main = () => { if (SpaccDotWebServer.envIsNode && process.argv[2] !== 'html') { resFiles = [__filename.split(require('path').sep).slice(-1)[0], ...resFiles]; }; const server = SpaccDotWebServer.setup({ appName: appName, staticPrefix: '/res/', staticFiles: resFiles, appPager: appPager, htmlPager: newHtmlPage, }); if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') { server.writeStaticHtml(); } else { if (SpaccDotWebServer.envIsNode) { crypto = require('crypto'); console.log('Running Server...'); }; server.initServer({ port: serverPort, address: '0.0.0.0', 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') ? `

You must login first. Go to Settings to continue.

` : ''}

About

${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).
Mainly aimed at old systems that might not support modern web-apps, the server-hosted version of this application works without any client-side scripts, and should be optionally reachable via unencrypted HTTP.
About practical use cases, you ask? I made this to upload game posts from my 3DS, and possibly microblog with my Kindle! (See an example: this post was published from my n3DS.)

Check out all my other web endeavors at ${A('https://hub.octt.eu.org')}, or join my Matrix space to chat or if you need help: ${A('https://matrix.to/#/#Spacc:matrix.org')}.

Versions

This app uses a novel approach behind the scenes to be able to run in one of either two modes, while reusing a single codebase: a classical server-side-rendered application, which works well on very limited systems but requires connection with a dedicated backend server that runs it, or a modern client-side single-page-application, relying on many modern web technologies, but working without an hosting server. Occasional bugs or update delays aside, the two essentially have feature parity and the same interface, but can be useful in different situations. Use whatever you prefer in each possible situation.

Open-Source, Licensing, Disclaimers

Copyright (C) 2024 OctoSpacc
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see ${A('https://www.gnu.org/licenses/')}.

${isEnvServer ? `You can obtain the full source code and assets by downloading the following files: ${resFiles.map((file) => ` • ${file}`).join('')}. ` : 'To get the original, unminified source code, visit this same page on the server-side version (refer to the Versions section above).'}

${isEnvServer ? `

Terms of Use and Privacy Policy

${appTerms}` : ''}

Changelog

2024-02-24

2024-02-12

2024-02-10

2024-02-09

`); } ]; const endpointCompose = [ (ctx) => (ctx.urlSections[0] === 'compose' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => { let noticeHtml = ''; const accountString = ctx.getCookie('account'); if (!accountString) { return ctx.redirectTo('/'); } ctx.response.statusCode = 200; const postUploadStatus = ((ctx.bodyParameters?.publish && 'publish') || (ctx.bodyParameters?.draft && 'draft')); if (ctx.request.method === 'POST' && postUploadStatus) { if (!matchCsrfToken(ctx.bodyParameters, accountString)) { ctx.response.statusCode = 401; noticeHtml = strings.csrfErrorHtml; } const isThereAnyFile = ((ctx.bodyParameters.file?.data?.length || ctx.bodyParameters.file?.size) > 0); if (!ctx.bodyParameters.text?.trim() && !isThereAnyFile) { ctx.response.statusCode = 500; noticeHtml = `

Post content is empty. Please write some text or upload a media.

`; } const account = accountDataFromString(accountString); if (!checkUpstreamAllowed(account.instance)) { ctx.response.statusCode = 500; noticeHtml = strings.upstreamDisallowedHtml; } let mediaData; try { // there is a media to upload first if (httpCodes.success.includes(ctx.response.statusCode) && isThereAnyFile) { const mediaReq = await fetch(`${corsProxyIfNeed(account.cors)}${account.instance}/wp-json/wp/v2/media`, { headers: { Authorization: `Basic ${btoa(account.username + ':' + account.password)}`, "Content-Type": ctx.bodyParameters.file.type, "Content-Disposition": `attachment; filename=${ctx.bodyParameters.file.filename}`, }, method: "POST", body: (ctx.bodyParameters.file.data || ctx.bodyParameters.file) }); mediaData = await mediaReq.json(); if (!httpCodes.success.includes(mediaReq.status)) { noticeHtml = `

Upstream server responded with error ${ctx.response.statusCode = mediaReq.status}: ${JSON.stringify(mediaData)}

`; } } // upload actual post if nothing has errored before if (httpCodes.success.includes(ctx.response.statusCode)) { const tagsHtml = (ctx.bodyParameters.tags === 'on' ? `

#${suggestedTags.join(' #')}

` : ''); const figureHtml = `${mediaData?.id && mediaData?.source_url ? `
` : ''}`; const postReq = await fetch(`${corsProxyIfNeed(account.cors)}${account.instance}/wp-json/wp/v2/posts`, { headers: { Authorization: `Basic ${btoa(account.username + ':' + account.password)}`, "Content-Type": "application/json", }, method: "POST", body: JSON.stringify({ status: postUploadStatus, featured_media: mediaData?.id, title: ctx.bodyParameters.title, content: (ctx.bodyParameters.html === 'on' ? `${ctx.bodyParameters.text}${tagsHtml}${figureHtml}` : ` ${ctx.bodyParameters.text?.trim() ? `

${ctx.bodyParameters.text.replaceAll('\r\n', '
')}

` : ''} ${tagsHtml} ${ctx.bodyParameters.text?.trim() && mediaData?.id && mediaData?.source_url ? `

` : ''} ${figureHtml}`.trim()), }) }); const postData = await postReq.json(); if (httpCodes.success.includes(postReq.status)) { noticeHtml = `

${postUploadStatus === 'publish' ? 'Post published' : ''}${postUploadStatus === 'draft' ? 'Draft uploaded' : ''}! ${A(postData.link)}

`; } else { noticeHtml = `

Upstream server responded with error ${ctx.response.statusCode = postReq.status}: ${JSON.stringify(postData)}

`; } } } catch (err) { console.log(err); ctx.response.statusCode = 500; // display only generic error from server-side, for security noticeHtml = `

${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}

`; } // 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 } ctx.renderPage(`${noticeHtml} ${makeFragmentLoggedIn(accountString)}
${makeFormCsrf(accountString)}
`, 'Compose Post'); } ]; const endpointSettings = [ (ctx) => (ctx.urlSections[0] === 'settings' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => { let noticeHtml = ''; const accountString = ctx.getCookie('account'); ctx.response.statusCode = 200; if (ctx.request.method === 'POST') { if (accountString && !matchCsrfToken(ctx.bodyParameters, accountString)) { ctx.response.statusCode = 401; noticeHtml = strings.csrfErrorHtml; } if (ctx.response.statusCode === 200 && ctx.bodyParameters.login) { ctx.bodyParameters.instance = ctx.bodyParameters.instance.trim(); if (!checkUpstreamAllowed(ctx.bodyParameters.instance)) { ctx.response.statusCode = 500; noticeHtml = strings.upstreamDisallowedHtml; } try { const upstreamReq = await fetch(`${corsProxyIfNeed(ctx.bodyParameters.cors === 'on')}${ctx.bodyParameters.instance}/wp-json/wp/v2/users?context=edit`, { headers: { Authorization: `Basic ${btoa(ctx.bodyParameters.username + ':' + ctx.bodyParameters.password)}`, } }); const upstreamData = await upstreamReq.json(); if (upstreamReq.status === 200) { let cookieFlags = (ctx.bodyParameters.remember === 'on' ? `; max-age=${365*24*60*60}` : ''); ctx.setCookie(`account=${ctx.bodyParameters.instance},${ctx.bodyParameters.username},${ctx.bodyParameters.password}${cookieFlags}`); // TODO: add cookie renewal procedure return ctx.redirectTo('/'); } else { ctx.response.statusCode = upstreamReq.status; noticeHtml = `

Upstream server responded with error ${upstreamReq.status}: ${JSON.stringify(upstreamData)}

`; } } catch (err) { console.log(err); ctx.response.statusCode = 500; // display only generic error from server-side, for security noticeHtml = `

${isEnvServer ? 'Some unknown error just happened. Please check that your data is correct, and try again.' : err}

`; } } else if (ctx.response.statusCode === 200 && ctx.bodyParameters.logout) { ctx.setCookie(`account=`); return ctx.redirectTo('/'); } } ctx.renderPage(`${noticeHtml} ${accountString ? `

Current Account

${makeFragmentLoggedIn(accountString)}
${makeFormCsrf(accountString)}
` : '

You must login first.

'} ${!accountString ? `

Login

${makeFormCsrf(accountString)}
` : ''} `, 'Settings'); } ]; main();