diff --git a/src/lib/structures/TimelineBaseMethods.js b/src/lib/structures/TimelineBaseMethods.js index 467004f..a176a3a 100644 --- a/src/lib/structures/TimelineBaseMethods.js +++ b/src/lib/structures/TimelineBaseMethods.js @@ -1,5 +1,5 @@ const constants = require("../constants") -const {proxyImage, proxyExtendedOwner} = require("../utils/proxyurl") +const {proxyImage, proxyVideo} = require("../utils/proxyurl") class TimelineBaseMethods { constructor() { @@ -21,10 +21,18 @@ class TimelineBaseMethods { } } + isVideo() { + return this.data.__typename === "GraphVideo" + } + getDisplayUrlP() { return proxyImage(this.data.display_url) } + getVideoUrlP() { + return proxyVideo(this.data.video_url) + } + getAlt() { return this.data.accessibility_caption || "No image description available." } diff --git a/src/lib/structures/TimelineEntry.js b/src/lib/structures/TimelineEntry.js index f4690f3..26fba3d 100644 --- a/src/lib/structures/TimelineEntry.js +++ b/src/lib/structures/TimelineEntry.js @@ -203,6 +203,12 @@ class TimelineEntry extends TimelineBaseMethods { } } + fetchVideoURL() { + if (!this.isVideo()) return Promise.resolve(null) + else if (this.data.video_url) return Promise.resolve(this.getVideoUrlP()) + else return this.update().then(() => this.getVideoUrlP()) + } + async fetchFeedData() { const children = await this.fetchChildren() return { diff --git a/src/lib/utils/proxyurl.js b/src/lib/utils/proxyurl.js index 94d6a90..17ce50c 100644 --- a/src/lib/utils/proxyurl.js +++ b/src/lib/utils/proxyurl.js @@ -5,6 +5,12 @@ function proxyImage(url, width) { return "/imageproxy?"+params.toString() } +function proxyVideo(url) { + const params = new URLSearchParams() + params.set("url", url) + return "/videoproxy?"+params.toString() +} + /** * @param {import("../types").ExtendedOwner} owner */ @@ -15,4 +21,5 @@ function proxyExtendedOwner(owner) { } module.exports.proxyImage = proxyImage +module.exports.proxyVideo = proxyVideo module.exports.proxyExtendedOwner = proxyExtendedOwner diff --git a/src/site/api/proxy.js b/src/site/api/proxy.js index 1e15820..6a7b251 100644 --- a/src/site/api/proxy.js +++ b/src/site/api/proxy.js @@ -3,47 +3,69 @@ const {request} = require("../../lib/utils/request") const {proxy} = require("pinski/plugins") const sharp = require("sharp") +const VERIFY_SUCCESS = Symbol("VERIFY_SUCCESS") + +/** + * Check that a resource is on Instagram. + * @param {URL} completeURL + */ +function verifyURL(completeURL) { + const params = completeURL.searchParams + if (!params.get("url")) return {status: "fail", value: [400, "Must supply `url` query parameter"]} + try { + var url = new URL(params.get("url")) + } catch (e) { + return {status: "fail", value: [400, "`url` query parameter is not a valid URL"]} + } + // check url protocol + if (url.protocol !== "https:") return {status: "fail", value: [400, "URL protocol must be `https:`"]} + // check url host + if (!["fbcdn.net", "cdninstagram.com"].some(host => url.host.endsWith(host))) return {status: "fail", value: [400, "URL host is not allowed"]} + return {status: "ok", url} +} module.exports = [ - {route: "/imageproxy", methods: ["GET"], code: async (input) => { - /** @type {URL} */ - // check url param exists - const completeURL = input.url - const params = completeURL.searchParams - if (!params.get("url")) return [400, "Must supply `url` query parameter"] - try { - var url = new URL(params.get("url")) - } catch (e) { - return [400, "`url` query parameter is not a valid URL"] + { + route: "/imageproxy", methods: ["GET"], code: async (input) => { + const verifyResult = verifyURL(input.url) + if (verifyResult.status !== "ok") return verifyResult.value + if (!["png", "jpg"].some(ext => verifyResult.url.pathname.endsWith(ext))) return [400, "URL extension is not allowed"] + const params = input.url.searchParams + const width = +params.get("width") + if (typeof width === "number" && !isNaN(width) && width > 0) { + /* + This uses sharp to force crop the image to a square. + "entropy" seems to currently work better than "attention" on the thumbnail of this shortcode: B55yH20gSl0 + Some thumbnails aren't square and would otherwise be stretched on the page without this. + If I cropped the images client side, it would have to be done with CSS background-image, which means no . + */ + return request(verifyResult.url).then(res => { + const converter = sharp().resize(width, width, {position: "entropy"}) + return { + statusCode: 200, + contentType: "image/jpeg", + headers: { + "Cache-Control": constants.caching.image_cache_control + }, + stream: res.body.pipe(converter) + } + }) + } else { + // No specific size was requested, so just stream proxy the file directly. + return proxy(verifyResult.url, { + "Cache-Control": constants.caching.image_cache_control + }) + } } - // check url protocol - if (url.protocol !== "https:") return [400, "URL protocol must be `https:`"] - // check url host - if (!["fbcdn.net", "cdninstagram.com"].some(host => url.host.endsWith(host))) return [400, "URL host is not allowed"] - if (!["png", "jpg"].some(ext => url.pathname.endsWith(ext))) return [400, "URL extension is not allowed"] - const width = +params.get("width") - if (typeof width === "number" && !isNaN(width) && width > 0) { - /* - This uses sharp to force crop the image to a square. - "entropy" seems to currently work better than "attention" on the thumbnail of this shortcode: B55yH20gSl0 - Some thumbnails aren't square and would otherwise be stretched on the page without this. - If I cropped the images client side, it would have to be done with CSS background-image, which means no . - */ - return request(url).then(res => { - const converter = sharp().resize(width, width, {position: "entropy"}) - return { - statusCode: 200, - contentType: "image/jpeg", - headers: { - "Cache-Control": constants.caching.image_cache_control - }, - stream: res.body.pipe(converter) - } - }) - } else { - // No specific size was requested, so just stream proxy the file directly. + }, + { + route: "/videoproxy", methods: ["GET"], code: async (input) => { + const verifyResult = verifyURL(input.url) + if (verifyResult.status !== "ok") return verifyResult.value + const url = verifyResult.url + if (!["mp4"].some(ext => url.pathname.endsWith(ext))) return [400, "URL extension is not allowed"] return proxy(url, { "Cache-Control": constants.caching.image_cache_control }) } - }} + } ] diff --git a/src/site/api/routes.js b/src/site/api/routes.js index 9f961a0..f756786 100644 --- a/src/site/api/routes.js +++ b/src/site/api/routes.js @@ -92,6 +92,7 @@ module.exports = [ return getOrFetchShortcode(fill[0]).then(async post => { await post.fetchChildren() await post.fetchExtendedOwnerP() // parallel await is okay since intermediate fetch result is cached + if (post.isVideo()) await post.fetchVideoURL() return render(200, "pug/post.pug", {post}) }).catch(error => { if (error === constants.symbols.NOT_FOUND) { diff --git a/src/site/pug/post.pug b/src/site/pug/post.pug index ef595e7..4bd8986 100644 --- a/src/site/pug/post.pug +++ b/src/site/pug/post.pug @@ -22,5 +22,8 @@ html if post.getCaption() p.description= post.getCaption() section.images-gallery - for image in post.children - img(src=image.getDisplayUrlP() alt=image.getAlt() width=image.data.dimensions.width height=image.data.dimensions.height).sized-image + for entry in post.children + if entry.isVideo() + video(src=entry.getVideoUrlP() controls preload="auto" width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-video + else + img(src=entry.getDisplayUrlP() alt=entry.getAlt() width=entry.data.dimensions.width height=entry.data.dimensions.height).sized-image diff --git a/src/site/sass/main.sass b/src/site/sass/main.sass index de85d61..be26e19 100644 --- a/src/site/sass/main.sass +++ b/src/site/sass/main.sass @@ -267,17 +267,23 @@ body @media screen and (max-width: $layout-a-max) flex: 1 - .sized-image + .sized-image, .sized-video color: #eee background-color: #3b3c3d max-height: 94vh max-width: 100% - width: auto - height: auto &:not(:last-child) margin-bottom: 10px + .sized-image + width: auto + height: auto + + .sized-video + width: 100% + height: 100% + .error-page box-sizing: border-box min-height: 100vh