diff --git a/src/lib/constants.js b/src/lib/constants.js index b1f6fbb..5b541d1 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -39,9 +39,36 @@ let constants = { allow_user_from_reel: "preferForRSS", // one of: "never", "fallback", "prefer", "onlyPreferSaved", "preferForRSS" + feeds: { + // Whether feeds are enabled. + enabled: true, + // Whether to display links to feeds on pages. + display_links: true, + // Whether to display the `v!` link to validate a feed. + display_validation_links: false, + // This feed message field allows you to insert a custom message into all RSS feeds to inform users of important changes, + // such as feeds being disabled forever on that instance. + feed_message: { + enabled: false, + // If the feed message is enabled, then `id` MUST be supplied. + // Please set it to `bibliogram:feed_announcement/your.domain/1` + // replacing `your.domain` with the address of your own domain, + // and incrementing `1` every time you make a new announcement (to make sure the IDs are unique). + id: "", + // The timestamp that you disabled feeds at. For example, if you disabled feeds forever starting at 2020-04-01T12:00:00 UTC, + // you should set this to 1585742400000. + timestamp: 0, + // The title of the feed item. + title: "Important message from Bibliogram", + // The text of the message. + message: "There is an important message about feeds on this Bibliogram instance. Please visit this link to read the message: ", + // The link address. + link: "https://your.domain/feedannouncement" + }, + feed_disabled_max_age: 2*24*60*60 // 2 days + }, + settings: { - rss_enabled: true, - display_feed_validation_buttons: false, enable_updater_page: false }, @@ -132,6 +159,8 @@ let constants = { } }, + additional_routes: [], + database_version: 3 } diff --git a/src/lib/structures/Timeline.js b/src/lib/structures/Timeline.js index f38f3a8..74db932 100644 --- a/src/lib/structures/Timeline.js +++ b/src/lib/structures/Timeline.js @@ -4,6 +4,7 @@ const config = require("../../../config") const TimelineEntry = require("./TimelineEntry") const InstaCache = require("../cache") const collectors = require("../collectors") +const {getFeedSetup} = require("../utils/feed") require("../testimports")(constants, collectors, TimelineEntry, InstaCache) /** @param {any[]} edges */ @@ -55,24 +56,8 @@ class Timeline { } async fetchFeed() { - // we likely cannot use full_name here - reel fallback would make the feed title inconsistent, leading to confusing experience - const usedName = `@${this.user.data.username}` - const feed = new Feed({ - title: usedName, - description: this.user.data.biography, - id: `bibliogram:user/${this.user.data.username}`, - link: `${constants.website_origin}/u/${this.user.data.username}`, - feedLinks: { - rss: `${constants.website_origin}/u/${this.user.data.username}/rss.xml`, - atom: `${constants.website_origin}/u/${this.user.data.username}/atom.xml` - }, - image: constants.website_origin+this.user.proxyProfilePicture, - updated: new Date(this.user.cachedAt), - author: { - name: usedName, - link: `${constants.website_origin}/u/${this.user.data.username}` - } - }) + const setup = getFeedSetup(this.user.data.username, this.user.data.biography, constants.website_origin+this.user.proxyProfilePicture, new Date(this.user.cachedAt)) + const feed = new Feed(setup) const page = this.pages[0] // only get posts from first page await Promise.all(page.map(item => item.fetchFeedData().then(feedData => feed.addItem(feedData)) diff --git a/src/lib/structures/TimelineEntry.js b/src/lib/structures/TimelineEntry.js index 3a5a7c3..99f5736 100644 --- a/src/lib/structures/TimelineEntry.js +++ b/src/lib/structures/TimelineEntry.js @@ -245,7 +245,7 @@ class TimelineEntry extends TimelineBaseMethods { Readers should display the description as HTML rather than using the media enclosure. enclosure: { url: this.data.display_url, - type: "image/jpeg" //TODO: can instagram have PNGs? everything is JPEG according to https://medium.com/@autolike.it/how-to-avoid-low-res-thumbnails-on-instagram-android-problem-bc24f0ed1c7d + type: "image/jpeg" // Instagram only has JPEGs as far as I can tell } */ } diff --git a/src/lib/utils/feed.js b/src/lib/utils/feed.js new file mode 100644 index 0000000..d89f747 --- /dev/null +++ b/src/lib/utils/feed.js @@ -0,0 +1,23 @@ +const constants = require("../constants") + +function getFeedSetup(username, description, image, updated) { + const usedName = `@${username}` + return { + title: usedName, + description, + id: `bibliogram:user/${username}`, + link: `${constants.website_origin}/u/${username}`, + feedLinks: { + rss: `${constants.website_origin}/u/${username}/rss.xml`, + atom: `${constants.website_origin}/u/${username}/atom.xml` + }, + image, + updated, + author: { + name: usedName, + link: `${constants.website_origin}/u/${username}` + } + } +} + +module.exports.getFeedSetup = getFeedSetup diff --git a/src/site/api/feed.js b/src/site/api/feed.js index e7f9042..7e64a72 100644 --- a/src/site/api/feed.js +++ b/src/site/api/feed.js @@ -1,66 +1,109 @@ const constants = require("../../lib/constants") +const {Feed} = require("feed") +const {getFeedSetup} = require("../../lib/utils/feed") const {fetchUser, userRequestCache} = require("../../lib/collectors") const {render} = require("pinski/plugins") const {pugCache} = require("../passthrough") +const {compile} = require("pug") + +const rssAnnouncementTemplate = compile(` +p(style="white-space: pre-line") #{message}#[a(href=link)= link] +`) + +function respondWithFeed(feed, kind, maxAge, available) { + if (kind === "rss") { + var data = { + contentType: "application/rss+xml", // see https://stackoverflow.com/questions/595616/what-is-the-correct-mime-type-to-use-for-an-rss-feed, + content: feed.rss2() + } + } else if (kind === "atom") { + var data = { + contentType: "application/atom+xml", // see https://en.wikipedia.org/wiki/Atom_(standard)#Including_in_HTML + content: feed.atom1() + } + } + const headers = { + "Cache-Control": `public, max-age=${maxAge}` + } + if (!available) headers["X-Bibliogram-Feed-Unavailable"] = 1 + return { + statusCode: 200, // must return 200 even if announcement only, since readers might not display anything with a failed status code + contentType: data.contentType, + headers, + content: data.content + } +} + +/** + * @param {Feed} feed + */ +function addAnnouncementFeedItem(feed) { + feed.addItem({ + title: constants.feeds.feed_message.title, + description: rssAnnouncementTemplate({ + message: constants.feeds.feed_message.message, + link: constants.feeds.feed_message.link + }), + link: constants.feeds.feed_message.link, + id: constants.feeds.feed_message.id, + published: new Date(constants.feeds.feed_message.timestamp), + date: new Date(constants.feeds.feed_message.timestamp) + }) +} + module.exports = [ - {route: `/u/(${constants.external.username_regex})/(rss|atom)\\.xml`, methods: ["GET"], code: ({fill}) => { - if (constants.settings.rss_enabled) { + { + route: `/u/(${constants.external.username_regex})/(rss|atom)\\.xml`, methods: ["GET"], code: ({fill}) => { const kind = fill[1] - return fetchUser(fill[0], constants.symbols.fetch_context.RSS).then(async user => { - const feed = await user.timeline.fetchFeed() - if (kind === "rss") { - var data = { - contentType: "application/rss+xml", // see https://stackoverflow.com/questions/595616/what-is-the-correct-mime-type-to-use-for-an-rss-feed, - content: feed.rss2() + if (constants.feeds.enabled) { + return fetchUser(fill[0], constants.symbols.fetch_context.RSS).then(async user => { + const feed = await user.timeline.fetchFeed() + if (constants.feeds.feed_message.enabled) { + addAnnouncementFeedItem(feed) } - } else if (kind === "atom") { - var data = { - contentType: "application/atom+xml", // see https://en.wikipedia.org/wiki/Atom_(standard)#Including_in_HTML - content: feed.atom1() - } - } - return { - statusCode: 200, - contentType: data.contentType, - headers: { - "Cache-Control": `public, max-age=${userRequestCache.getTtl("user/"+user.data.username, 1000)}` - }, - content: data.content - } - }).catch(error => { - if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) { - return render(404, "pug/friendlyerror.pug", { - statusCode: 404, - title: "Not found", - message: "This user doesn't exist.", - withInstancesLink: false - }) - } else if (error === constants.symbols.INSTAGRAM_DEMANDS_LOGIN || error === constants.symbols.RATE_LIMITED) { - return { - statusCode: 503, - contentType: "text/html", - headers: { - "Cache-Control": `public, max-age=${userRequestCache.getTtl("user/"+fill[0], 1000)}`, - "Retry-After": userRequestCache.getTtl("user/"+fill[0], 1000) - }, - content: pugCache.get("pug/blocked.pug").web({ - expiresMinutes: userRequestCache.getTtl("user/"+fill[0], 1000*60) + return respondWithFeed(feed, kind, userRequestCache.getTtl("user/"+user.data.username, 1000), true) + }).catch(error => { + if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) { + return render(404, "pug/friendlyerror.pug", { + statusCode: 404, + title: "Not found", + message: "This user doesn't exist.", + withInstancesLink: false }) + } else if (error === constants.symbols.INSTAGRAM_DEMANDS_LOGIN || error === constants.symbols.RATE_LIMITED) { + return { + statusCode: 503, + contentType: "text/html", + headers: { + "Cache-Control": `public, max-age=${userRequestCache.getTtl("user/"+fill[0], 1000)}`, + "Retry-After": userRequestCache.getTtl("user/"+fill[0], 1000) + }, + content: pugCache.get("pug/blocked.pug").web({ + expiresMinutes: userRequestCache.getTtl("user/"+fill[0], 1000*60) + }) + } + } else if (error === constants.symbols.extractor_results.AGE_RESTRICTED) { + return render(403, "pug/age_gated.pug") + } else { + throw error } - } else if (error === constants.symbols.extractor_results.AGE_RESTRICTED) { - return render(403, "pug/age_gated.pug") + }) + } else { + if (constants.feeds.feed_message.enabled) { + const setup = getFeedSetup(fill[0], "", undefined, new Date()) + const feed = new Feed(setup) + addAnnouncementFeedItem(feed) + return Promise.resolve(respondWithFeed(feed, kind, constants.feeds.feed_disabled_max_age, false)) } else { - throw error + return Promise.resolve(render(403, "pug/friendlyerror.pug", { + statusCode: 403, + title: "Feeds disabled", + message: "Feeds are disabled on this instance.", + withInstancesLink: true + })) } - }) - } else { - return Promise.resolve(render(403, "pug/friendlyerror.pug", { - statusCode: 403, - title: "Feeds disabled", - message: "Feeds are disabled on this instance.", - withInstancesLink: true - })) + } } - }} + } ] diff --git a/src/site/api/routes.js b/src/site/api/routes.js index 29be478..f5b9e6f 100644 --- a/src/site/api/routes.js +++ b/src/site/api/routes.js @@ -69,8 +69,7 @@ module.exports = [ await user.timeline.fetchUpToPage(page - 1) } const followerCountsAvailable = !(user.constructor.name === "ReelUser" && user.following === 0 && user.followedBy === 0) - const {website_origin, settings: {display_feed_validation_buttons}} = constants - return render(200, "pug/user.pug", {url, user, followerCountsAvailable, constants, website_origin, display_feed_validation_buttons}) + return render(200, "pug/user.pug", {url, user, followerCountsAvailable, constants}) }).catch(error => { if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) { return render(404, "pug/friendlyerror.pug", { diff --git a/src/site/pug/user.pug b/src/site/pug/user.pug index 35b9961..faeb4e6 100644 --- a/src/site/pug/user.pug +++ b/src/site/pug/user.pug @@ -1,4 +1,4 @@ -//- Needs user, followerCountsAvailable, url, constants, website_origin, display_feed_validation_buttons +//- Needs user, followerCountsAvailable, url, constants include includes/timeline_page.pug include includes/next_page_button.pug @@ -17,12 +17,12 @@ html include includes/head script(src="/static/js/pagination.js" type="module") script(src="/static/js/post_overlay.js" type="module") - meta(property="og:url" content=`${website_origin}/u/${user.data.username}`) + meta(property="og:url" content=`${constants.website_origin}/u/${user.data.username}`) meta(property="og:type" content="profile") meta(property="og:title" content=(user.data.full_name || user.data.username)) if user.data.biography meta(property="og:description" content=user.data.biography) - meta(property="og:image" content=`${website_origin}${user.proxyProfilePicture}`) + meta(property="og:image" content=`${constants.website_origin}${user.proxyProfilePicture}`) meta(property="og:image:width" content=150) meta(property="og:image:height" content=150) meta(property="og:image:type" content="image/jpeg") @@ -58,9 +58,9 @@ html else div.profile-counter.not-available Followers not available. div.links - if constants.settings.rss_enabled - +feed_link("RSS", "rss", user.data.username, "application/rss+xml", display_feed_validation_buttons) - +feed_link("Atom", "atom", user.data.username, "application/atom+xml", display_feed_validation_buttons) + if constants.feeds.enabled && constants.feeds.display_links + +feed_link("RSS", "rss", user.data.username, "application/rss+xml", constants.feeds.display_validation_links) + +feed_link("Atom", "atom", user.data.username, "application/atom+xml", constants.feeds.display_validation_links) a(rel="noreferrer noopener" href=`https://www.instagram.com/${user.data.username}` target="_blank") instagram.com - const hasPosts = !user.data.is_private && user.timeline.pages.length && user.timeline.pages[0].length diff --git a/src/site/sass/main.sass b/src/site/sass/main.sass index 43a6f49..2f308df 100644 --- a/src/site/sass/main.sass +++ b/src/site/sass/main.sass @@ -4,6 +4,7 @@ $layout-c-max: 680px $layout-home-a-max: 520px $layout-home-b-min: 521px $main-theme-link-color: #085cae +$medium-red-bg: #6a2222 @font-face font-family: "Bariol" @@ -480,7 +481,7 @@ body background-color: #ff7c7c .about-container - background-color: #6a2222 + background-color: $medium-red-bg color: #eee padding: 50px 20px flex: 1 @@ -570,19 +571,23 @@ body font-weight: bold background-color: #282828 -.updater-page - font-size: 20px - max-width: 800px - margin: 0 auto - color: #222 - +@mixin article-code code - font-size: 0.9em + font-size: 0.8em + letter-spacing: -0.2px background: #ccc color: #000 padding: 0px 4px border-radius: 2px +.updater-page + @include article-code + + font-size: 20px + max-width: 800px + margin: 0 auto + color: #222 + .commits border-left: 2px solid #555 margin: 10px 0px @@ -595,3 +600,36 @@ body border-left: 2px solid padding-left: 8px color: #700 + +.article-page + @include article-code + + background-color: #fff4e8 + font-size: 22px + line-height: 1.4 + color: #222 + min-height: 100vh + + header + background-color: $medium-red-bg + color: #fff + padding: 40px 10px + line-height: 1.2 + + h1, h2 + text-align: center + margin: 0 + + h1 + font-size: 50px + + h2 + font-size: 30px + + .article-main + max-width: 800px + margin: 0 auto + padding: 20px 20px 100px + + a, a:visited + color: $main-theme-link-color diff --git a/src/site/server.js b/src/site/server.js index 4d19261..2f943ca 100644 --- a/src/site/server.js +++ b/src/site/server.js @@ -29,6 +29,10 @@ subdirs("pug", async (err, dirs) => { pinski.muteLogsStartingWith("/videoproxy") pinski.muteLogsStartingWith("/static") + for (const route of constants.additional_routes) { + pinski.addRoute(route.web, route.local, route.type) + } + if (constants.tor.enabled) { await require("../lib/utils/tor") // make sure tor state is known before going further }