Update WuppiMini, Add TiVuOcto, Update Build.sh

This commit is contained in:
octospacc 2024-07-30 23:29:01 +02:00
parent 53204d067b
commit 260dc1e61d
16 changed files with 2018 additions and 892 deletions

View File

@ -1,13 +1,14 @@
#!/bin/sh
SourceApps="SpiderADB WuppiMini"
SourceApps="SpiderADB TiVuOcto WuppiMini"
HubSdkApps="${SourceApps} MatrixStickerHelper TiktOctt"
HtmlHeadInject='<script src="../../shared/OctoHub-Global.js"></script>'
quoteVar(){ echo '"'"$1"'"' ;}
getMetaAttr(){
file="$1"
name="$2"
key="$([ -n "$3" ] && echo "$3" || echo "property")"
key="$([ -n "$3" ] && echo "$3" || echo property)"
grep '<meta '"$key"'="'"$name"'"' "$file" | grep '>' | cut -d '"' -f4
}
@ -30,9 +31,9 @@ node ../WriteRedirectPages.js
for App in ${HubSdkApps}
do
file="./${App}/index.html"
name="$(getMetaAttr "${file}" og:title)"
description="$(getMetaAttr "${file}" og:description property)"
url="$(getMetaAttr "${file}" Url OctoSpaccHubSdk)" #"$(getMetaAttr "${file}" og:url property)"
name="$( getMetaAttr "${file}" og:title)"
description="$(getMetaAttr "${file}" og:description)"
url="$( getMetaAttr "${file}" Url OctoSpaccHubSdk)"
cat << [OctoSpaccHubSdk-WebManifest-EOF] > "./${App}/WebManifest.json"
{
$(getMetaAttr "${file}" WebManifestExtra OctoSpaccHubSdk | sed s/\'/\"/g)
@ -42,5 +43,7 @@ do
"name": "${name}"
}
[OctoSpaccHubSdk-WebManifest-EOF]
sed -i 's|</head>|<title>'"${name}"'</title><link rel="manifest" href="./WebManifest.json"/></head>|' "${file}"
htmltitle='<title>'"${name}"'</title>'
htmlcanonical='<link rel="canonical" href="'"${url}"'"/>'
sed -i 's|</head>|<link rel="manifest" href="./WebManifest.json"/>'"${htmltitle}${htmlcanonical}${htmlmanifest}${HtmlHeadInject}"'</head>|' "${file}"
done

1233
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta property="og:title" content="🕷️ SpiderADB"/>
<meta property="og:title" content="SpiderADB"/>
<meta OctoSpaccHubSdk="Url" content="https://hub.octt.eu.org/SpiderADB/"/>
<meta OctoSpaccHubSdk="WebManifestExtra" content="'display':'standalone', 'icons':[{ 'src':'./icon.png', 'type':'image/png', 'sizes':'512x512' }],"/>
<link rel="apple-touch-icon" href="./icon.png"/>
@ -15,7 +15,6 @@
<script src="./util.js"></script>
<script src="./holo-web/holo-extra-octt.js"></script>
<script src="./holo-web/holo-touch.js"></script>
<script src="../../shared/OctoHub-Global.js"></script>
<style>
.floatRight { float: right; }
body { overflow-x: hidden; padding-bottom: 0; overflow-wrap: break-word; }

View File

@ -107,9 +107,9 @@
}
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
}
}
}

2
source/TiVuOcto/Build.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
echo index.html node_modules

View File

@ -0,0 +1,3 @@
#!/bin/sh
npm update
npm install

519
source/TiVuOcto/index.html Normal file
View File

