Redo app from the ground up

Make it an ES module, TODO: Typescript
Make the themes explicitly mastodon

Signed-off-by: Sam Therapy <sam@samtherapy.net>
This commit is contained in:
Sam Therapy 2022-02-06 13:14:18 -06:00
parent 2f34e2a7d9
commit 73a0ccf871
Signed by: sam
GPG key ID: 4D8B07C18F31ACBD
22 changed files with 1849 additions and 1459 deletions

View file

@ -10,11 +10,11 @@ The homepage has a tool for generating iframe code for you, with a sensible `san
## API
### V2
### V1
#### GET `/api/v2/feed`
#### GET `/api/v1/feed`
> example: `/api/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67&scale=90&theme=light`
> example: `/api/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67&scale=90&theme=masto-light`
Returns a html page which displays a mastodon feed for a user URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67')` ).
@ -24,32 +24,15 @@ Querystring options:
| ------ | -------- | ----------- |
| `userurl` | **yes** | Mastodon/ActivityPub account URL (usually `https://${instance}/users/${username}`) |
| `feedurl` | no | a URL to a page of an ActivityPub post collection. Only used for pages beyond the first. |
| `theme` | no | either `dark`, `light` or `auto`, to select the UI theme (default is `dark`). `auto` will appear light unless the user sets up dark mode on their device. |
| `theme` | no | either `masto-dark`, `masto-light` or `masto-auto`, to select the UI theme (default is `masto-dark`). `auto` will appear masto-light unless the user sets up masto-dark mode on their device. |
| `boosts` | no | whether to show boosts or not |
| `replies` | no | whether to show replies or not |
| `size` | no | the scale of the UI in percent. |
### V1 (deprecated, will now just redirect you to the v2 API)
#### GET `/api/feed`
> example: `/api/feed?url=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67.atom&scale=90&theme=light`
Returns a html page which displays a mastodon feed for an atom feed URL. Note that URLs must be URI encoded (i.e. `encodeURIComponent('https://octodon.social/users/fenwick67.atom')` ).
Querystring options:
| option | required | description |
| ------ | -------- | ----------- |
| `url` | **yes** | Mastodon Atom feed URL |
| `theme` | no | either dark, light or auto, to select the UI theme (default is dark). `auto` will appear light unless the user sets up dark mode on their device. |
| `size` | no | the scale of the UI in percent. |
## Server Installation
This is a straightforward node project with zero databases or anything, you should just be able to run `npm install` and then `npm start` to get up and running. Set your `PORT` environment variable to change the port it listens on.
## Improve me
Feel free to add a chaching layer, improve the styles and add more features.
Feel free to add a caching layer, improve the styles and add more features.

View file

