2024-07-30 23:29:01 +02:00
#!/usr/bin/env node
2024-02-24 20:27:20 +01:00
// search "Copyright" in this file for licensing info
2024-02-13 00:26:47 +01:00
// configuration
const appName = 'WuppìMini' ;
const serverPort = 8135 ;
const detailedLogging = true ;
const serverLanUpstreams = false ;
const serverPlaintextUpstreams = false ;
2024-07-30 23:29:01 +02:00
const appSelfContained = false ;
let staticFiles = [ 'package.json' , 'package-lock.json' ] ;
let linkStyles = [ 'index.css' ] ;
2024-02-13 00:26:47 +01:00
const appTerms = `
< p > ( These terms apply to the server - hosted version of the app only . )
< br / > This service is offered for free , in the hope that it can be useful , but without any warranty .
< br / > 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 .
< br / > 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 .
< br / > 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 ) .
< br / > We reserve the right to ban you from using the service at any time , for any reason , and without any explanation or prior warning .
< br / > By continuing with the usage of this site , you declare to understand and agree to these terms .
< br / > If you don ' t agree with these terms , discontinue usage of this site immediately , and instead < a href = "/info#h-floss" > get the source code < / a > t o h o s t i t y o u r s e l f , f i n d a n o t h e r i n s t a n c e , o r u s e t h e < a h r e f = " / i n f o # h - v e r s i o n s " > l o c a l , c l i e n t - s i d e v e r s i o n < / a > .
< / p > ` ;
2024-02-24 20:27:20 +01:00
const suggestedTags = [ 'fromWuppiMini' ] ;
2024-07-30 23:29:01 +02:00
const corsProxies = [ 'corsproxy.io?' , 'corsproxy.org?' , 'hlb0.octt.eu.org/cors-main.php/' ] ;
2024-02-13 00:26:47 +01:00
2024-02-24 20:27:20 +01:00
const SpaccDotWebServer = require ( 'SpaccDotWeb/SpaccDotWeb.Server.js' ) ;
2024-07-30 23:29:01 +02:00
var crypto , escapeHtml ;
2024-02-24 20:27:20 +01:00
let isEnvServer = SpaccDotWebServer . envIsNode ;
let isEnvBrowser = SpaccDotWebServer . envIsBrowser ;
2024-07-30 23:29:01 +02:00
const httpCodes = { success : [ 200 , 201 ] } ;
const appPlatforms = {
"wp.org" : { name : "WordPress.org (Community/Self-hosted)" , writing : true } ,
//"greader": { name: "RSS with Google Reader API (FreshRSS, ...)", reading: true },
2024-02-13 00:26:47 +01:00
} ;
2024-07-30 23:29:01 +02:00
const appLanguages = {
en : "🇬🇧️ English" ,
it : "🇮🇹️ Italiano" ,
} ;
const appStrings = {
compose : { en : ` Compose ` , it : ` Componi ` } ,
read : { en : ` Read ` , it : ` Leggi ` } ,
settings : { en : ` Settings ` , it : ` Impostazioni ` } ,
language : { en : ` Language ` , it : ` Lingua ` } ,
composePost : {
en : ` Compose Post ` ,
it : ` Componi Post ` ,
} ,
postTitle : {
en : ` Post Title ` ,
it : ` Titolo del Post ` ,
} ,
includeTags : {
en : ` Include suggested Tags ` ,
it : ` Includi Tag suggeriti ` ,
} ,
postingHint : {
en : ` What's on your mind? ` ,
it : ` A cosa stai pensando? ` ,
} ,
uploadDraft : {
en : ` Upload Draft ` ,
it : ` Carica Bozza ` ,
} ,
publish : {
en : ` Publish! ` ,
it : ` Pubblica! ` ,
} ,
postPublished : {
en : ` Post published ` ,
it : ` Post pubblicato ` ,
} ,
draftUploaded : {
en : ` Draft uploaded ` ,
it : ` Bozza caricata ` ,
} ,
unknownError : {
en : ` An unknown error just happened. Please check that your data is correct, and try again. ` ,
it : ` Si è verificato un errore sconosciuto. Per favore, controlla che i dati inseriti siano corretti, e riprova. ` ,
} ,
upstreamError : {
en : ` Upstream server responded with error ` ,
it : ` Il server di upstream ha risposto con errore ` ,
} ,
csrfError : {
en : ` Authorization token mismatch. Please try resubmitting. ` ,
it : ` Il token di autorizzazione non combacia. Per favore ritenta l'invio. ` ,
} ,
upstreamDisallowed : {
en : ` Upstream destination is not allowed from backend. (Only global HTTPS sites are allowed.) ` ,
it : ` La destinazione di upstream non è permessa dal backend. (Solo i siti HTTPS globali sono permessi.) ` ,
} ,
currentAccounts : {
en : ` Current Accounts ` ,
it : ` Account Correnti ` ,
} ,
addNewAccount : {
en : ` Add New Account ` ,
it : ` Aggiungi Nuovo Account ` ,
} ,
siteInstanceUrl : {
en : ` Site/Instance URL ` ,
it : ` URL Sito/Istanza ` ,
} ,
rememberMe : {
en : ` Remember me ` ,
it : ` Ricordami ` ,
} ,
loginAndSave : {
en : ` Login and Save ` ,
it : ` Login e Salva ` ,
} ,
mustAddAccount : {
en : ( type ) => ` You must add ${ type === 'compose' ? 'a writing' : type === 'read' ? 'a reading' : 'an' } account to continue. Go to <a href="/settings">Settings</a>. ` ,
it : ( type ) => ` Devi aggiungere un account ${ type === 'compose' ? 'di scrittura' : type === 'read' ? 'di lettura' : '' } per continuare. Vai alle <a href="/settings">Impostazioni</a>. ` ,
} ,
accountExists : {
en : ` The account you tried to add is already registered. ` ,
it : ` L'account che hai provato ad aggiungere è già registrato. ` ,
} ,
logoutAccounts : {
en : ` Logout Selected Accounts ` ,
it : ` Logout Account Selezionati ` ,
} ,
noAccountSelected : {
en : ` You haven't selected any account to be removed. ` ,
it : ` Non hai selezionato alcun account da rimuovere. `
} ,
applyLanguage : {
en : ` Apply Language ` ,
it : ` Applica Lingua ` ,
} ,
postEmpty : {
en : ` Post content is empty. Please write some text or upload a media. ` ,
it : ` Il post è vuoto. Per favore scrivi del testo o carica un file. ` ,
} ,
} ;
appStrings . get = ( string , language = 'en' ) => ( appStrings [ string ] [ language ] || Object . values ( appStrings [ string ] ) [ 0 ] ) ;
2024-02-13 00:26:47 +01:00
2024-07-30 23:29:01 +02:00
const appPager = ( content , title , opts = { } , ctx ) => ` <div id="header">
< h1 > < a href = "/" > $ { appName } < / a > < / h 1 >
< a href = "/compose" > $ { appStrings . get ( 'compose' , getUserLanguage ( opts . context ) ) } 📝 ️ < / a >
<!-- $ { ctx . envIsBrowser ? '' : ` <a href="/read"> ${ appStrings . get ( 'read' , getUserLanguage ( opts . context ) ) } 📜️</a> ` } -- >
< a href = "/info" > Info ℹ ️ < / a >
< a href = "/settings" > $ { appStrings . get ( 'settings' , getUserLanguage ( opts . context ) ) } ⚙ ️ < / a >
< / d i v > < d i v i d = " m a i n " > $ { t i t l e ? ` < h 2 > $ { t i t l e } < / h 2 > ` : ' ' } $ { c o n t e n t } < / d i v > ` ;
2024-02-24 20:27:20 +01:00
2024-07-30 23:29:01 +02:00
const htmlPager = ( content , title , opts = { } , ctx ) => ` <!DOCTYPE html><html><head>
2024-02-13 00:26:47 +01:00
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
2024-04-21 00:58:00 +02:00
< meta property = "og:title" content = "${title ? `${title} — ` : ''}${appName}" / >
< meta OctoSpaccHubSdk = "Url" content = "https://hub.octt.eu.org/WuppiMini/" / >
2024-04-27 20:29:04 +02:00
< meta OctoSpaccHubSdk = "WebManifestExtra" content = "'display':'standalone', 'icons':[{ 'src':'./icon.png', 'type':'image/png', 'sizes':'256x256' }]," / >
2024-07-30 23:29:01 +02:00
< title > $ { title ? ` ${ title } — ` : '' } $ { appName } < / t i t l e >
2024-04-27 20:29:04 +02:00
< link rel = "apple-touch-icon" href = "./icon.png" / >
2024-07-30 23:29:01 +02:00
$ { linkStyles . map ( ( path ) => SpaccDotWebServer . makeHtmlStyleFragment ( path , appSelfContained ) ) . join ( '' ) }
2024-02-24 20:27:20 +01:00
< / h e a d > < b o d y > < ! - -
-- > $ { isEnvBrowser ? ` <div id="transition"></div> ` : '' } <!--
2024-07-30 23:29:01 +02:00
-- > < div id = "app" > $ { appPager ( content , title , opts , ctx ) } < / d i v > < ! - -
2024-02-24 20:27:20 +01:00
-- > < / b o d y > < / h t m l > ` ;
2024-02-13 00:26:47 +01:00
2024-07-30 23:29:01 +02:00
const A = ( href ) => ` <a href=" ${ href } "> ${ href } </a> ` ;
2024-02-13 00:26:47 +01:00
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 ? `
< input type = "hidden" name = "formTime" value = "${time}" / >
< input type = "hidden" name = "formToken" value = "${genCsrfToken(accountString, time)}" / >
` : '');
} ;
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 ) ;
2024-07-30 23:29:01 +02:00
const corsProxyIfNeed = ( /*need*/ ) => ( isEnvBrowser /*&& need*/ ? ` https:// ${ corsProxies [ ~ ~ ( Math . random ( ) * corsProxies . length ) ] } ` : '' ) ;
2024-02-13 00:26:47 +01:00
2024-02-24 20:27:20 +01:00
/ * c o n s t h a n d l e R e q u e s t = a s y n c ( r e q , r e s = { } ) = > {
// TODO warn if the browser has cookies disabled when running on server side
2024-02-13 00:26:47 +01:00
// 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' ;
2024-02-24 20:27:20 +01:00
} ;
} ; * /
2024-07-30 23:29:01 +02:00
const getUserLanguage = ( ctx ) => ( ctx . getCookie ? . ( 'language' ) || ctx . clientLanguages ? . [ 0 ] ? . split ( '-' ) [ 0 ] ) ;
2024-02-24 20:27:20 +01:00
2024-07-30 23:29:01 +02:00
//const getAccountsData = (ctx) => accountsDataFromCookieString(ctx.getCookie('account'));
const getPlatformActionIcon = platform => ( platform . writing && platform . reading ? '💱️'
: ( platform . writing && '📤️' ) || ( platform . reading && '📥️' ) ) ;
2024-02-24 20:27:20 +01:00
2024-07-30 23:29:01 +02:00
const makeAccountFormId = ( account ) => ( btoa ( account . username ) + ":" + btoa ( account . instance ) ) ;
const accountDataFromFormId = ( accountsData , accountId ) => accountsData . filter ( account => {
let [ username , instance ] = accountId . split ( ':' ) ;
username = atob ( username ) ;
instance = atob ( instance ) ;
return accountsData . filter ( account => ( account . username === username && account . instance === instance ) ) [ 0 ] ;
} ) [ 0 ] ;
const accountsDataFromCookieString = ( accountString ) => {
const accounts = [ ] ;
if ( ! accountString ) {
return accounts ;
}
// old format, kept in just to support old sessions
let tokens = accountString . split ( ',' ) ;
if ( tokens . length >= 3 && [ 'http' , 'https' ] . includes ( accountString . split ( '://' ) [ 0 ] . toLowerCase ( ) ) ) {
return [ { platform : "wp.org" , instance : tokens [ 0 ] , username : tokens [ 1 ] , password : tokens . slice ( 2 ) . join ( ',' ) , options : { } } ] ;
}
// new format
for ( const account of accountString . split ( ',' ) ) {
const tokens = account . split ( ':' ) ;
const options = { } ;
for ( const option of tokens . slice ( 4 ) ) {
const [ key , value ] = option . split ( '=' ) ;
options [ key ] = ( value || true ) ;
}
accounts . push ( {
platform : tokens [ 0 ] ,
instance : atob ( tokens [ 1 ] ) ,
username : atob ( tokens [ 2 ] ) ,
password : atob ( tokens [ 3 ] ) ,
options ,
} ) ;
}
return accounts ;
} ;
const accountsCookieStringFromData = ( accountData ) => {
let accounts = '' ;
for ( const account of [ ] . concat ( accountData ) ) {
accounts += ` , ${ account . platform } : ${ btoa ( account . instance ) } : ${ btoa ( account . username ) } : ${ btoa ( account . password ) } ` ;
for ( const [ key , value ] of Object . entries ( account . options ) ) {
accounts += ` : ${ key } ` + ( value === true ? '' : ` = ${ value } ` ) ;
}
}
return accounts . slice ( 1 ) ;
} ;
const makeCookieFlags = ( opts ) => ( ( opts === true || opts . remember === 'on' ) ? ` ; max-age= ${ 365 * 24 * 60 * 60 } ` : '' ) ;
const main = async ( ) => {
if ( isEnvServer && process . argv [ 2 ] !== 'writeStaticHtml' ) {
staticFiles = [ _ _filename . split ( require ( 'path' ) . sep ) . slice ( - 1 ) [ 0 ] , ... staticFiles ] ;
2024-02-24 20:27:20 +01:00
} ;
const server = SpaccDotWebServer . setup ( {
appName : appName ,
staticPrefix : '/res/' ,
2024-07-30 23:29:01 +02:00
staticFiles , linkStyles ,
appPager , htmlPager ,
2024-02-24 20:27:20 +01:00
} ) ;
2024-07-30 23:29:01 +02:00
if ( isEnvServer && process . argv [ 2 ] === 'writeStaticHtml' ) {
server . writeStaticHtml ( appSelfContained ) ;
2024-02-24 20:27:20 +01:00
} else {
2024-07-30 23:29:01 +02:00
escapeHtml = ( await require ( 'escape-html/index.js' ) || window . escapeHtml ) ;
if ( isEnvServer ) {
2024-02-24 20:27:20 +01:00
crypto = require ( 'crypto' ) ;
2024-07-30 23:29:01 +02:00
console . log ( ` Running Server on : ${ serverPort } ... ` ) ;
2024-02-24 20:27:20 +01:00
} ;
server . initServer ( {
port : serverPort ,
address : '0.0.0.0' ,
maxBodyUploadSize : 4e6 , // 4 MB
2024-07-30 23:29:01 +02:00
endpoints : [ endpointRoot , endpointInfo , /* endpointHub, */ endpointCompose , /* endpointRead, */ endpointSettings , endpointCatch ] ,
2024-02-24 20:27:20 +01:00
} ) ;
} ;
} ;
2024-07-30 23:29:01 +02:00
// TODO fix this for now that we have 2 sections but may not always have the needed account
const endpointRoot = [ ( ctx ) => ( ! ctx . urlSections [ 0 ] ) , ( ctx ) => {
const account = accountsDataFromCookieString ( ctx . getCookie ( 'account' ) ) ? . [ 0 ] ;
ctx . redirectTo ( ( account ? ( appPlatforms [ account . platform ] . writing ? '/compose' : '/read' ) : '/info' ) + ` ? ${ ctx . urlQuery } ` ) ;
} ] ;
2024-02-24 20:27:20 +01:00
2024-07-30 23:29:01 +02:00
const endpointCatch = [ ( ctx ) => true , ( ctx ) => ctx . renderPage ( '<p>This page does not exist. <a href="/">Go back to the home page</a>.</p>' , ( ctx . response . statusCode = 404 ) ) ] ;
2024-02-24 20:27:20 +01:00
2024-07-30 23:29:01 +02:00
const endpointInfo = [ 'GET /info/' , ( ctx ) => {
2024-02-24 20:27:20 +01:00
ctx . response . statusCode = 200 ;
ctx . renderPage ( `
2024-07-30 23:29:01 +02:00
$ { ! ctx . getCookie ( 'account' ) ? ` <p class="notice info"> ${ appStrings . get ( 'mustAddAccount' , getUserLanguage ( ctx ) ) ( ctx . urlParameters . ref ) } </p> ` : '' }
2024-02-24 20:27:20 +01:00
< h3 > About < / h 3 >
< 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 ) .
< br / >
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 .
< br / >
About practical use cases , you ask ? I made this to upload game posts from my 3 DS , and possibly microblog with my Kindle ! ( See an example : < a href = "https://octospacc.altervista.org/2024/02/09/test-wuppimini/" > this post < / a > w a s p u b l i s h e d f r o m m y n 3 D S . )
< br / > < br / >
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' ) } .
< / p >
< h3 id = "h-versions" > Versions < / h 3 >
< p >
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 .
< / p >
< ul >
< li > Server - hosted version : $ { A ( 'https://wuppimini.octt.eu.org/' ) } . < / l i >
< li > Client - side version : $ { A ( 'https://hub.octt.eu.org/WuppiMini/' ) } . < / l i >
< / u l >
< h3 id = "h-floss" > Open - Source , Licensing , Disclaimers < / h 3 >
< p >
Copyright ( C ) 2024 OctoSpacc
< br / >
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 .
< br / >
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 .
< br / >
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/' ) } .
< / p >
< p >
$ { isEnvServer ? ` You can obtain the full source code and assets by downloading the following files:
2024-07-30 23:29:01 +02:00
$ { staticFiles . map ( file => ` • <a href="/res/ ${ file } "> ${ file } </a> ` ) . join ( '' ) } .
2024-02-24 20:27:20 +01:00
` : 'To get the original, unminified source code, visit this same page on the server-side version (refer to the Versions section above).'}
2024-04-19 00:34:22 +02:00
Alternatively , you can also find the source code on my shared Git repo : $ { A ( 'https://gitlab.com/octospacc/octospacc.gitlab.io/-/tree/master/source/WuppiMini/' ) } .
2024-02-24 20:27:20 +01:00
< / p >
$ { isEnvServer ? ` <h3>Terms of Use and Privacy Policy</h3> ${ appTerms } ` : '' }
< h3 > Changelog < / h 3 >
2024-07-30 23:29:01 +02:00
< h4 > 2024 - 07 - 15 ( deployed 2024 - 07 - 30 ) < / h 4 > < u l >
<!-- < li > New Read function , connecting to RSS servers via Google Reader API . < / l i > - - >
< li > Multi - account support ( required changes to the cookie format ) , allowing for selection of destination in Compose and filtering of sources in Read . < / l i >
< li > New multi - language support , using browser language as a default and allowing change in Settings . < / l i >
< li > Slight UX improvements and bugfixes to existing forms and screens . < / l i >
< li > Made a preview of the Compose screen visible before logging - in . < / l i >
< / u l >
< h4 > 2024 - 02 - 24 < / h 4 > < u l >
2024-02-24 20:27:20 +01:00
< li > Allow uploading posts as published or draft , via 2 distinct buttons . < / l i >
< li > Migrated fancy portable - server - codebase to < a href = "https://gitlab.com/SpaccInc/SpaccDotWeb" > SpaccDotWeb < / a > f o r c o d e r e u s e a n d s l i m m i n g d o w n o f t h e a p p l i c a t i o n c o r e . < / l i >
< / u l >
2024-07-30 23:29:01 +02:00
< h4 > 2024 - 02 - 12 < / h 4 > < u l >
2024-02-24 20:27:20 +01:00
< li > First working client - side version of the current app , without backend server ( still a bit buggy ) . < / l i >
< li > Fixed suggested tags handling not working and making the post error out , by instead simply writing them in the post body < / l i >
< / u l >
2024-07-30 23:29:01 +02:00
< h4 > 2024 - 02 - 10 < / h 4 > < u l >
2024-02-24 20:27:20 +01:00
< li > Add "remember me" login option . < / l i >
< li > Add "suggested tags" publishing option , will automatically add this list of tags to the post : [ $ { suggestedTags } ] . < / l i >
< / u l >
2024-07-30 23:29:01 +02:00
< h4 > 2024 - 02 - 09 < / h 4 > < u l >
2024-02-24 20:27:20 +01:00
< li > First working version , with an UI reminiscent of [ that dead social network that rhymes with Meterse ] , and Info , Settings , and Composition pages ! < / l i >
< li > Allow logging in with a WordPress . org profile , and creating new posts , including uploading images . < / l i >
< li > Add licensing and proper source code listing . < / l i >
< li > Tested on New and Old 3 DS . < / l i >
< / u l >
` );
} ] ;
2024-07-30 23:29:01 +02:00
const endpointCompose = [ 'GET|POST /compose/' , async ( ctx ) => {
let [ noticeErrorHtml , noticeSuccessHtml ] = [ '' , '' ] ;
const language = getUserLanguage ( ctx ) ;
2024-02-24 20:27:20 +01:00
const accountString = ctx . getCookie ( 'account' ) ;
2024-07-30 23:29:01 +02:00
const accounts = accountsDataFromCookieString ( accountString ) ? . filter ( account => appPlatforms [ account . platform ] . writing ) ; //getAccountsData(ctx);
2024-02-24 20:27:20 +01:00
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 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'csrfError' , language ) ;
2024-02-13 00:26:47 +01:00
}
2024-02-24 20:27:20 +01:00
const isThereAnyFile = ( ( ctx . bodyParameters . file ? . data ? . length || ctx . bodyParameters . file ? . size ) > 0 ) ;
if ( ! ctx . bodyParameters . text ? . trim ( ) && ! isThereAnyFile ) {
ctx . response . statusCode = 500 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'postEmpty' , language ) ;
2024-02-13 00:26:47 +01:00
}
2024-07-30 23:29:01 +02:00
const account = accountDataFromFormId ( accounts , ctx . account ) ; //accountsDataFromCookieString(accountString)[0];
2024-02-24 20:27:20 +01:00
if ( ! checkUpstreamAllowed ( account . instance ) ) {
ctx . response . statusCode = 500 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'upstreamDisallowed' , language ) ;
2024-02-24 20:27:20 +01:00
}
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 : {
2024-07-30 23:29:01 +02:00
... getBackendHeaders ( account ) ,
2024-02-24 20:27:20 +01:00
"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 ) ) {
2024-07-30 23:29:01 +02:00
noticeErrorHtml = ` ${ appStrings . get ( 'upstreamError' , language ) } ${ ctx . response . statusCode = mediaReq . status } : ${ escapeHtml ( JSON . stringify ( mediaData ) ) } ` ;
2024-02-13 00:26:47 +01:00
}
2024-02-24 20:27:20 +01:00
}
// upload actual post if nothing has errored before
if ( httpCodes . success . includes ( ctx . response . statusCode ) ) {
const tagsHtml = ( ctx . bodyParameters . tags === 'on' ? `
2024-02-13 00:26:47 +01:00
<!-- wp : paragraph -- >
< p > # $ { suggestedTags . join ( ' #' ) } < / p >
<!-- / w p : p a r a g r a p h - - >
` : '');
2024-02-24 20:27:20 +01:00
const figureHtml = ` ${ mediaData ? . id && mediaData ? . source _url ? `
2024-02-13 00:26:47 +01:00
<!-- wp : image { "id" : $ { mediaData . id } , "sizeSlug" : "large" } -- >
< figure class = "wp-block-image size-large" > < img src = "${mediaData.source_url}" class = "wp-image-${mediaData.id}" / > < / f i g u r e >
<!-- / w p : i m a g e - - >
` : ''} ` ;
2024-02-24 20:27:20 +01:00
const postReq = await fetch ( ` ${ corsProxyIfNeed ( account . cors ) } ${ account . instance } /wp-json/wp/v2/posts ` , { headers : {
2024-07-30 23:29:01 +02:00
... getBackendHeaders ( account ) ,
2024-02-24 20:27:20 +01:00
"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 ( ) ? `
2024-02-13 00:26:47 +01:00
<!-- wp : paragraph -- >
2024-02-24 20:27:20 +01:00
< p > $ { ctx . bodyParameters . text . replaceAll ( '\r\n' , '<br/>' ) } < / p >
2024-02-13 00:26:47 +01:00
<!-- / w p : p a r a g r a p h - - >
` : ''}
$ { tagsHtml }
2024-02-24 20:27:20 +01:00
$ { ctx . bodyParameters . text ? . trim ( ) && mediaData ? . id && mediaData ? . source _url ? `
2024-02-13 00:26:47 +01:00
<!-- wp : paragraph -- >
< p > < / p >
<!-- / w p : p a r a g r a p h - - >
` : ''}
$ { figureHtml } ` .trim()),
2024-02-24 20:27:20 +01:00
} ) } ) ;
const postData = await postReq . json ( ) ;
if ( httpCodes . success . includes ( postReq . status ) ) {
2024-07-30 23:29:01 +02:00
noticeSuccessHtml = ` ${ appStrings . get ( ( postUploadStatus === 'publish' ? 'postPublished' : postUploadStatus === 'draft' ? 'draftUploaded' : '' ) , language ) } ! ${ A ( postData . link ) } ` ;
2024-02-24 20:27:20 +01:00
} else {
2024-07-30 23:29:01 +02:00
noticeErrorHtml = ` ${ appStrings . get ( 'upstreamError' , language ) } ${ ctx . response . statusCode = postReq . status } : ${ escapeHtml ( JSON . stringify ( postData ) ) } ` ;
2024-02-13 00:26:47 +01:00
}
}
2024-02-24 20:27:20 +01:00
} catch ( err ) {
console . log ( err ) ;
ctx . response . statusCode = 500 ;
// display only generic error from server-side, for security
2024-07-30 23:29:01 +02:00
noticeErrorHtml = ( isEnvServer ? appStrings . get ( 'unknownError' , language ) : err ) ;
2024-02-13 00:26:47 +01:00
}
2024-02-24 20:27:20 +01:00
// 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
2024-02-13 00:26:47 +01:00
}
2024-07-30 23:29:01 +02:00
const disabledHtml = ( ! Object . keys ( accounts ) . length ? 'disabled="true"' : '' ) ;
ctx . renderPage ( `
$ { disabledHtml ? ` <p class="notice info"> ${ appStrings . get ( 'mustAddAccount' , getUserLanguage ( ctx ) ) ( 'compose' ) } </p> ` : '' }
$ { noticeErrorHtml ? ` <p class="notice error"> ${ noticeErrorHtml } </p> ` : '' }
$ { noticeSuccessHtml ? ` <p class="notice success"> ${ noticeSuccessHtml } </p> ` : '' }
2024-02-24 20:27:20 +01:00
< form method = "POST" enctype = "multipart/form-data" > $ { makeFormCsrf ( accountString ) }
2024-07-30 23:29:01 +02:00
< select name = "account" $ { disabledHtml } > $ { accounts . map ( account => ` <option value=" ${ makeAccountFormId ( account ) } ">
$ { account . username } @ $ { account . instance } ( $ { account . platform } )
< / o p t i o n > ` ) . j o i n ( ' ' ) } < / s e l e c t >
< input type = "text" name = "title" placeholder = "${appStrings.get('postTitle', language)}" value = "${ctx.bodyParameters.title && ctx.response.statusCode !== 200 ? ctx.bodyParameters.title : ''}" $ { disabledHtml } / >
< input type = "file" accept = "image/jpeg,image/gif,image/png,image/webp,image/bmp" name = "file" $ { disabledHtml } / >
< textarea name = "text" rows = "10" placeholder = "${appStrings.get('postingHint', language)}" $ { disabledHtml } > $ { ctx . bodyParameters . text && ctx . response . statusCode !== 200 ? ctx . bodyParameters . text : '' } < / t e x t a r e a >
2024-02-24 20:27:20 +01:00
<!-- TODO : fix the turn off - on on submit of these checkboxes ... -- >
2024-07-30 23:29:01 +02:00
< label > < input type = "checkbox" name = "html" $ { ctx . bodyParameters . html === 'on' && ctx . response . statusCode !== 200 ? 'checked="true"' : '' } $ { disabledHtml } / > Raw HTML mode < / l a b e l >
< label > < input type = "checkbox" name = "tags" $ { ctx . request . method === 'GET' || ( ctx . bodyParameters . tags === 'on' && ctx . response . statusCode !== 200 ) ? 'checked="true"' : '' } $ { disabledHtml } / > $ { appStrings . get ( 'includeTags' , language ) } < / l a b e l >
< input type = "submit" name = "draft" value = "${appStrings.get('uploadDraft', language)}" $ { disabledHtml } / >
< input type = "submit" name = "publish" value = "${appStrings.get('publish', language)}" $ { disabledHtml } / >
< / f o r m >
` , appStrings.get('composePost', language));
} ] ;
const endpointRead = [ 'GET /read/' , async ( ctx ) => {
const accountString = ctx . getCookie ( 'account' ) ;
const accounts = accountsDataFromCookieString ( accountString ) ? . filter ( account => appPlatforms [ account . platform ] . reading ) ;
if ( ! accounts || ! Object . keys ( accounts ) . length ) {
return ctx . redirectTo ( '/?ref=read' ) ;
}
ctx . response . statusCode = 200 ;
const [ accountIndex , channelIndex ] = ctx . urlSections . slice ( 1 ) ;
if ( accountIndex && channelIndex ) {
const account = accounts [ accountIndex ] ;
const postsReq = await fetch ( ` ${ corsProxyIfNeed ( ) } ${ account . instance } /api/greader.php/reader/api/0/stream/contents/feed/ ${ channelIndex } ?output=json ` /*`reading-list?output=json`*/ , { headers : getBackendHeaders ( account ) } ) ;
const postsData = await postsReq . json ( ) ;
ctx . renderPage ( ` <div class="posts"> ${ postsData . items . map ( post => ` <article>
< header >
< b > < a href = "${post.origin.htmlUrl}" > $ { post . origin . title } < / a > < / b >
< a href = "${post.canonical[0].href}" > $ { post . published } < / a >
< h3 > $ { post . title } < / h 3 >
< / h e a d e r >
< p > $ { post . summary . content } < / p >
< / a r t i c l e > ` ) . j o i n ( ' ' ) } < / d i v > ` ) ;
} else {
//if (ctx.request.method === 'POST') {
// // TODO handle reading of selected accounts, folders, other options
//}
const account = accounts [ 0 ] ;
const channelsReq = await fetch ( ` ${ corsProxyIfNeed ( ) } ${ account . instance } /api/greader.php/reader/api/0/subscription/list?output=json ` , { headers : getBackendHeaders ( account ) } ) ;
const channelsData = await channelsReq . json ( ) ;
const unreadReq = await fetch ( ` ${ corsProxyIfNeed ( ) } ${ account . instance } /api/greader.php/reader/api/0/unread-count?output=json ` , { headers : getBackendHeaders ( account ) } ) ;
const unreadData = await unreadReq . json ( ) ;
const unreads = { } ;
for ( const unread of unreadData . unreadcounts ) {
unreads [ unread . id ] = unread . count ;
}
const folders = { } ;
for ( const channel of channelsData . subscriptions ) {
const category = channel . categories [ 0 ] ;
folders [ category . id . slice ( 'user/-/label/' . length ) ] = category . label ;
}
ctx . renderPage ( `
< form >
< select name = "account" multiple = "true" > $ { Object . entries ( accounts ) . map ( account => ` <option value=" ${ account [ 0 ] /*makeAccountFormId(account)*/ } " ${ ( ! ctx . bodyParameters . account || ctx . bodyParameters . account . includes ( account [ 0 ] ) ) ? 'selected="true"' : '' } >
$ { account [ 1 ] . username } @ $ { account [ 1 ] . instance } ( $ { account [ 1 ] . platform } )
< / o p t i o n > ` ) . j o i n ( ' ' ) } < / s e l e c t >
< select name = "folder" multiple = "true" > $ { Object . entries ( folders ) . map ( folder => ` <option value=" ${ 0 } / ${ folder [ 0 ] } " selected="true">
$ { folder [ 1 ] }
< / o p t i o n > ` ) . j o i n ( ' ' ) } < / s e l e c t >
2024-02-24 20:27:20 +01:00
< / f o r m >
2024-07-30 23:29:01 +02:00
< div class = "channels" > $ { channelsData . subscriptions . map ( channel => ` <a href="/read/ ${ 0 } / ${ channel . id . split ( '/' ) [ 1 ] } ">
< img src = "${channel.iconUrl}" / > $ { channel . title } $ { unreads [ channel . id ] ? ` <span> ${ unreads [ channel . id ] } </span> ` : '' }
< / a > ` ) . j o i n ( ' ' ) } < / d i v >
` , 'Channels');
}
2024-02-24 20:27:20 +01:00
} ] ;
2024-07-30 23:29:01 +02:00
const endpointSettings = [ 'GET|POST /settings/' , async ( ctx ) => {
let [ noticeErrorHtml ] = [ '' ] ;
const language = getUserLanguage ( ctx ) ;
2024-02-24 20:27:20 +01:00
const accountString = ctx . getCookie ( 'account' ) ;
2024-07-30 23:29:01 +02:00
const accounts = accountsDataFromCookieString ( accountString ) ; //getAccountsData(ctx);
2024-02-24 20:27:20 +01:00
ctx . response . statusCode = 200 ;
if ( ctx . request . method === 'POST' ) {
if ( accountString && ! matchCsrfToken ( ctx . bodyParameters , accountString ) ) {
ctx . response . statusCode = 401 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'csrfError' , language ) ;
} else if ( ctx . bodyParameters . language ) {
ctx . setCookie ( ` language= ${ ctx . bodyParameters . language } ${ makeCookieFlags ( true ) } ` ) ;
return ctx . redirectTo ( '/settings' ) ; // TODO remove, just a workaround to the server not updating readable cookies by itself
} else if ( ctx . bodyParameters . login ) {
2024-02-24 20:27:20 +01:00
ctx . bodyParameters . instance = ctx . bodyParameters . instance . trim ( ) ;
if ( ! checkUpstreamAllowed ( ctx . bodyParameters . instance ) ) {
ctx . response . statusCode = 500 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'upstreamDisallowed' , language ) ;
} else if ( accounts . filter ( account => ( makeAccountFormId ( account ) === makeAccountFormId ( ctx . bodyParameters ) ) ) . length ) {
ctx . response . statusCode = 500 ;
noticeErrorHtml = appStrings . get ( 'accountExists' , language ) ;
} else {
const loginResult = await loginBackend ( ctx . bodyParameters , language ) ;
if ( loginResult . success ) {
ctx . setCookie ( ` account= ${ accountsCookieStringFromData ( [ ... accounts , {
... ctx . bodyParameters ,
options : { remember : ( ctx . bodyParameters . remember === 'on' ) } ,
... loginResult . data ,
} ] ) } $ { makeCookieFlags ( ctx . bodyParameters ) } ` ); // TODO: add cookie renewal procedure
}
if ( loginResult . code ) {
ctx . response . statusCode = loginResult . code ;
}
if ( loginResult . error ) {
noticeErrorHtml = loginResult . error ;
}
if ( loginResult . redirect ) {
return ctx . redirectTo ( loginResult . redirect ) ;
}
2024-02-13 00:26:47 +01:00
}
2024-07-30 23:29:01 +02:00
} else if ( ctx . bodyParameters . logout ) {
if ( ctx . bodyParameters . account ) {
for ( const accountId of [ ] . concat ( ctx . bodyParameters . account ) ) {
const formAccount = JSON . stringify ( accountDataFromFormId ( accounts , accountId ) ) ;
for ( const dataAccountIndex in accounts ) {
if ( formAccount === JSON . stringify ( accounts [ dataAccountIndex ] ) ) {
accounts . splice ( dataAccountIndex , 1 ) ;
}
}
2024-02-13 00:26:47 +01:00
}
2024-07-30 23:29:01 +02:00
ctx . setCookie ( ` account= ${ accountsCookieStringFromData ( accounts ) } ${ makeCookieFlags ( ctx . bodyParameters ) } ` ) ;
return ctx . redirectTo ( '/' ) ;
} else {
2024-02-24 20:27:20 +01:00
ctx . response . statusCode = 500 ;
2024-07-30 23:29:01 +02:00
noticeErrorHtml = appStrings . get ( 'noAccountSelected' , language ) ;
2024-02-13 00:26:47 +01:00
}
}
}
2024-07-30 23:29:01 +02:00
const mustRememberAccount = accounts ? . slice ( - 1 ) ? . [ 0 ] ? . options ? . remember ;
const formSharedHtml = ` ${ mustRememberAccount ? '<input type="hidden" name="remember" value="on"/>' : '' } ` ;
ctx . renderPage ( `
$ { noticeErrorHtml ? ` <p class="notice error"> ${ noticeErrorHtml } </p> ` : '' }
2024-02-24 20:27:20 +01:00
< form method = "POST" > $ { makeFormCsrf ( accountString ) }
2024-07-30 23:29:01 +02:00
< h3 > $ { appStrings . get ( 'language' , language ) } < / h 3 >
< select name = "language" > $ { Object . entries ( appLanguages ) . map ( lang => ` <option value=" ${ lang [ 0 ] } " ${ language === lang [ 0 ] ? 'selected="true"' : '' } > ${ lang [ 1 ] } </option> ` ) . join ( '' ) } < / s e l e c t >
< input type = "submit" value = "${appStrings.get('applyLanguage', language)}" / >
$ { formSharedHtml } < / f o r m >
$ { accountString ? ` <h3> ${ appStrings . get ( 'currentAccounts' , language ) } </h3>
< form method = "POST" > $ { makeFormCsrf ( accountString ) }
$ { accounts . map ( account => ` <label><input type="checkbox" name="account" value=" ${ makeAccountFormId ( account ) } "/>
$ { getPlatformActionIcon ( appPlatforms [ account . platform ] ) }
$ { account . username } @ $ { account . instance } ( $ { account . platform } ) < / l a b e l > ` ) . j o i n ( ' ' ) }
< input type = "submit" name = "logout" value = "${appStrings.get('logoutAccounts', language)}" / >
$ { formSharedHtml } < / f o r m >
` : ''}
< h3 > $ { appStrings . get ( 'addNewAccount' , language ) } < / h 3 >
2024-02-24 20:27:20 +01:00
< form method = "POST" > $ { makeFormCsrf ( accountString ) }
2024-07-30 23:29:01 +02:00
< select name = "platform" > $ { Object . entries ( appPlatforms ) . map ( platform => ` <option value=" ${ platform [ 0 ] } " ${ ctx . bodyParameters . platform === platform [ 0 ] ? 'selected="true"' : '' } >
$ { getPlatformActionIcon ( platform [ 1 ] ) } $ { platform [ 1 ] . name }
< / o p t i o n > ` ) . j o i n ( ' ' ) } < / s e l e c t >
< p > < i >
Note : For WordPress . org you must use an "application password"
( < code > / w p - a d m i n / p r o f i l e . p h p # a p p l i c a t i o n - p a s s w o r d s - s e c t i o n < / c o d e > )
< / i > < / p >
< input type = "url" name = "instance" placeholder = "${appStrings.get('siteInstanceUrl', language)}" value = "${ctx.bodyParameters.instance || ''}" required = "true" / >
2024-02-24 20:27:20 +01:00
< input type = "text" name = "username" placeholder = "Username" value = "${ctx.bodyParameters.username || ''}" required = "true" / >
< input type = "password" name = "password" placeholder = "Password" value = "${ctx.bodyParameters.password || ''}" required = "true" / >
2024-07-30 23:29:01 +02:00
< label >
< input type = "checkbox" name = "remember" $ { ctx . request . method === 'POST'
? ( ctx . bodyParameters . remember === 'on' ? 'checked="true"' : '' )
: ( mustRememberAccount ? 'checked="true"' : '' ) }
$ { accounts ? . length > 0 ? 'disabled="true"' : '' }
/ > $ { a p p S t r i n g s . g e t ( ' r e m e m b e r M e ' , l a n g u a g e ) } < / l a b e l >
< input type = "submit" name = "login" value = "${appStrings.get('loginAndSave', language)}" / >
$ { formSharedHtml } < / f o r m >
` , appStrings.get('settings', language));
2024-02-24 20:27:20 +01:00
} ] ;
2024-02-13 00:26:47 +01:00
2024-07-30 23:29:01 +02:00
const getBackendHeaders = ( account ) => {
switch ( account . platform ) {
case 'wp.org' :
return { "Authorization" : ` Basic ${ btoa ( account . username + ':' + account . password ) } ` } ;
break ;
case 'greader' :
return { "Authorization" : ` GoogleLogin auth= ${ account . username } / ${ account . password } ` } ;
break ;
}
} ;
const loginBackend = async ( loginData , language ) => {
const result = { } ;
let upstreamReq , upstreamData ;
try {
switch ( loginData . platform ) {
case 'wp.org' :
upstreamReq = await fetch ( ` ${ corsProxyIfNeed ( /*loginData.cors === 'on'*/ ) } ${ loginData . instance } /wp-json/wp/v2/users?context=edit ` , { headers : getBackendHeaders ( loginData ) } ) ;
upstreamData = await upstreamReq . json ( ) ;
if ( upstreamReq . status === 200 ) {
result . success = true ;
result . redirect = '/' ;
} else {
result . code = upstreamReq . status ;
result . error = ` ${ appStrings . get ( 'upstreamError' , language ) } ${ upstreamReq . status } : ${ escapeHtml ( JSON . stringify ( upstreamData ) ) } ` ;
}
break ;
case 'greader' :
upstreamReq = await fetch ( ` ${ corsProxyIfNeed ( ) } ${ loginData . instance } /api/greader.php/accounts/ClientLogin?Email= ${ encodeURIComponent ( loginData . username ) } &Passwd= ${ encodeURIComponent ( loginData . password ) } ` ) ;
upstreamData = await upstreamReq . text ( ) ;
if ( upstreamReq . status === 200 ) {
const authToken = upstreamData . split ( '\n' ) . filter ( line => line . startsWith ( 'Auth=' ) ) [ 0 ] . split ( '=' ) [ 1 ] . split ( '/' ) ;
result . data = { username : authToken [ 0 ] , password : authToken [ 1 ] } ;
result . success = true ;
result . redirect = '/' ;
} else {
result . code = upstreamReq . status ;
result . error = ` ${ appStrings . get ( 'upstreamError' , language ) } ${ upstreamReq . status } : ${ escapeHtml ( upstreamData ) } ` ;
}
break ;
}
} catch ( err ) {
console . log ( err ) ;
// display only generic error from server-side, for security
result . code = 500 ;
result . error = ( isEnvServer ? appStrings . get ( 'unknownError' , language ) : err ) ;
}
return result ;
} ;
2024-02-24 20:27:20 +01:00
main ( ) ;