@ -0,0 +1,519 @@
<!DOCTYPE html>
<html>
<head>
<title>TiVuOcto</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta property="og:title" content="TiVuOcto"/>
<meta OctoSpaccHubSdk="Url" content="https://hub.octt.eu.org/TiVuOcto/"/>
<meta OctoSpaccHubSdk="WebManifestExtra" content="'display':'standalone',"/>
<link href="./node_modules/muicss/dist/css/mui.min.css" rel="stylesheet"/>
<link href="./node_modules/video.js/dist/video-js.min.css" rel="stylesheet"/>
<style>
:root {
--headerHeight: 48px;
--sidebarWidth: 200px;
--colorBackground: #000;
--colorBackground2: #102;
--colorForeground: #fff;
--colorForeground2: #bbb;
--colorAccent: #305;
--colorAccent2: #203;
}
html,
body {
height: 100%;
background-color: var(--colorBackground);
color: var(--colorForeground);
}
body a {
color: var(--colorForeground2);
}
html,
body,
input,
textarea,
button {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004);
}
#header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 2;
transition: left 0.2s;
}
#sidedrawer {
position: fixed;
top: 0;
bottom: 0;
width: var(--sidebarWidth);
left: calc(0px - var(--sidebarWidth));
overflow: auto;
z-index: 2;
background-color: var(--colorBackground2);
transition: transform 0.2s;
}
#content-wrapper {
min-height: 100%;
overflow-x: hidden;
margin-left: 0px;
transition: margin-left 0.2s;
}
#content-wrapper > * {
position: relative;
top: var(--headerHeight);
width: 100%;
height: calc(100vh - var(--headerHeight));
}
@media (min-width: 768px) {
#header {
left: var(--sidebarWidth);
}
#sidedrawer {
transform: translate(var(--sidebarWidth));
}
#content-wrapper {
margin-left: var(--sidebarWidth);
}
#footer {
margin-left: var(--sidebarWidth);
}
body[data-hide-sidedrawer="1"] #header {
left: 0;
}
body[data-hide-sidedrawer="1"] #sidedrawer {
transform: translate(0px);
}
body[data-hide-sidedrawer="1"] #content-wrapper,
body[data-hide-sidedrawer="1"] #footer {
margin-left: 0;
}
}
#sidedrawer[data-active="true"] {
transform: translate(var(--sidebarWidth));
}
#header .sidedrawer-toggle,
#header .sidemenu-toggle {
color: var(--colorForeground);
cursor: pointer;
user-select: none;
font-size: 20px;
}
#header .sidedrawer-toggle {
margin-right: 10px;
line-height: 20px;
}
#header .sidedrawer-toggle:hover,
#header .sidemenu-toggle:hover {
color: var(--colorForeground2);
text-decoration: none;
}
#header .mui-dropdown {
float: right;
}
#sidedrawer-brand {
padding-left: 20px;
}
#sidedrawer ul {
list-style: none;
padding: 1em;
}
#sidedrawer > ul {
padding-left: 0px;
}
#sidedrawer > ul > li:first-child {
padding-top: 15px;
}
#sidedrawer > ul > li > ul > li {
padding: 1em;
word-break: break-word;
}
#sidedrawer > ul > li > ul > li:hover {
background: var(--colorAccent);
}
#sidedrawer > ul > li > ul > li:target {
background: var(--colorAccent2) !important;
}
#sidedrawer > ul > li > a {
position: sticky;
top: 0;
z-index: 1;
}
#sidedrawer ul > li > a > strong {
display: block;
padding: 15px 22px;
background-color: var(--colorBackground2);
}
#sidedrawer ul > li > a > strong:hover {
background-color: var(--colorAccent);
}
#sidedrawer strong + ul > li {
padding: 6px 0px;
}
#sidedrawer > ul > li > ul > li > a {
font-size: x-large;
display: block;
}
#sidedrawer > ul > li > ul > li > a > img {
width: auto;
height: auto;
max-width: 100%;
max-height: 160px;
/* <https://stackoverflow.com/questions/12690444/css-border-on-png-image-with-transparent-parts/70470266#70470266> */
filter:
drop-shadow(2px 0 0 var(--colorForeground))
drop-shadow(0 2px 0 var(--colorForeground))
drop-shadow(-2px 0 0 var(--colorForeground))
drop-shadow(0 -2px 0 var(--colorForeground));
}
#header .mui-appbar {
height: var(--headerHeight);
min-height: var(--headerHeight);
line-height: var(--headerHeight);
background-color: var(--colorAccent);
}
#no-video,
#app-info {
box-sizing: border-box;
overflow: auto;
padding-left: 1em;
padding-right: 1em;
}
</style>
</head>
<body>
<div id="sidedrawer" class="mui--no-user-select">
<div id="sidedrawer-brand" class="mui--appbar-line-height">
<span class="mui--text-title"></span>
</div>
<div class="mui-divider"></div>
</div>
<header id="header">
<div class="mui-appbar mui--appbar-line-height">
<div class="mui-container-fluid"><!--
--><a class="sidedrawer-toggle mui--visible-xs-inline-block mui--visible-sm-inline-block js-show-sidedrawer"></a><!--
--><a class="sidedrawer-toggle mui--hidden-xs mui--hidden-sm js-hide-sidedrawer"></a><!--
--><span class="mui--text-title"></span>
<div class="mui-dropdown">
<a class="sidemenu-toggle" data-mui-toggle="dropdown"></a>
<ul class="mui-dropdown__menu mui-dropdown__menu--right" style="top: 31px;">
<li><a href="#/about">About TiVuOcto</a></li>
</ul>
</div>
</div>
</div>
</header>
<div id="content-wrapper">
<div id="app-info">
<p id="no-javascript" style="padding-top: 1em;">
⚠️ You need to <a href="https://enable-javascript.com">enable JavaScript</a> to run this app.
</p>
<h2>About TiVuOcto</h2><p>
A minimal webapp for playing IPTV streams.
Includes free TV channels from the entire world
with a responsive UI, clean UX, no ads, no spyware.
</p>
<h3>Disclaimer</h3><p>
This app allows users to play video live streams
based on Internet standards, from either embedded sources,
or user-specified sources that the app is not affiliated with.
<br/>
The embedded sources are specified below and publicly available,
only including fully legal free-to-watch/public-domain content.
This app does not make any pay-per-view content freely available,
nor does it include any DRM/geoblocking circumvention measures.
</p>
<h3>Licensing</h3><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 href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.
</p>
<h3>Thanks and Third Party Libraries</h3>
<p>This app wouldn't have been possible without the following:</p><ul>
<li><a href="https://github.com/Free-TV/IPTV">Free-TV IPTV</a> playlist</li>
<li><a href="https://videojs.com">Video.js</a> player [<a href="https://github.com/videojs/video.js/blob/main/LICENSE">Apache 2.0</a>]</li>
<li><a href="https://muicss.com">MUI CSS</a> framework [<a href="https://github.com/muicss/mui/blob/master/LICENSE.txt">MIT</a>]</li>
</ul>
<h3>Changelog</h3>
<h4>2024-07-30</h4>
<ul>
<li>First MVP version using the biggest free IPTV list, and about screen.</li>
<li>Channel list in sidebar, divided by country with flag emojis.</li>
</ul>
</div>
</div>
<script src="./node_modules/muicss/dist/js/mui.min.js"></script>
<script src="./node_modules/video.js/dist/video.min.js"></script>
<script>(async function(){
var COUNTRIES = {
ar: '🇸🇦️',
en: '🏴󠁧󠁢󠁥󠁮󠁧󠁿️',
es: '🇪🇸️',
albania: '🇦🇱️',
andorra: '🇦🇩️',
argentina: '🇦🇷️',
australia: '🇦🇺️',
austria: '🇦🇹️',
azerbaijan: '🇦🇿️',
belarus: '🇧🇾️',
belgium: '🇧🇪️',
bosnia_and_herzegovina: '🇧🇦️',
brazil: '🇧🇷️',
bulgaria: '🇧🇬️',
canada: '🇨🇦️',
chad: '🇹🇩️',
chile: '🇨🇱️',
china: '🇨🇳️',
costa_rica: '🇨🇷️',
croatia: '🇭🇷️',
cyprus: '🇨🇾️',
czech_republic: '🇨🇿️',
denmark: '🇩🇰️',
dominican_republic: '🇩🇴️',
estonia: '🇪🇪️',
faroe_islands: '🇫🇴️',
finland: '🇫🇮️',
france: '🇫🇷️',
georgia: '🇬🇪️',
germany: '🇩🇪️',
greece: '🇬🇷️',
greenland: '🇬🇱️',
hong_kong: '🇭🇰️',
hungary: '🇭🇺️',
iceland: '🇮🇸️',
india: '🇮🇳️',
iran: '🇮🇷️',
iraq: '🇮🇶️',
ireland: '🇮🇪️',
israel: '🇮🇱️',
italy: '🇮🇹️',
japan: '🇯🇵️',
korea: '🇰🇷️',
kosovo: '🇽🇰️',
latvia: '🇱🇻️',
lithuania: '🇱🇹️',
luxembourg: '🇱🇺️',
macau: '🇲🇴️',
malta: '🇲🇹️',
mexico: '🇲🇽️',
moldova: '🇲🇩️',
monaco: '🇲🇨️',
montenegro: '🇲🇪️',
netherlands: '🇳🇱️',
north_korea: '🇰🇵️',
north_macedonia: '🇲🇰️',
norway: '🇳🇴️',
paraguay: '🇵🇾️',
peru: '🇵🇪️',
poland: '🇵🇱️',
portugal: '🇵🇹️',
qatar: '🇶🇦️',
romania: '🇷🇴️',
russia: '🇷🇺️',
san_marino: '🇸🇲️',
saudi_arabia: '🇸🇦️',
serbia: '🇷🇸️',
slovakia: '🇸🇰️',
slovenia: '🇸🇮️',
somalia: '🇸🇴️',
spain: '🇪🇸️',
sweden: '🇸🇪️',
switzerland: '🇨🇭️',
taiwan: '🇹🇼️',
trinidad: '🇹🇹️',
turkey: '🇹🇷️',
uk: '🇬🇧️',
ukraine: '🇺🇦️',
united_arab_emirates: '🇦🇪️',
usa: '🇺🇸️',
venezuela: '🇻🇪️',
};
var APPNAME = document.title;
//var APPINFO = document.querySelector('#app-info-container > div').innerHTML;
function setTitle (title='') {
title = title.trim();
document.title = (title ? `${title} — ${APPNAME}` : APPNAME);
document.querySelector('#header .mui--text-title').textContent = (title || APPNAME);
}
setTitle();
document.querySelector('#sidedrawer .mui--text-title').textContent = APPNAME;
function getQuotedValue (data, key) {
return data.split(`${key}="`)?.[1]?.split('"')?.[0];
}
function getToActiveChannel () {
var channelEl = document.querySelector(`#sidedrawer a[href="${location.hash}"]`);
if (!channelEl) {
return;
}
channelEl.parentElement.parentElement.hidden = false;
channelEl.parentElement.scrollIntoView();
//document.querySelector('#sidedrawer').scrollTop -= parseInt(getComputedStyle(document.querySelector('#header')).height.slice(0, -2));
return channelEl;
}
async function loadFromHash (event) {
var videoEl = document.querySelector('#video-player');
var noVideoEl = document.querySelector('#no-video');
var appInfoEl = document.querySelector('#app-info');
var channelEl = getToActiveChannel();
var video = videojs('video-player');
video.pause();
if (channelEl) {
noVideoEl.hidden = true;
appInfoEl.hidden = true;
channelEl.click();
setTitle(channelEl.textContent);
var src = `https://hlb0.octt.eu.org/cors-main.php/${channelEl.dataset.src}`;
var headers = (await fetch(src)).headers;
if (event.videoInitialLoad) {
video.muted(true);
}
video.src({ src, type: (headers.get('content-type') || 'application/x-mpegURL') });
video.play();
videoEl.hidden = false;
} else {
setTitle();
var hashMain = location.hash.split('/')[1];
videoEl.hidden = true;
switch (hashMain) {
default:
noVideoEl.hidden = false;
appInfoEl.hidden = true;
break; case 'about':
noVideoEl.hidden = true;
appInfoEl.hidden = false;
}
}
}
var sideDrawerEl = document.querySelector('#sidedrawer');
document.querySelector('.js-show-sidedrawer').onclick = (function(){
var overlayEl = mui.overlay('on', { onclose: function(){
sideDrawerEl.dataset.active = false;
document.body.appendChild(sideDrawerEl);
}});
document.body.appendChild(overlayEl);
overlayEl.appendChild(sideDrawerEl);
sideDrawerEl.dataset.active = true;
getToActiveChannel();
});
document.querySelector('.js-hide-sidedrawer').onclick = (function(){
document.body.dataset.hideSidedrawer = Number(!Number(document.body.dataset.hideSidedrawer || 0));
});
document.querySelector('#no-javascript').remove();
document.querySelector('#app-info').hidden = true;
document.querySelector('#content-wrapper').innerHTML += `
<div id="no-video" hidden="true">
<p style="margin-top: 1em;">
Select a valid channel to play video.
</p>
</div>
<video id="video-player" class="video-js" controls="controls" data-setup="{}"></video>
`;
// TODO error handling
var iptvRequest = await fetch('https://raw.githubusercontent.com/Free-TV/IPTV/master/playlist.m3u8');
var iptvData = await iptvRequest.text();
var iptvChannels = iptvData.split('\n#EXTINF:-1').slice(1);
var channelsHtml = '';
var channelGroupEls = {};
for (var channel of iptvChannels) {
var group = getQuotedValue(channel, 'group-title');
var channelId = getQuotedValue(channel, 'tvg-id');
if (!channelId) {
continue;
}
channelGroupEls[group] ||= '';
channelGroupEls[group] += `<li id="/0/${channelId}">
<a href="#/0/${channelId}" data-src="${channel.split('\n')[1]}">
<img loading="lazy" src="${getQuotedValue(channel, 'tvg-logo')}"/>
${getQuotedValue(channel, 'tvg-name')}
</a>
</li>`;
}
for (var group in channelGroupEls) {
var groupLower = group.toLowerCase();
channelsHtml += `<li>
<a class="category-toggle" href="javascript:void(0);">
<strong>${
COUNTRIES[groupLower.replaceAll(' ', '_')] ||
COUNTRIES[groupLower.replace('vod', '').trim().replaceAll(' ', '_')] ||
COUNTRIES[groupLower.split('(')[1]?.split(')')[0]] ||
''} ${group}</strong>
</a><ul>${channelGroupEls[group]}</ul>
</li>`;
}
document.querySelector('#sidedrawer').innerHTML += `<ul>${channelsHtml}</ul>`;
Array.from(document.querySelectorAll('#sidedrawer a.category-toggle')).forEach(function(titleEl){
var groupEl = titleEl.nextElementSibling;
groupEl.hidden = true;
titleEl.onclick = (function(){
groupEl.hidden = !groupEl.hidden;
titleEl.scrollIntoView();
});
});
window.addEventListener('hashchange', loadFromHash);
loadFromHash({ videoInitialLoad: true });
})();</script>
</body>
</html>