@ -1,21 +1,21 @@
// build the styles
var fs = require('fs');
var sass = require('node-sass');
import { readFileSync, writeFileSync } from 'fs';
import { renderSync } from 'node-sass';
var staticDir = './static/'
var srcDir = './stylesrc/';
var themes = ['light','dark','auto'];
var themes = ['masto-light','masto-dark','masto-auto'];
themes.forEach(function(s){
var sassFile = srcDir+s+'.scss'
var cssFile = staticDir+s+'.css'
var result = sass.renderSync({
data: fs.readFileSync(sassFile,'utf8'),
var result = renderSync({
data: readFileSync(sassFile,'utf8'),
includePaths:[srcDir]
});
fs.writeFileSync(cssFile,result.css,'utf8')
writeFileSync(cssFile,result.css,'utf8')
});

View file

@ -1,13 +1,10 @@
var Express = require('express');
// v1 api
var convert = require('./lib/convert');
import Express from 'express';
// v2 api
var convertv2 = require('./lib/convertv2');
var serveStatic = require('serve-static');
var request = require('request');
var cors = require('cors');
var errorPage = require('./lib/errorPage');
var morgan = require('morgan');
import convertv2 from './lib/convert.js';
import serveStatic from 'serve-static';
import cors from 'cors';
import errorPage from './lib/errorPage.js';
import morgan from 'morgan';
var app = Express();
@ -40,7 +37,7 @@ app.get('/api/feed',cors(),logger,function(req,res){
var userUrl = feedUrl.replace(/\.atom.*/i,'');
var redirectUrl = '/api/v2/feed?';
var redirectUrl = '/api/v1/feed?';
var qs = ['userurl='+encodeURIComponent(userUrl),"api=v1"];
(['size','theme','boosts','replies']).forEach(key=>{
@ -53,9 +50,9 @@ app.get('/api/feed',cors(),logger,function(req,res){
});
app.options('/api/v2/feed',cors());
// http://localhost:8000/api/v2/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67
app.get('/api/v2/feed',cors(),logger,function(req,res){
app.options('/api/v1/feed',cors());
// http://localhost:8000/api/v1/feed?userurl=https%3A%2F%2Foctodon.social%2Fusers%2Ffenwick67
app.get('/api/v1/feed',cors(),logger,function(req,res){
// get feed url
var userUrl = req.query.userurl;

View file

@ -1,312 +1,252 @@
var FeedParser = require('feedparser');
var ejs = require('ejs');
var fs = require('fs');
var template = ejs.compile(fs.readFileSync('./lib/template.ejs','utf8'));
var timeAgo = require('timeago.js');
function isArray(a){
return Array.isArray(a);
}
// accumulate a stream of XML into a html file
module.exports = function(stream,opts,callback){
var callback = callback;
var opts = opts;
if (typeof opts == 'function'){
callback = opts;
opts = {};
}
// convert s from atom feed to a full html page for rendering
breakDown(stream,function(er,data){
if (er) {
return callback(er);
}
// try and build up
try{
var result = buildUp(data,opts)
}catch(e){
return callback(e);
}
return callback(null,result);
});
}
// break the xml into json
function breakDown(stream,callback){
var spent = false;
function cbOnce(er,data){
if (!spent){
callback(er,data);
spent = true;
}
}
stream.on('error',cbOnce);
var feedparser = new FeedParser();
feedparser.on('error', cbOnce);
stream.pipe(feedparser)
feedparser.items = [];
feedparser.on('readable', function () {
// This is where the action is!
var stream = this; // `this` is `feedparser`, which is a stream
var items = [];
var item;
while (item = stream.read()) {
feedparser.items.push(item);
}
});
feedparser.on('end',function(er){cbOnce(null,feedparser)});
}
// hydrate the json to html
function buildUp(jsonObj,opts){
// assign opts to the obj
jsonObj.opts = opts||{};
// iterate through the items
jsonObj.items = jsonObj.items.filter(function(item){
// get date
item.stringDate = getTimeDisplay(item.date);
item.content = getH(item,'atom:content');
if (!item.content ){// item was deleted
return false;
}
// make anchor tags have the "_top" target
item.content = item.content.replace(/\<\s*a\s*/ig,'<a target="_top"');
// get enclosures
item.enclosures = [];
function findEnclosure(link){
if (!link['@']){return;} // avoid keyerror
var rel = link['@'].rel;
var href = link['@'].href;
if (rel == 'enclosure'){
item.enclosures.push({
url:href,
type:link['@'].type||''
});
}
}
// find them in the item's links
if (item['atom:link']){
var links = item['atom:link'];
if (!isArray(links) && typeof links == 'object'){
links = [links];
}else if (!isArray(links)){
links = [];
}
links.forEach(findEnclosure);
}
// find them in activity:object
if (item["activity:object"] && item["activity:object"].link){
var enclosureLinks = item["activity:object"].link;
if (!isArray(enclosureLinks) && typeof enclosureLinks == 'object'){
enclosureLinks = [enclosureLinks];
}else if (!isArray(enclosureLinks)){
enclosureLinks = [];// not an object or array.
}
enclosureLinks.forEach(findEnclosure);
}
// get author info
item.author = {};
var _author = item.meta['atom:author'];
if ( item['activity:object'] && item['activity:object'].author){
_author = item['activity:object'].author;
}
item.author.name = getH(_author,'name');
item.author.uri = getH(_author,'uri');
item.author.fullName = getH(_author,'email');
item.author.displayName = getH(_author,'poco:displayname')||getH(_author,'poco:preferredUsername')||item.author.name;
var authorLinks = _author.link || [];
if (!isArray(authorLinks) && typeof authorLinks == 'object'){
authorLinks = [authorLinks];
}else if ( !isArray(authorLinks) ){
authorLinks = [];// not an object or string.
}
authorLinks.forEach(function(link){// todo: guard against authorLinks not being an array
if (!link['@']){return;} // avoid keyerror
var rel = link['@'].rel;
var href = link['@'].href;
if (rel == 'avatar'){
item.author.avatar = href;
}else if(rel == 'alternate'){
item.author.alternate = href;
}
});
// now detect if item is a reply or boost
item.isBoost = false;// <activity:verb>http://activitystrea.ms/schema/1.0/share</activity:verb>
item.isReply = false;
var v = getH(item,'activity:verb')
if (v.indexOf('share') > -1){
item.isBoost = true;
}
if (item['thr:in-reply-to']){
item.isReply = true;
}
if(item.summary){
item.hasCw = true;
item.cw = item.summary;
}
if(item['activity:object'] && item['activity:object'].summary && item['activity:object'].summary['#']){
item.hasCw = true;
item.cw = item['activity:object'].summary['#'];
}
// get a pagination ID for an entry
item.paginationId = false;
if (item['atom:link']){
var links = item['atom:link'];
if (!isArray(links) && typeof links == 'object'){
links = [links];
}else if (!isArray(links)){
links = [];
}
links.forEach((link)=>{
if (!link['@']){return}
if (link['@']['rel']=='self'){
// parse out the pagination id
// href looks like this in mastodon: https://octodon.social/users/fenwick67/updates/732275.atom
// href looks like this in pleroma (and we should ignore): https://social.fenwick.pizza/objects/1e2fa906-378c-43f8-98fa-271aae455758
var href = link['@']['href'];
if (!href){return}
item.atomHref = href;
var match = href.match(/\/\d+.atom/);
if(!match){return}
var id = match[0].replace(/\D/g,'');
if (id){
item.paginationId = id;
}else{
return;
}
}
})
}
return true;
});
if (jsonObj.meta && jsonObj.meta['atom:author'] && jsonObj.meta['atom:author'].link && Array.isArray(jsonObj.meta['atom:author'].link) ){
jsonObj.meta['atom:author'].link.forEach(link=>{
var l = link['@'];
if (l.rel=="header"){
jsonObj.meta.headerImage = l.href;
}
else if(l.rel=="avatar"){
jsonObj.meta.avatar = l.href;
}
});
}
// get next page
jsonObj.feedUrl = opts.feedUrl;
jsonObj.isIndex = (opts.feedUrl.indexOf('?') == -1);
// prefer link(rel=next)
var nextPageFeedUrl = '';
var links = jsonObj.meta['atom:link'];
if (links){
if (!isArray(links) && typeof links == 'object'){
links = [links];
}else if (!isArray(links)){
links = [];
}
links.forEach(function(link){
if (link['@'] && link['@']['rel'] == 'next' && link['@']['href']){
nextPageFeedUrl = link['@']['href'];
}
})
}
if (!nextPageFeedUrl){
// alternative: try to get the oldest entry id on this page
var lowestId = Infinity;
jsonObj.items.forEach(function(item){
var id = Number(item.paginationId);
if ( id < lowestId ){
lowestId = id;
}
});
if (lowestId < Infinity && opts.feedUrl){
nextPageFeedUrl = opts.feedUrl.replace(/\?.+/g,'') + '?max_id='+lowestId;
}
}
if(nextPageFeedUrl){
jsonObj.nextPageLink = opts.mastofeedUrl.replace(encodeURIComponent(opts.feedUrl),encodeURIComponent(nextPageFeedUrl));
console.log(jsonObj.nextPageLink);
}
return template(jsonObj);
}
// utilities below
// get obj[key]['#'] or ''
function getH(obj,key){
if (!obj[key]){return ''}
return obj[key]['#']||'';
}
function getTimeDisplay(d){
var d = d;
if (typeof d !== 'object'){
d = new Date(d);
}
// convert to number
dt = d.getTime();
var now = Date.now();
var delta = now - dt;
// over 6 days ago
if (delta > 1000*60*60*24*6){
return isoDateToEnglish(d.toISOString());
}else{
return timeAgo().format(dt);
}
}
function isoDateToEnglish(d){
var dt = d.split(/[t\-]/ig);
var months = [ "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December" ];
return months[Number(dt[1])-1] +' '+dt[2]+ ', '+dt[0];
}
import { compile } from 'ejs';
import { readFileSync } from 'fs';
var template = compile(readFileSync('./lib/template.ejs', 'utf8'));
import { format } from 'timeago.js';
import got from 'got';
const map = new Map();
const hour = 3600000;
// get JSON for an AP URL, by either fetching it or grabbing it from a cache.
// Honestly request-promise-cache should be good enough. Redis would be a nice upgrade but for
// a single process install it will be fine.
// note: rejects on HTTP 4xx or 5xx
async function apGet(url,ttl) {
return new Promise(function(resolve,reject){
// fail early
if (!url){
reject(new Error('URL is invalid'));
}
got( {
url:url,
cache:map,
headers: {
"accept": "application/activity+json"
}
})
.then(response=>JSON.parse(response.body))
.then(resolve)
.catch(reject)
})
}
// like Promise.all except returns null on error instead of failing all the promises
async function promiseSome(proms){
function noRejectWrap(prom){
return new Promise(function(resolve,reject){
prom // it's already been called
.then(resolve)
.catch(e=>{
// console.warn(e);// for debugging
resolve(null)
})
})
}
return await Promise.all(proms.map(noRejectWrap))
}
export default async function (opts) {
var opts = opts;
var feedUrl = opts.feedUrl;
var userUrl = opts.userUrl;
var isIndex = false;
if (!userUrl) {
throw new Error('need user URL');
}
var user, feed;
// get user and feed in parallel if I have both URLs.
// can cache feed aggressively since it is a specific start and end.
if (userUrl && feedUrl){
[user, feed] = await Promise.all([ apGet(userUrl), apGet(feedUrl) ]);
}else{
// get user, then outbox, then feed
user = await apGet(userUrl,24 * hour);
isIndex = true;
var outbox = await apGet(user.outbox, 1 * hour);
// outbox.first can be a string for a URL, or an object with stuffs in it
if (typeof outbox.first == 'object'){
feed = outbox.first;
} else {
feed = await apGet(outbox.first, hour/6);// 10 mins. Because the base feed URL can get new toots quickly.
}
}
var templateData = {
opts: opts,// from the request
meta: metaForUser(user),
items: await itemsForFeed(opts,user,feed),
nextPageLink: getNextPage(opts,user,feed),
isIndex: isIndex
};
return template(templateData);
}
function metaForUser(user) {
return {
avatar: user.icon && user.icon.url?user.icon.url:null,
headerImage:user.image && user.image.url?user.image.url:null,
title: user.name||user.preferredUsername||null,
description: user.summary||null,
link:user.url||"#"
}
}
async function itemsForFeed(opts,user,feed) {
var items = feed.orderedItems;
if (opts.boosts){
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
var boostData = [];
var boostUrls = feed.orderedItems.filter(i=>i.type=="Announce").map(i=>i.object);
// console.log(boostUrls);
boostData = await promiseSome(boostUrls.map(apGet));
// now get user data for each of those
let userData = await promiseSome(boostData.map(d=>d?d.attributedTo||'':null).map(apGet));
// put a ._userdata key on the item object if this is a boost
for (var i = 0; i < boostData.length; i ++){
if (userData[i] && boostData[i]){
boostData[i]._userdata = userData[i];
}
}
// some URLs may have failed but IDGAF
// console.log(boostData[0]);
boostData.forEach((boostToot)=>{
if (!boostToot){// failed request
return;
}
// inject in-place into items
var index = -1;
for (var i = 0; i < items.length; i ++){
if (items[i].object == boostToot.id){
index = i;
break;
}
}
if (index == -1){
console.warn("warning: couldn't match boost to item: ",boostToot)
return;
}
boostToot.object = boostToot;// this lets the later stage parser access object without errors :)
items[i] = boostToot;
})
}
return items.filter((item)=>{
return typeof item.object == 'object';// handle weird cases
}).map((item)=>{
var enclosures = (item.object.attachment||[]).filter((a)=>{
return a.type == "Document";
}).map((a)=>{
return {
name:a.name,
type:a.mediaType,
url:a.url
}
});
var op = item._userdata?item._userdata:user;
return {
isBoost:!!item._userdata,
title:item._userdata?user.preferredUsername+' shared a status by '+op.preferredUsername:'',
isReply:!!(item.object && item.object.inReplyTo),
hasCw:item.object.sensitive||false,
cw:item.object.summary,
content: item.object&&item.object.content?item.object.content:'',//TODO sanitize then render without entity escapes
atomHref:item.published?item.published.replace(/\W+/g,''):Math.random().toString().replace('.',''),// used for IDs
enclosures:enclosures,
stringDate:item.published?getTimeDisplay(Date.parse(item.published)):'',
permalink:item.object.id?item.object.id:'#',
author:{
uri:op.url,// link to author page
avatar:op.icon&&op.icon.url?op.icon.url:'',
displayName:op.name || op.preferredUsername,
fullName:op.preferredUsername+'@'+(new URL(op.url).hostname),
}
}
})
}
function getNextPage(opts,user,feed){
//based on feed.next
if (!feed.next){return null}
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
var base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf('?'));
var ret = '/api/v1/feed?userurl=' + encodeURIComponent(opts.userUrl) + '&feedurl=' +encodeURIComponent(feed.next);
// add other params to the end
(['theme','header','size','boosts','replies']).forEach((k)=>{
if (typeof opts[k] != 'undefined'){
ret+=`&${k}=${ opts[k].toString() }`;
}
})
return ret;
}
// utilities below
function getTimeDisplay(d) {
var d = d;
if (typeof d !== 'object') {
d = new Date(d);
}
// convert to number
let dt = d.getTime();
var now = Date.now();
var delta = now - dt;
// over 6 days ago
if (delta > 1000 * 60 * 60 * 24 * 6) {
return isoDateToEnglish(d.toISOString());
} else {
return format(dt);
}
}
function isoDateToEnglish(d) {
var dt = d.split(/[t\-]/ig);
var months = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
return months[Number(dt[1]) - 1] + ' ' + dt[2] + ', ' + dt[0];
}

View file

@ -1,252 +0,0 @@
var ejs = require('ejs');
var fs = require('fs');
var template = ejs.compile(fs.readFileSync('./lib/template.ejs', 'utf8'));
var timeAgo = require('timeago.js');
var request = require('request-promise-cache')
const hour = 3600000;
// get JSON for an AP URL, by either fetching it or grabbing it from a cache.
// Honestly request-promise-cache should be good enough. Redis would be a nice upgrade but for
// a single process install it will be fine.
// note: rejects on HTTP 4xx or 5xx
async function apGet(url,ttl) {
return new Promise(function(resolve,reject){
// fail early
if (!url){
reject(new Error('URL is invalid'));
}
request( {
uri:url,
cacheKey:url,
cacheTTL:ttl || 24 * hour,
headers: {
"accept": "application/activity+json"
}
})
.then(body=>JSON.parse(body))
.then(resolve)
.catch(reject)
})
}
// like Promise.all except returns null on error instead of failing all the promises
async function promiseSome(proms){
function noRejectWrap(prom){
return new Promise(function(resolve,reject){
prom // it's already been called
.then(resolve)
.catch(e=>{
// console.warn(e);// for debugging
resolve(null)
})
})
}
return await Promise.all(proms.map(noRejectWrap))
}
module.exports = async function (opts) {
var opts = opts;
var feedUrl = opts.feedUrl;
var userUrl = opts.userUrl;
var isIndex = false;
if (!userUrl) {
throw new Error('need user URL');
}
var user, feed;
// get user and feed in parallel if I have both URLs.
// can cache feed aggressively since it is a specific start and end.
if (userUrl && feedUrl){
[user, feed] = await Promise.all([ apGet(userUrl), apGet(feedUrl) ]);
}else{
// get user, then outbox, then feed
user = await apGet(userUrl,24 * hour);
isIndex = true;
var outbox = await apGet(user.outbox, 1 * hour);
// outbox.first can be a string for a URL, or an object with stuffs in it
if (typeof outbox.first == 'object'){
feed = outbox.first;
} else {
feed = await apGet(outbox.first, hour/6);// 10 mins. Because the base feed URL can get new toots quickly.
}
}
var templateData = {
opts: opts,// from the request
meta: metaForUser(user),
items: await itemsForFeed(opts,user,feed),
nextPageLink: getNextPage(opts,user,feed),
isIndex: isIndex
};
return template(templateData);
}
function metaForUser(user) {
return {
avatar: user.icon && user.icon.url?user.icon.url:null,
headerImage:user.image && user.image.url?user.image.url:null,
title: user.name||user.preferredUsername||null,
description: user.summary||null,
link:user.url||"#"
}
}
async function itemsForFeed(opts,user,feed) {
var items = feed.orderedItems;
if (opts.boosts){
// yes, I have to fetch all the fucking boosts for this whole feed apparently >:/
var boostData = [];
var boostUrls = feed.orderedItems.filter(i=>i.type=="Announce").map(i=>i.object);
// console.log(boostUrls);
boostData = await promiseSome(boostUrls.map(apGet));
// now get user data for each of those
let userData = await promiseSome(boostData.map(d=>d?d.attributedTo||'':null).map(apGet));
// put a ._userdata key on the item object if this is a boost
for (var i = 0; i < boostData.length; i ++){
if (userData[i] && boostData[i]){
boostData[i]._userdata = userData[i];
}
}
// some URLs may have failed but IDGAF
// console.log(boostData[0]);
boostData.forEach((boostToot)=>{
if (!boostToot){// failed request
return;
}
// inject in-place into items
var index = -1;
for (var i = 0; i < items.length; i ++){
if (items[i].object == boostToot.id){
index = i;
break;
}
}
if (index == -1){
console.warn("warning: couldn't match boost to item: ",boostToot)
return;
}
boostToot.object = boostToot;// this lets the later stage parser access object without errors :)
items[i] = boostToot;
})
}
return items.filter((item)=>{
return typeof item.object == 'object';// handle weird cases
}).map((item)=>{
var enclosures = (item.object.attachment||[]).filter((a)=>{
return a.type == "Document";
}).map((a)=>{
return {
name:a.name,
type:a.mediaType,
url:a.url
}
});
var op = item._userdata?item._userdata:user;
return {
isBoost:!!item._userdata,
title:item._userdata?user.preferredUsername+' shared a status by '+op.preferredUsername:'',
isReply:!!(item.object && item.object.inReplyTo),
hasCw:item.object.sensitive||false,
cw:item.object.summary,
content: item.object&&item.object.content?item.object.content:'',//TODO sanitize then render without entity escapes
atomHref:item.published?item.published.replace(/\W+/g,''):Math.random().toString().replace('.',''),// used for IDs
enclosures:enclosures,
stringDate:item.published?getTimeDisplay(Date.parse(item.published)):'',
permalink:item.object.id?item.object.id:'#',
author:{
uri:op.url,// link to author page
avatar:op.icon&&op.icon.url?op.icon.url:'',
displayName:op.name || op.preferredUsername,
fullName:op.preferredUsername+'@'+(new URL(op.url).hostname),
}
}
})
}
function getNextPage(opts,user,feed){
//based on feed.next
if (!feed.next){return null}
// take feed.next, uriencode it, then take user url, then take options.mastofeedUrl
var base = opts.mastofeedUrl.slice(0,opts.mastofeedUrl.indexOf('?'));
var ret = '/api/v2/feed?userurl=' + encodeURIComponent(opts.userUrl) + '&feedurl=' +encodeURIComponent(feed.next);
// add other params to the end
(['theme','header','size','boosts','replies']).forEach((k)=>{
if (typeof opts[k] != 'undefined'){
ret+=`&${k}=${ opts[k].toString() }`;
}
})
return ret;
}
// utilities below
function getTimeDisplay(d) {
var d = d;
if (typeof d !== 'object') {
d = new Date(d);
}
// convert to number
dt = d.getTime();
var now = Date.now();
var delta = now - dt;
// over 6 days ago
if (delta > 1000 * 60 * 60 * 24 * 6) {
return isoDateToEnglish(d.toISOString());
} else {
return timeAgo().format(dt);
}
}
function isoDateToEnglish(d) {
var dt = d.split(/[t\-]/ig);
var months = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
return months[Number(dt[1]) - 1] + ' ' + dt[2] + ', ' + dt[0];
}

View file

@ -1,14 +1,14 @@
var ejs = require('ejs');
var fs = require('fs');
var template = ejs.compile(fs.readFileSync('./lib/template.ejs', 'utf8'));
import { compile } from 'ejs';
import { readFileSync } from 'fs';
var template = compile(readFileSync('./lib/template.ejs', 'utf8'));
module.exports = function(code,message,displayOptions){
export default function(code,message,displayOptions){
var msg;
var displayOptions = displayOptions || {};
if (code == 500 && !message){
msg = '<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href="https://github.com/fenwick67/mastofeed/issues">please open an issue on GitHub</a>, or message fenwick67@octodon.social</p>'
msg = '<p>Sorry, we are having trouble fetching posts for this user. Please try again later.</p><br><p>If the issue persists, <a href="https://git.froth.zone/Sam/fedifeed/issues">please open an issue on Gitea</a>, or message sam@froth.zone</p>'
}else{
msg = message||'';
}

View file

@ -4,12 +4,12 @@
<style type="text/css"></style>
<base target="_top" /><!-- this element is amazing-->
<% if (opts.theme && opts.theme.toLowerCase() == 'light'){ %>
<link rel="stylesheet" href="/light.css"></link>
<% if (opts.theme && opts.theme.toLowerCase() == 'masto-light'){ %>
<link rel="stylesheet" href="/masto-light.css"></link>
<% } else if (opts.theme && opts.theme.toLowerCase() == 'auto'){ %>
<link rel="stylesheet" href="/auto.css"></link>
<link rel="stylesheet" href="/masto-auto.css"></link>
<% } else { %>
<link rel="stylesheet" href="/dark.css"></link>
<link rel="stylesheet" href="/masto-dark.css"></link>
<% } %>
<% if (opts.size){ %>

View file

@ -1,22 +1,24 @@
{
"dependencies": {
"cors": "^2.8.5",
"ejs": "^2.5.8",
"express": "^4.16.4",
"feedparser": "^2.2.9",
"morgan": "^1.9.1",
"request": "^2.88.0",
"ejs": "^3.1.6",
"express": "^4.17.2",
"feedparser": "^2.2.10",
"morgan": "^1.10.0",
"got": "^12.0.1",
"request": "^2.88.2",
"request-promise-cache": "^2.0.1",
"request-promise-native": "^1.0.7",
"serve-static": "^1.13.2",
"timeago.js": "^3.0.2"
"serve-static": "^1.14.2",
"timeago.js": "^4.0.2"
},
"main": "index.js",
"type": "module",
"license": "MIT",
"scripts": {
"start": "node index.js",
"build-styles": "node build-styles.js"
},
"devDependencies": {
"node-sass": "^4.12.0"
"node-sass": "^7.0.1"
}
}

View file

@ -39,9 +39,9 @@
<br>
<label>Theme:
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Auto (based on css prefers-color-scheme)</option>
<option value="masto-dark">masto-dark</option>
<option value="masto-light">masto-light</option>
<option value="masto-auto">masto-auto (based on css prefers-color-scheme)</option>
</select>
</label>
<br>
@ -61,7 +61,7 @@
<h3>Live Preview:</h3>
<span class="iframe-contain">
<iframe id="frame" allowfullscreen sandbox="allow-top-navigation allow-scripts" width="400" height="800"
src="/api/v2/feed?userurl=https%3A%2F%2Fmastodon.social%2Fusers%2Fgargron&replies=false&boosts=true"></iframe>
src="/api/v1/feed?userurl=https%3A%2F%2Fmastodon.social%2Fusers%2Fgargron&replies=false&boosts=true"></iframe>
</span>
<br>
</div>
@ -79,7 +79,7 @@
var portStr = (window.location.port && window.location.port != 80) ? (':' + window.location.port) : ''
var iframeUrl = window.location.protocol + '//' + window.location.hostname + portStr
+ "/api/v2/feed?userurl=" + encodeURIComponent(inUrl) + "&theme=" + val('theme') + '&size=' + val('size')
+ "/api/v1/feed?userurl=" + encodeURIComponent(inUrl) + "&theme=" + val('theme') + '&size=' + val('size')
+ "&header=" + showHeader + '&replies=' + showReplies + '&boosts=' + showBoosts;
document.getElementById('result').value = '<iframe allowfullscreen sandbox="allow-top-navigation allow-scripts" width="' + val('width') + '" height="' + val('height') + '" src="' + iframeUrl + '"></iframe>';

163
static/pleroma.css Normal file
View file

@ -0,0 +1,163 @@
html,
body {
background-color: #282c37;
font-family: 'Roboto', roboto, Arial, sans-serif;
color: #ffffff;
font-weight: lighter;
overflow-x: hidden;
font-size: 100%;
word-break: break-word; }
* {
margin: 0;
padding: 0; }
a,
a * {
color: #2b90d9; }
.meta {
background-color: #39404d; }
.header {
display: flex;
background-size: cover;
min-height: 8rem;
color: #ffffff; }
.header .header-left, .header .header-right {
margin: 0; }
.header .header-left {
min-width: 8rem;
position: relative;
text-align: center;
background: rgba(40, 44, 55, 0.3); }
.header .header-left .avatar {
width: 6rem;
height: 6rem;
position: relative;
top: calc(50% - 3rem); }
.header .header-right {
flex-grow: 1;
font-size: 0.9rem;
padding: 0.9rem;
background: rgba(40, 44, 55, 0.85); }
.header .header-title {
font-size: 1.3rem; }
.item {
padding: 1rem;
border-top: solid 1px #626d80; }
.item-content,
.cw,
.title {
font-size: 1.1rem;
font-weight: lighter; }
.item-content *, .cw {
margin: 1rem 0;
line-height: 1.4rem; }
.item-title,
.date,
.author-fullname {
color: #9baec8;
font-size: 0.9rem; }
.date {
margin: 1rem 0 0 0;
text-decoration: none;
display: block; }
.date:hover {
text-decoration: underline; }
.item-title {
margin-bottom: 0.7rem; }
.author {
display: flex;
margin-bottom: 1rem; }
.author-info {
margin: 0 1rem;
display: flex;
flex-direction: column;
justify-content: space-around; }
.author-info .author-displayname {
font-size: 1.2rem;
color: #ffffff;
text-decoration: none;
display: block;
font-weight: bolder; }
.avatar {
width: 3rem;
height: 3rem;
border: none;
border-radius: 10%; }
.avatar.circular {
border-radius: 100%; }
.enclosures {
padding: 0.5em 0;
display: flex;
flex-wrap: wrap;
flex-direction: row;
overflow: hidden; }
.enclosure {
display: flex;
flex: 1 1 auto;
width: 50%;
border: none;
max-height: 12rem; }
a.enclosure {
cursor: zoom-in; }
.enclosure > * {
flex: 1 1 auto;
width: 100%;
height: 100%;
object-fit: cover; }
.meta .title {
font-weight: bold; }
.hidden {
display: none; }
.button {
padding: 0.5rem 1rem;
border: none;
margin: 1rem;
border-radius: 0.5rem;
display: inline-block;
text-decoration: none;
background: #2b90d9;
color: #ffffff;
font-weight: 400;
cursor: pointer;
text-transform: uppercase;
user-select: none; }
label.button {
padding: 0.25rem 0.5rem;
margin: 0.4rem;
background: #626d80;
color: #282c37;
font-size: 0.8rem; }
input[type=checkbox] {
position: absolute;
left: -9999px; }
input[type=checkbox]:checked ~ label::after {
content: " Less"; }
input[type=checkbox]:not(:checked) ~ label::after {
content: " More"; }
input[type=checkbox]:not(:checked) ~ div {
display: none; }

View file

@ -1,5 +0,0 @@
@import 'light.scss';
@media (prefers-color-scheme: dark) {
@import 'dark.scss';
}

5
stylesrc/masto-auto.scss Normal file
View file

@ -0,0 +1,5 @@
@import 'masto-light.scss';
@media (prefers-color-scheme: masto-dark) {
@import 'masto-dark.scss';
}

View file

@ -7,4 +7,4 @@ $dim: $lighter;
$dimmer: mix($darkest,$lighter,50%);
$link: $vibrant;
@import 'base.scss';
@import 'masto-base.scss';

View file

@ -7,7 +7,7 @@ $dim: mix($lighter,$darkest,90%);
$dimmer: mix($lighter,$darkest,80%);
$link: $vibrant;
@import 'base.scss';
@import 'masto-base.scss';
.item-content,
.description,

View file

@ -10,7 +10,7 @@
</a>boosted
</span>
</div>
<div class="detailed-status light">
<div class="detailed-status masto-light">
<a class="detailed-status__display-name p-author h-card" target="_blank" rel="noopener" href="https://social.tchncs.de/@muninnherself"><div>
<div class="avatar">
<img alt="" class="u-photo" src="https://assets.octodon.social/accounts/avatars/000/018/289/original/eb6b32e85f8606b4.jpg" width="48" height="48">

View file

@ -4,7 +4,7 @@
<style type="text/css"></style>
<link rel="stylesheet" href="/dark.css"></link>
<link rel="stylesheet" href="/masto-dark.css"></link>

View file

@ -1,15 +1,14 @@
// do a test
var fs = require('fs'),
request = require('request'),
convert = require('../lib/convert')
import { createReadStream, writeFileSync } from 'fs';
import convert from '../lib/convert.js';
var r = fs.createReadStream('./test/sample.atom');
var r = createReadStream('./test/sample.atom');
convert(r,function(er,data){
if (er){return console.log('error: ',er)}
console.log('ok');
fs.writeFileSync('./test/result.html',data,'utf8');
writeFileSync('./test/result.html',data,'utf8');
})

2192
yarn.lock

File diff suppressed because it is too large Load diff