358
source/TiVuOcto/package-lock.json generated Normal file
View File

@ -0,0 +1,358 @@
{
"name": "TiVuOcto",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"muicss": "^0.10.3",
"video.js": "^8.17.1"
}
},
"node_modules/@babel/runtime": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz",
"integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@videojs/http-streaming": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.13.1.tgz",
"integrity": "sha512-G7YrgNEq9ETaUmtkoTnTuwkY9U+xP7Xncedzgxio/Rmz2Gn2zmodEbBIVQinb2UDznk7X8uY5XBr/Ew6OD/LWg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "4.0.0",
"aes-decrypter": "4.0.1",
"global": "^4.4.0",
"m3u8-parser": "^7.1.0",
"mpd-parser": "^1.3.0",
"mux.js": "7.0.3",
"video.js": "^7 || ^8"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"video.js": "^8.14.0"
}
},
"node_modules/@videojs/vhs-utils": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.0.0.tgz",
"integrity": "sha512-xJp7Yd4jMLwje2vHCUmi8MOUU76nxiwII3z4Eg3Ucb+6rrkFVGosrXlMgGnaLjq724j3wzNElRZ71D/CKrTtxg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0",
"url-toolkit": "^2.2.1"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/@videojs/xhr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"global": "~4.4.0",
"is-function": "^1.0.1"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/aes-decrypter": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.1.tgz",
"integrity": "sha512-H1nh/P9VZXUf17AA5NQfJML88CFjVBDuGkp5zDHa7oEhYN9TTpNLJknRY1ie0iSKWlDf6JRnJKaZVDSQdPy6Cg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^3.0.5",
"global": "^4.4.0",
"pkcs7": "^1.0.4"
}
},
"node_modules/aes-decrypter/node_modules/@videojs/vhs-utils": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz",
"integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0",
"url-toolkit": "^2.2.1"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/individual": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
"integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g=="
},
"node_modules/is-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/m3u8-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.1.0.tgz",
"integrity": "sha512-7N+pk79EH4oLKPEYdgRXgAsKDyA/VCo0qCHlUwacttQA0WqsjZQYmNfywMvjlY9MpEBVZEt0jKFd73Kv15EBYQ==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^3.0.5",
"global": "^4.4.0"
}
},
"node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz",
"integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0",
"url-toolkit": "^2.2.1"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/mpd-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.0.tgz",
"integrity": "sha512-WgeIwxAqkmb9uTn4ClicXpEQYCEduDqRKfmUdp4X8vmghKfBNXZLYpREn9eqrDx/Tf5LhzRcJLSpi4ohfV742Q==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.0.0",
"@xmldom/xmldom": "^0.8.3",
"global": "^4.4.0"
},
"bin": {
"mpd-to-m3u8-json": "bin/parse.js"
}
},
"node_modules/muicss": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/muicss/-/muicss-0.10.3.tgz",
"integrity": "sha512-CuVrxnns64RZogHhrAxpMw2BF74Mably9KGUwk5LxHkG1yXUGArf8ZWP8jYd1ueNplqIwgrRdkau8GgrmXTMUQ==",
"dependencies": {
"react-addons-shallow-compare": "^15.6.2"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/mux.js": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.3.tgz",
"integrity": "sha512-gzlzJVEGFYPtl2vvEiJneSWAWD4nfYRHD5XgxmB2gWvXraMPOYk+sxfvexmNfjQUFpmk6hwLR5C6iSFmuwCHdQ==",
"dependencies": {
"@babel/runtime": "^7.11.2",
"global": "^4.4.0"
},
"bin": {
"muxjs-transmux": "bin/transmux.js"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"dependencies": {
"@babel/runtime": "^7.5.5"
},
"bin": {
"pkcs7": "bin/cli.js"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
"integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-addons-shallow-compare": {
"version": "15.6.3",
"resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.3.tgz",
"integrity": "sha512-EDJbgKTtGRLhr3wiGDXK/+AEJ59yqGS+tKE6mue0aNXT6ZMR7VJbbzIiT6akotmHg1BLj46ElJSb+NBMp80XBg==",
"dependencies": {
"object-assign": "^4.1.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"peer": true
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/rust-result": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
"integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==",
"dependencies": {
"individual": "^2.0.0"
}
},
"node_modules/safe-json-parse": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
"integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==",
"dependencies": {
"rust-result": "^1.0.0"
}
},
"node_modules/url-toolkit": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz",
"integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg=="
},
"node_modules/video.js": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.17.1.tgz",
"integrity": "sha512-MKW/oRs5B9UeN6TiF+CsVNGacxV4mPWlyDt1VzRkNXy6gPkCK04oQKB2XEhHHQCtACv3PeOkOXnr5b1ID2LwPg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/http-streaming": "3.13.1",
"@videojs/vhs-utils": "^4.0.0",
"@videojs/xhr": "2.7.0",
"aes-decrypter": "^4.0.1",
"global": "4.4.0",
"m3u8-parser": "^7.1.0",
"mpd-parser": "^1.2.2",
"mux.js": "^7.0.1",
"safe-json-parse": "4.0.0",
"videojs-contrib-quality-levels": "4.1.0",
"videojs-font": "4.2.0",
"videojs-vtt.js": "0.15.5"
}
},
"node_modules/videojs-contrib-quality-levels": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
"dependencies": {
"global": "^4.4.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
},
"peerDependencies": {
"video.js": "^8"
}
},
"node_modules/videojs-font": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ=="
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
"dependencies": {
"global": "^4.3.1"
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"dependencies": {
"muicss": "^0.10.3",
"video.js": "^8.17.1"
}
}

4
source/WuppiMini/Build.sh Normal file → Executable file
View File

@ -1,3 +1,3 @@
#!/bin/sh
node ./index.js html > /dev/null
echo index.js index.html icon.png node_modules/SpaccDotWeb/SpaccDotWeb.Server.js
node ./index.js writeStaticHtml > /dev/null
echo index.js index.css index.html icon.png node_modules node_modules/SpaccDotWeb/SpaccDotWeb.Server.js node_modules/SpaccDotWeb/SpaccDotWeb.Alt.js

0
source/WuppiMini/Requirements.sh Normal file → Executable file
View File

115
source/WuppiMini/index.css Normal file
View File

@ -0,0 +1,115 @@
* {
box-sizing: border-box;
font-family: sans-serif;
}
code {
font-family: revert;
}
body {
margin: 0;
padding-bottom: 8px;
color: #000;
background: #eee url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwBAMAAADMe/ShAAAAHlBMVEX29vb09PTy8vL19fXz8/Px8fHw8PDv7+/u7u739/dHdNvrAAACSElEQVR4AezYNX7DMBiGcafcKTRlC0hdy7T1Z7iAcS5YOkEl38DeQlNOW24YX63fsxr+Ztm2RmvjMiu2bWbOXPmjtW2A7S8YKlfMCK4lKPzeMoJvYhROHYIJno9gggkmmGCCCSaYYIIJJphgggkmGP/PZQZf4XDHCK5JFFYtE7hXkwWYap0awEMPh8NHA7jPNQprv2EAH5zh8MsTDo9vY/xGxuCeLXFYsVMY7nNZwCm/AcM3scZhnTooPBwfafBYP4JwiZvBfgWDh7Xxkc71DmXjY916hOAbLrMxK7ZuTOfKdxC4ZMf6z1Ui2iEh/2Sdssru8L4dy+zXFVGbuVtntyPxK+cqZRc7wr1Ll0v95/qser1DVZv/yVr5bv10Bczq+ML4Rl/als/ww4WfJptbwuACMbgwhaUVfkvgt6LU1icpdkAAAADAEEz/1EKshHOBgMAnwZ7gNgG9DEwybTKpyZiLRCT6ItqUCJuIYqKoosaJlEsMJBki+ZOElwRfkpqSuElcS9Qnd4LcGN3enRUACMRQDFSDJRzg3wIKuNnNzygYA83rGbx9g1cwGAwGg8FgMBgMBoPBYDAYDAaDwWAwGAwGg8FgMBgMBoPB/bFgfx7ZH4TWJ7DB0W9w5hwcdgen7MHxfp8r9IFGkKQEEU6WHWWhVZaWZTFdlg9mwWSdiAZRbJABB+FzkHoHcXuQ82cDBtlkQzVSkc1yZEMk2fRKNjZTzetkg0LlhJK1KjAYDAaDwWAwGAwGg8FgcAD3L/H6J4DLS/j67eEOl9GYoKbAV2YAAAAASUVORK5CYII=');
background-size: 10px;
}
a {
color: #777;
}
h1 {
display: inline;
}
h1 > a {
color: #000;
}
form > input, form > select, form > textarea, form > label {
display: block;
margin: 8px 0px;
width: 100%;
}
form > input[type="text"], form > input[type="url"], form > input[type="password"], form > input[type="submit"], /*form > input[type="button"],*/ form > select, textarea {
min-height: 2em;
}
form > textarea {
font-size: medium;
}
form > textarea, form > select[multiple] {
resize: vertical;
}
div#header {
margin: 0;
padding: 8px;
border-bottom: 4px solid #5ac800;
background: #fff;
overflow-x: auto;
white-space: nowrap;
}
div#header > * {
display: inline;
padding: 0px 0.25em;
}
div#main {
width: 90%;
margin-left: auto;
margin-right: auto;
padding: 0px 8px;
}
div.channels > a {
display: block;
min-height: 2em;
}
div.channels > a > img {
width: 2em;
height: auto;
}
div.channels > a > span {
background: #777;
color: #fff;
padding: 2px;
border-radius: 2px;
}
div.posts > article {
background: #fff;
padding: 8px;
margin: 8px;
border: 1px solid #ccc;
word-break: break-word;
}
div.posts > article > header > a {
float: right;
}
div.posts > article img, div.posts > article video, div.posts > article iframe {
width: auto;
height: auto;
max-width: 100%;
max-height: 80vh;
}
div#transition {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 1;
background: black;
opacity: 0.25;
cursor: progress;
}
p.notice, blockquote {
background: white;
padding: 1em;
border-width: 4px;
border-left-width: 1em;
border-style: solid;
}
blockquote {
border-color: #777;
}
p.notice.success {
border-color: #5ac800;
}
p.notice.error {
border-color: #de0000;
}
p.notice.info {
border-color: #0097ce;
}

View File

@ -1,3 +1,4 @@
#!/usr/bin/env node
// search "Copyright" in this file for licensing info
// configuration
@ -6,7 +7,9 @@ const serverPort = 8135;
const detailedLogging = true;
const serverLanUpstreams = false;
const serverPlaintextUpstreams = false;
let resFiles = [ 'package.json', 'package-lock.json' ];
const appSelfContained = false;
let staticFiles = [ 'package.json', 'package-lock.json' ];
let linkStyles = [ 'index.css' ];
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.
@ -18,113 +21,144 @@ const appTerms = `
<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> to host it yourself, find another instance, or use the <a href="/info#h-versions">local, client-side version</a>.
</p>`;
const suggestedTags = [ 'fromWuppiMini' ];
const corsProxies = [ 'corsproxy.io', 'corsproxy.org' ];
const corsProxies = [ 'corsproxy.io?', 'corsproxy.org?', 'hlb0.octt.eu.org/cors-main.php/' ];
const SpaccDotWebServer = require('SpaccDotWeb/SpaccDotWeb.Server.js');
let crypto;
var crypto, escapeHtml;
let isEnvServer = SpaccDotWebServer.envIsNode;
let isEnvBrowser = SpaccDotWebServer.envIsBrowser;
const httpCodes = { success: [200,201] };
const strings = {
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>`,
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 },
};
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]);
const appPager = (content, title) => `${title ? `<h2>${title}</h2>` : ''}${content}`;
const appPager = (content, title, opts={}, ctx) => `<div id="header">
<h1><a href="/">${appName}</a></h1>
<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>
</div><div id="main">${title ? `<h2>${title}</h2>` : ''}${content}</div>`;
const newHtmlPage = (content, title) => `<!DOCTYPE html><html><head>
const htmlPager = (content, title, opts={}, ctx) => `<!DOCTYPE html><html><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta property="og:title" content="${title ? `${title} — ` : ''}${appName}"/>
<meta OctoSpaccHubSdk="Url" content="https://hub.octt.eu.org/WuppiMini/"/>
<meta OctoSpaccHubSdk="WebManifestExtra" content="'display':'standalone', 'icons':[{ 'src':'./icon.png', 'type':'image/png', 'sizes':'256x256' }],"/>
<title>${title ? `${title}` : ''}${appName}</title>
<link rel="apple-touch-icon" href="./icon.png"/>
<script src="../../shared/OctoHub-Global.js"></script>
<style>
* {
box-sizing: border-box;
font-family: sans-serif;
}
code {
font-family: revert;
}
body {
margin: 0;
padding-bottom: 8px;
color: #000;
background: #eee url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwBAMAAADMe/ShAAAAHlBMVEX29vb09PTy8vL19fXz8/Px8fHw8PDv7+/u7u739/dHdNvrAAACSElEQVR4AezYNX7DMBiGcafcKTRlC0hdy7T1Z7iAcS5YOkEl38DeQlNOW24YX63fsxr+Ztm2RmvjMiu2bWbOXPmjtW2A7S8YKlfMCK4lKPzeMoJvYhROHYIJno9gggkmmGCCCSaYYIIJJphgggkmGP/PZQZf4XDHCK5JFFYtE7hXkwWYap0awEMPh8NHA7jPNQprv2EAH5zh8MsTDo9vY/xGxuCeLXFYsVMY7nNZwCm/AcM3scZhnTooPBwfafBYP4JwiZvBfgWDh7Xxkc71DmXjY916hOAbLrMxK7ZuTOfKdxC4ZMf6z1Ui2iEh/2Sdssru8L4dy+zXFVGbuVtntyPxK+cqZRc7wr1Ll0v95/qser1DVZv/yVr5bv10Bczq+ML4Rl/als/ww4WfJptbwuACMbgwhaUVfkvgt6LU1icpdkAAAADAEEz/1EKshHOBgMAnwZ7gNgG9DEwybTKpyZiLRCT6ItqUCJuIYqKoosaJlEsMJBki+ZOElwRfkpqSuElcS9Qnd4LcGN3enRUACMRQDFSDJRzg3wIKuNnNzygYA83rGbx9g1cwGAwGg8FgMBgMBoPBYDAYDAaDwWAwGAwGg8FgMBgMBoPB/bFgfx7ZH4TWJ7DB0W9w5hwcdgen7MHxfp8r9IFGkKQEEU6WHWWhVZaWZTFdlg9mwWSdiAZRbJABB+FzkHoHcXuQ82cDBtlkQzVSkc1yZEMk2fRKNjZTzetkg0LlhJK1KjAYDAaDwWAwGAwGg8FgcAD3L/H6J4DLS/j67eEOl9GYoKbAV2YAAAAASUVORK5CYII=');
background-size: 10px;
}
a {
color: #777;
}
h1 {
display: inline;
}
h1 > a {
color: #000;
}
form > input, form > select, form > textarea, form > label {
display: block;
margin: 8px 0px;
width: 100%;
}
form > input[type="text"], form > input[type="url"], form > input[type="password"], form > input[type="submit"], /*form > input[type="button"],*/ form > select, textarea {
min-height: 2em;
}
textarea {
font-size: medium;
}
div#header {
margin: 0;
padding: 8px;
border-bottom: 4px solid #5ac800;
background: #fff;
}
div#header > * {
display: inline;
padding: 0px 8px;
}
div#app {
width: 90%;
margin-left: auto;
margin-right: auto;
padding: 0px 8px;
}
div#transition {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
background: black;
opacity: 0.25;
}
p.notice {
background: white;
padding: 1em;
border-width: 4px;
border-left-width: 1em;
border-style: solid;
}
p.notice.success {
border-color: #5ac800;
}
p.notice.error {
border-color: #de0000;
}
</style>
${linkStyles.map((path) => SpaccDotWebServer.makeHtmlStyleFragment(path, appSelfContained)).join('')}
</head><body><!--
-->${isEnvBrowser ? `<div id="transition"></div>` : ''}<!--
--><div id="header"><!--
--><h1><a href="/">${appName}</a></h1><!--
--><a href="/info">Info</a><!--
--><a href="/settings">Settings</a><!--
--></div><!--
--><div id="app">${appPager(content, title)}</div><!--
--><div id="app">${appPager(content, title, opts, ctx)}</div><!--
--></body></html>`;
const A = (href) => `<a href="${href}">${href}</a>`
const A = (href) => `<a href="${href}">${href}</a>`;
const Log = (type, msg) => ((type !== 'D' || detailedLogging) && console.log(`${type}: ${msg}`));
@ -158,7 +192,7 @@ const genCsrfToken = (accountString, time) => (isEnvServer && time && crypto.scr
const matchCsrfToken = (bodyParams, accountString) => (isEnvServer ? bodyParams.formToken === genCsrfToken(accountString, bodyParams.formTime) : true);
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={}) => {
// TODO warn if the browser has cookies disabled when running on server side
@ -176,54 +210,105 @@ const corsProxyIfNeed = (need) => (isEnvBrowser /*&& need*/ ? `https://${corsPro
};
};*/
// 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 getUserLanguage = (ctx) => (ctx.getCookie?.('language') || ctx.clientLanguages?.[0]?.split('-')[0]);
const makeFragmentLoggedIn = (accountString) => {
const accountData = accountDataFromString(accountString);
return `<p>Logged in as <i>${accountData.username} @ ${A(accountData.instance)}</i>.</p>`;
}
//const getAccountsData = (ctx) => accountsDataFromCookieString(ctx.getCookie('account'));
const main = () => {
if (SpaccDotWebServer.envIsNode && process.argv[2] !== 'html') {
resFiles = [__filename.split(require('path').sep).slice(-1)[0], ...resFiles];
const getPlatformActionIcon = platform => (platform.writing && platform.reading ? '💱️'
: (platform.writing && '📤️') || (platform.reading && '📥️'));
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];
};
const server = SpaccDotWebServer.setup({
appName: appName,
staticPrefix: '/res/',
staticFiles: resFiles,
appPager: appPager,
htmlPager: newHtmlPage,
staticFiles, linkStyles,
appPager, htmlPager,
});
if (SpaccDotWebServer.envIsNode && process.argv[2] === 'html') {
server.writeStaticHtml();
if (isEnvServer && process.argv[2] === 'writeStaticHtml') {
server.writeStaticHtml(appSelfContained);
} else {
if (SpaccDotWebServer.envIsNode) {
escapeHtml = (await require('escape-html/index.js') || window.escapeHtml);
if (isEnvServer) {
crypto = require('crypto');
console.log('Running Server...');
console.log(`Running Server on :${serverPort}...`);
};
server.initServer({
port: serverPort,
address: '0.0.0.0',
maxBodyUploadSize: 4e6, // 4 MB
endpoints: [ endpointRoot, endpointInfo, endpointCompose, endpointSettings, endpointCatch ],
endpoints: [ endpointRoot, endpointInfo, /* endpointHub, */ endpointCompose, /* endpointRead, */ endpointSettings, endpointCatch ],
});
};
};
const endpointRoot = [ (ctx) => (!ctx.urlSections[0]), (ctx) => ctx.redirectTo(ctx.getCookie('account') ? '/compose' : '/info') ];
// 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}`);
} ];
const endpointCatch = [ (ctx) => true, (ctx) => ctx.renderPage('', (ctx.response.statusCode = 404)) ];
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)) ];
const endpointInfo = [ (ctx) => (ctx.urlSections[0] === 'info' && ctx.request.method === 'GET'), (ctx) => {
const endpointInfo = [ 'GET /info/', (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>` : ''}
${!ctx.getCookie('account') ? `<p class="notice info">${appStrings.get('mustAddAccount', getUserLanguage(ctx))(ctx.urlParameters.ref)}</p>` : ''}
<h3>About</h3>
<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).
@ -261,29 +346,32 @@ const endpointInfo = [ (ctx) => (ctx.urlSections[0] === 'info' && ctx.request.me
</p>
<p>
${isEnvServer ? `You can obtain the full source code and assets by downloading the following files:
${resFiles.map((file) => ` • <a href="/res/${file}">${file}</a>`).join('')}.
${staticFiles.map(file => ` • <a href="/res/${file}">${file}</a>`).join('')}.
` : 'To get the original, unminified source code, visit this same page on the server-side version (refer to the Versions section above).'}
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/')}.
</p>
${isEnvServer ? `<h3>Terms of Use and Privacy Policy</h3>${appTerms}` : ''}
<h3>Changelog</h3>
<h4>2024-02-24</h3>
<ul>
<h4>2024-07-15 (deployed 2024-07-30)</h4><ul>
<!--<li>New Read function, connecting to RSS servers via Google Reader API.</li>-->
<li>Multi-account support (required changes to the cookie format), allowing for selection of destination in Compose and filtering of sources in Read.</li>
<li>New multi-language support, using browser language as a default and allowing change in Settings.</li>
<li>Slight UX improvements and bugfixes to existing forms and screens.</li>
<li>Made a preview of the Compose screen visible before logging-in.</li>
</ul>
<h4>2024-02-24</h4><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>
<ul>
<h4>2024-02-12</h4><ul>
<li>First working client-side version of the current app, without backend server (still a bit buggy).</li>
<li>Fixed suggested tags handling not working and making the post error out, by instead simply writing them in the post body</li>
</ul>
<h4>2024-02-10</h4>
<ul>
<h4>2024-02-10</h4><ul>
<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>
</ul>
<h4>2024-02-09</h4>
<ul>
<h4>2024-02-09</h4><ul>
<li>First working version, with an UI reminiscent of [that dead social network that rhymes with Meterse], and Info, Settings, and Composition pages!</li>
<li>Allow logging in with a WordPress.org profile, and creating new posts, including uploading images.</li>
<li>Add licensing and proper source code listing.</li>
@ -292,41 +380,40 @@ const endpointInfo = [ (ctx) => (ctx.urlSections[0] === 'info' && ctx.request.me
`);
} ];
const endpointCompose = [ (ctx) => (ctx.urlSections[0] === 'compose' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => {
let noticeHtml = '';
const endpointCompose = [ 'GET|POST /compose/', async (ctx) => {
let [noticeErrorHtml, noticeSuccessHtml] = ['', ''];
const language = getUserLanguage(ctx);
const accountString = ctx.getCookie('account');
if (!accountString) {
return ctx.redirectTo('/');
}
const accounts = accountsDataFromCookieString(accountString)?.filter(account => appPlatforms[account.platform].writing);//getAccountsData(ctx);
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;
noticeErrorHtml = appStrings.get('csrfError', language);
}
const isThereAnyFile = ((ctx.bodyParameters.file?.data?.length || ctx.bodyParameters.file?.size) > 0);
if (!ctx.bodyParameters.text?.trim() && !isThereAnyFile) {
ctx.response.statusCode = 500;
noticeHtml = `<p class="notice error">Post content is empty. Please write some text or upload a media.</p>`;
noticeErrorHtml = appStrings.get('postEmpty', language);
}
const account = accountDataFromString(accountString);
const account = accountDataFromFormId(accounts, ctx.account); //accountsDataFromCookieString(accountString)[0];
if (!checkUpstreamAllowed(account.instance)) {
ctx.response.statusCode = 500;
noticeHtml = strings.upstreamDisallowedHtml;
noticeErrorHtml = appStrings.get('upstreamDisallowed', language);
}
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)}`,
...getBackendHeaders(account),
"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 = `<p class="notice error">Upstream server responded with error ${ctx.response.statusCode = mediaReq.status}: ${JSON.stringify(mediaData)}</p>`;
noticeErrorHtml = `${appStrings.get('upstreamError', language)} ${ctx.response.statusCode = mediaReq.status}: ${escapeHtml(JSON.stringify(mediaData))}`;
}
}
// upload actual post if nothing has errored before
@ -342,7 +429,7 @@ const endpointCompose = [ (ctx) => (ctx.urlSections[0] === 'compose' && ['GET',
<!-- /wp:image -->
` : ''}`;
const postReq = await fetch(`${corsProxyIfNeed(account.cors)}${account.instance}/wp-json/wp/v2/posts`, { headers: {
Authorization: `Basic ${btoa(account.username + ':' + account.password)}`,
...getBackendHeaders(account),
"Content-Type": "application/json",
}, method: "POST", body: JSON.stringify({
status: postUploadStatus,
@ -364,107 +451,240 @@ ${figureHtml}`.trim()),
}) });
const postData = await postReq.json();
if (httpCodes.success.includes(postReq.status)) {
noticeHtml = `<p class="notice success">${postUploadStatus === 'publish' ? 'Post published' : ''}${postUploadStatus === 'draft' ? 'Draft uploaded' : ''}! ${A(postData.link)}</p>`;
noticeSuccessHtml = `${appStrings.get((postUploadStatus === 'publish' ? 'postPublished' : postUploadStatus === 'draft' ? 'draftUploaded' : ''), language)}! ${A(postData.link)}`;
} else {
noticeHtml = `<p class="notice error">Upstream server responded with error ${ctx.response.statusCode = postReq.status}: ${JSON.stringify(postData)}</p>`;
noticeErrorHtml = `${appStrings.get('upstreamError', language)} ${ctx.response.statusCode = postReq.status}: ${escapeHtml(JSON.stringify(postData))}`;
}
}
} catch (err) {
console.log(err);
ctx.response.statusCode = 500;
// 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>`;
noticeErrorHtml = (isEnvServer ? appStrings.get('unknownError', language) : 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)}
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>` : ''}
<form method="POST" enctype="multipart/form-data">${makeFormCsrf(accountString)}
<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"/>
<textarea name="text" rows="10" placeholder="What's on your mind?">${ctx.bodyParameters.text && ctx.response.statusCode !== 200 ? ctx.bodyParameters.text : ''}</textarea>
<select name="account" ${disabledHtml}>${accounts.map(account => `<option value="${makeAccountFormId(account)}">
${account.username}@${account.instance} (${account.platform})
</option>`).join('')}</select>
<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 : ''}</textarea>
<!-- TODO: fix the turn off-on on submit of these checkboxes... -->
<label><input type="checkbox" name="html" ${ctx.bodyParameters.html === 'on' && ctx.response.statusCode !== 200 ? 'checked="true"' : ''}/> Raw HTML mode</label>
<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!"/>
<label><input type="checkbox" name="html" ${ctx.bodyParameters.html === 'on' && ctx.response.statusCode !== 200 ? 'checked="true"' : ''} ${disabledHtml}/> Raw HTML mode</label>
<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)}</label>
<input type="submit" name="draft" value="${appStrings.get('uploadDraft', language)}" ${disabledHtml}/>
<input type="submit" name="publish" value="${appStrings.get('publish', language)}" ${disabledHtml}/>
</form>
`, 'Compose Post');
`, appStrings.get('composePost', language));
} ];
const endpointSettings = [ (ctx) => (ctx.urlSections[0] === 'settings' && ['GET', 'POST'].includes(ctx.request.method)), async (ctx) => {
let noticeHtml = '';
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}</h3>
</header>
<p>${post.summary.content}</p>
</article>`).join('')}</div>`);
} 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})
</option>`).join('')}</select>
<select name="folder" multiple="true">${Object.entries(folders).map(folder => `<option value="${0}/${folder[0]}" selected="true">
${folder[1]}
</option>`).join('')}</select>
</form>
<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>`).join('')}</div>
`, 'Channels');
}
} ];
const endpointSettings = [ 'GET|POST /settings/', async (ctx) => {
let [noticeErrorHtml] = [''];
const language = getUserLanguage(ctx);
const accountString = ctx.getCookie('account');
const accounts = accountsDataFromCookieString(accountString);//getAccountsData(ctx);
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) {
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) {
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 = `<p class="notice error">Upstream server responded with error ${upstreamReq.status}: ${JSON.stringify(upstreamData)}</p>`;
}
} catch (err) {
console.log(err);
noticeErrorHtml = appStrings.get('upstreamDisallowed', language);
} else if (accounts.filter(account => (makeAccountFormId(account) === makeAccountFormId(ctx.bodyParameters))).length) {
ctx.response.statusCode = 500;
// 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>`;
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);
}
}
} 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);
}
}
}
ctx.setCookie(`account=${accountsCookieStringFromData(accounts)}${makeCookieFlags(ctx.bodyParameters)}`);
return ctx.redirectTo('/');
} else {
ctx.response.statusCode = 500;
noticeErrorHtml = appStrings.get('noAccountSelected', language);
}
} else if (ctx.response.statusCode === 200 && ctx.bodyParameters.logout) {
ctx.setCookie(`account=`);
return ctx.redirectTo('/');
}
}
ctx.renderPage(`${noticeHtml}
${accountString ? `
<h3>Current Account</h3>
${makeFragmentLoggedIn(accountString)}
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>` : ''}
<form method="POST">${makeFormCsrf(accountString)}
<input type="submit" name="logout" value="Logout"/>
</form>
` : '<p>You must login first.</p>'}
${!accountString ? `<h3><!--Add New Account-->Login</h3>
<h3>${appStrings.get('language', language)}</h3>
<select name="language">${Object.entries(appLanguages).map(lang => `<option value="${lang[0]}" ${language === lang[0] ? 'selected="true"' : ''}>${lang[1]}</option>`).join('')}</select>
<input type="submit" value="${appStrings.get('applyLanguage', language)}"/>
${formSharedHtml}</form>
${accountString ? `<h3>${appStrings.get('currentAccounts', language)}</h3>
<form method="POST">${makeFormCsrf(accountString)}
<select name="backend">
<option value="wp.org">
WordPress.org (Community/Self-hosted)
</option>
</select>
<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="${ctx.bodyParameters.instance || ''}" required="true"/>
${accounts.map(account => `<label><input type="checkbox" name="account" value="${makeAccountFormId(account)}"/>
${getPlatformActionIcon(appPlatforms[account.platform])}
${account.username}@${account.instance} (${account.platform})</label>`).join('')}
<input type="submit" name="logout" value="${appStrings.get('logoutAccounts', language)}"/>
${formSharedHtml}</form>
` : ''}
<h3>${appStrings.get('addNewAccount', language)}</h3>
<form method="POST">${makeFormCsrf(accountString)}
<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}
</option>`).join('')}</select>
<p><i>
Note: For WordPress.org you must use an "application password"
( <code>/wp-admin/profile.php#application-passwords-section</code> )
</i></p>
<input type="url" name="instance" placeholder="${appStrings.get('siteInstanceUrl', language)}" value="${ctx.bodyParameters.instance || ''}" required="true"/>
<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"/>
<!--${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" ${ctx.request.method === 'POST' && ctx.bodyParameters.remember !== 'on' ? '' : 'checked="true"'}/> Remember me</label>
<input type="submit" name="login" value="Login and Save"/>
</form>` : ''}
<!--${true ? `
<h3>Select and Manage Accounts</h3>
<form method="POST">${makeFormCsrf(accountString)}
<ul>
<li>
<input type="submit" name="select" value="username@url"/>
</li>
</ul>
</form>
` : ''}-->
`, 'Settings');
<label>
<input type="checkbox" name="remember" ${ctx.request.method === 'POST'
? (ctx.bodyParameters.remember === 'on' ? 'checked="true"' : '')
: (mustRememberAccount ? 'checked="true"' : '')}
${accounts?.length > 0 ? 'disabled="true"' : ''}
/> ${appStrings.get('rememberMe', language)}</label>
<input type="submit" name="login" value="${appStrings.get('loginAndSave', language)}"/>
${formSharedHtml}</form>
`, appStrings.get('settings', language));
} ];
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;
};
main();

View File

@ -5,11 +5,17 @@
"packages": {
"": {
"dependencies": {
"escape-html": "^1.0.3",
"mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0",
"SpaccDotWeb": "gitlab:SpaccInc/SpaccDotWeb"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -36,7 +42,7 @@
},
"node_modules/SpaccDotWeb": {
"version": "indev",
"resolved": "git+ssh://git@gitlab.com/SpaccInc/SpaccDotWeb.git#63dc2e648f93c7917cced6b08305255f8e4ce330",
"resolved": "git+ssh://git@gitlab.com/SpaccInc/SpaccDotWeb.git#495c7a8d2e0e570108b9d2ed23e555949270666e",
"dependencies": {
"mime-types": "^2.1.35",
"parse-multipart-data": "^1.5.0"

View File

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

View File

@ -6,6 +6,7 @@
<meta property="og:title" content="🎵️ TiktOctt"/>
<meta OctoSpaccHubSdk="Url" content="https://hub.octt.eu.org/TiktOctt/"/>
<meta OctoSpaccHubSdk="WebManifestExtra" content="'display':'standalone', 'icons':[{ 'src':'./logo.png', 'type':'image/png', 'sizes':'1024x1024' }],"/>
<link rel="apple-touch-icon" href="./logo.png"/>
<style>
:root {
--footerHeight: 40px;