First release
This commit is contained in:
17
src/site/api/api.js
Normal file
17
src/site/api/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const {fetchUser} = require("../../lib/collectors")
|
||||
|
||||
function reply(statusCode, content) {
|
||||
return {
|
||||
statusCode: statusCode,
|
||||
contentType: "application/json",
|
||||
content: JSON.stringify(content)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = [
|
||||
{route: "/api/user/(\\w+)", methods: ["GET"], code: async ({fill}) => {
|
||||
const user = await fetchUser(fill[0])
|
||||
const data = user.export()
|
||||
return reply(200, data)
|
||||
}}
|
||||
]
|
||||
61
src/site/api/proxy.js
Normal file
61
src/site/api/proxy.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const constants = require("../../lib/constants")
|
||||
const {request} = require("../../lib/utils/request")
|
||||
const {proxy} = require("pinski/plugins")
|
||||
const gm = require("gm")
|
||||
|
||||
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"]
|
||||
}
|
||||
// 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 graphicsmagick to force crop the image to a square.
|
||||
Some thumbnails aren't square and will 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 <img srcset>.
|
||||
*/
|
||||
return request(url).then(res => {
|
||||
const image = gm(res.body).gravity("Center").crop(width, width, 0, 0).repage("+")
|
||||
return {
|
||||
statusCode: 200,
|
||||
contentType: "image/jpeg",
|
||||
headers: {
|
||||
"Cache-Control": constants.image_cache_control
|
||||
},
|
||||
stream: image.stream("jpg")
|
||||
}
|
||||
/*
|
||||
Alternative buffer-based method for sending file:
|
||||
|
||||
return new Promise(resolve => {
|
||||
image.toBuffer((err, buffer) => {
|
||||
if (err) console.error(err)
|
||||
resolve({
|
||||
statusCode: 200,
|
||||
contentType: "image/jpeg",
|
||||
content: buffer
|
||||
})
|
||||
})
|
||||
})
|
||||
*/
|
||||
})
|
||||
} else {
|
||||
return proxy(url, {
|
||||
"Cache-Control": constants.image_cache_control
|
||||
})
|
||||
}
|
||||
}}
|
||||
]
|
||||
14
src/site/api/routes.js
Normal file
14
src/site/api/routes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const {fetchUser} = require("../../lib/collectors")
|
||||
const {render} = require("pinski/plugins")
|
||||
|
||||
module.exports = [
|
||||
{route: "/u/(\\w+)", methods: ["GET"], code: async ({url, fill}) => {
|
||||
const params = url.searchParams
|
||||
const user = await fetchUser(fill[0])
|
||||
const page = +params.get("page")
|
||||
if (typeof page === "number" && !isNaN(page) && page >= 1) {
|
||||
await user.timeline.fetchUpToPage(page - 1)
|
||||
}
|
||||
return render(200, "pug/user.pug", {url, user})
|
||||
}}
|
||||
]
|
||||
15
src/site/passthrough.js
Normal file
15
src/site/passthrough.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @typedef Passthrough
|
||||
* @property {import("pinski").Pinski} instance
|
||||
* @property {import("ws").Server} wss
|
||||
* @property {import("pinski").PugCache} pugCache
|
||||
*/
|
||||
|
||||
void 0
|
||||
|
||||
/** @type {Passthrough} */
|
||||
// @ts-ignore
|
||||
const passthrough = {
|
||||
}
|
||||
|
||||
module.exports = passthrough
|
||||
5
src/site/pug/includes/image.pug
Normal file
5
src/site/pug/includes/image.pug
Normal file
@@ -0,0 +1,5 @@
|
||||
mixin image(url)
|
||||
-
|
||||
let params = new URLSearchParams()
|
||||
params.set("url", url)
|
||||
img(src="/imageproxy?"+params.toString())&attributes(attributes)
|
||||
15
src/site/pug/includes/timeline_page.pug
Normal file
15
src/site/pug/includes/timeline_page.pug
Normal file
@@ -0,0 +1,15 @@
|
||||
//- Needs page, pageIndex
|
||||
|
||||
include image.pug
|
||||
|
||||
mixin timeline_page(page, pageIndex)
|
||||
- const pageNumber = pageIndex + 1
|
||||
if pageNumber > 1
|
||||
.page-number(id=`page-${pageNumber}`)
|
||||
span.number Page #{pageNumber}
|
||||
|
||||
.timeline-inner
|
||||
- const suggestedSize = 300
|
||||
each image in page
|
||||
- const thumbnail = image.getSuggestedThumbnail(suggestedSize) //- use this as the src in case there are problems with srcset
|
||||
+image(thumbnail.src)(alt=image.getAlt() width=thumbnail.config_width height=thumbnail.config_height srcset=image.getSrcset() sizes=`${suggestedSize}px`).image
|
||||
49
src/site/pug/user.pug
Normal file
49
src/site/pug/user.pug
Normal file
@@ -0,0 +1,49 @@
|
||||
include includes/timeline_page.pug
|
||||
|
||||
- const numberFormat = new Intl.NumberFormat().format
|
||||
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
meta(charset="utf-8")
|
||||
meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||
title
|
||||
= `${user.data.full_name} (@${user.data.username}) | Bibliogram`
|
||||
link(rel="stylesheet" type="text/css" href="/static/css/main.css")
|
||||
body
|
||||
.main-divider
|
||||
header.profile-overview
|
||||
.profile-sticky
|
||||
+image(user.data.profile_pic_url)(width="150px" height="150px").pfp
|
||||
//-
|
||||
Instagram only uses the above URL, but an HD version is also available:
|
||||
+image(user.data.profile_pic_url_hd)
|
||||
h1.full-name= user.data.full_name
|
||||
h2.username= `@${user.data.username}`
|
||||
p.bio= user.data.biography
|
||||
div.profile-counter
|
||||
span(data-numberformat=user.posts).count #{numberFormat(user.posts)}
|
||||
|
|
||||
| posts
|
||||
div.profile-counter
|
||||
span(data-numberformat=user.following).count #{numberFormat(user.following)}
|
||||
|
|
||||
| following
|
||||
div.profile-counter
|
||||
span(data-numberformat=user.followedBy).count #{numberFormat(user.followedBy)}
|
||||
|
|
||||
| followed by
|
||||
|
||||
main.timeline
|
||||
each page, pageIndex in user.timeline.pages
|
||||
+timeline_page(page, pageIndex)
|
||||
|
||||
if user.timeline.hasNextPage()
|
||||
div.next-page-container
|
||||
-
|
||||
const nu = new URL(url)
|
||||
nu.searchParams.set("page", user.timeline.pages.length+1)
|
||||
a(href=`${nu.search}#page-${user.timeline.pages.length+1}` data-cursor=user.timeline.page_info.end_cursor)#next-page.next-page Next page
|
||||
else
|
||||
div.page-number.no-more-pages
|
||||
span.number No more posts.
|
||||
137
src/site/sass/main.sass
Normal file
137
src/site/sass/main.sass
Normal file
@@ -0,0 +1,137 @@
|
||||
$layout-a-max: 820px
|
||||
$layout-b-min: 821px
|
||||
|
||||
body
|
||||
margin: 0
|
||||
padding: 0
|
||||
font-size: 18px
|
||||
|
||||
.main-divider
|
||||
display: block
|
||||
|
||||
@media screen and (min-width: $layout-b-min)
|
||||
display: grid
|
||||
grid-template-columns: 235px 1fr
|
||||
min-height: 100vh
|
||||
|
||||
.pfp
|
||||
border-radius: 50%
|
||||
|
||||
@mixin link-button
|
||||
color: hsl(107, 100%, 21.8%)
|
||||
background: hsl(87, 78.4%, 80%)
|
||||
padding: 12px
|
||||
border-radius: 10px
|
||||
border: 1px solid hsl(106.9, 49.8%, 46.9%)
|
||||
line-height: 1
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
color: hsl(106.4, 100%, 12.9%)
|
||||
background: hsl(102.1, 77.2%, 67.3%)
|
||||
border-color: hsl(104, 51.4%, 43.5%)
|
||||
|
||||
.profile-overview
|
||||
text-align: center
|
||||
z-index: 1
|
||||
position: relative
|
||||
contain: paint // </3 css
|
||||
line-height: 1
|
||||
|
||||
@media screen and (max-width: $layout-a-max)
|
||||
border-bottom: 1px solid #333
|
||||
box-shadow: 0px -2px 4px 4px rgba(0, 0, 0, 0.4)
|
||||
padding-bottom: 25px
|
||||
|
||||
@media screen and (min-width: $layout-b-min)
|
||||
border-right: 1px solid #333
|
||||
box-shadow: -2px 0px 4px 4px rgba(0, 0, 0, 0.4)
|
||||
|
||||
.profile-sticky
|
||||
position: sticky
|
||||
top: 0
|
||||
bottom: 0
|
||||
padding: 10px
|
||||
|
||||
.pfp
|
||||
margin: 25px 0
|
||||
|
||||
.full-name
|
||||
margin: 0 0 8px
|
||||
font-size: 30px
|
||||
|
||||
.username
|
||||
margin: 0
|
||||
font-size: 20px
|
||||
font-weight: normal
|
||||
|
||||
.profile-counter
|
||||
line-height: 1.3
|
||||
|
||||
.count
|
||||
font-weight: bold
|
||||
|
||||
.timeline
|
||||
--image-size: 260px
|
||||
$image-size: var(--image-size)
|
||||
$background: #fff4e8
|
||||
|
||||
@media screen and (max-width: $layout-a-max)
|
||||
--image-size: 120px
|
||||
|
||||
background-color: $background
|
||||
padding: 15px 15px 12vh
|
||||
|
||||
.page-number
|
||||
color: #444
|
||||
line-height: 1
|
||||
max-width: 600px
|
||||
margin: 0px auto
|
||||
padding: 20px 0px // separate margin and padding for better page hash jump locations
|
||||
text-align: center
|
||||
position: relative
|
||||
|
||||
.number
|
||||
position: relative
|
||||
z-index: 1
|
||||
padding: 10px
|
||||
background-color: $background
|
||||
|
||||
&::before
|
||||
position: absolute
|
||||
display: block
|
||||
content: ""
|
||||
left: 0
|
||||
right: 0
|
||||
top: 50%
|
||||
border-top: 1px solid
|
||||
|
||||
.next-page-container
|
||||
margin: 20px 0px
|
||||
display: flex
|
||||
justify-content: center
|
||||
|
||||
.next-page
|
||||
@include link-button
|
||||
font-size: 18px
|
||||
|
||||
.timeline-inner
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
margin: 0 auto
|
||||
|
||||
.image
|
||||
$margin: 5px
|
||||
|
||||
background-color: rgba(40, 40, 40, 0.25)
|
||||
margin: $margin
|
||||
max-width: $image-size
|
||||
max-height: $image-size
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
&:hover
|
||||
$border-width: 3px
|
||||
margin: $margin - $border-width
|
||||
border: $border-width solid #111
|
||||
26
src/site/server.js
Normal file
26
src/site/server.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const {Pinski} = require("pinski")
|
||||
const {subdirs} = require("node-dir")
|
||||
|
||||
const passthrough = require("./passthrough")
|
||||
|
||||
const pinski = new Pinski({
|
||||
port: 10407,
|
||||
relativeRoot: __dirname
|
||||
})
|
||||
|
||||
subdirs("pug", (err, dirs) => {
|
||||
if (err) throw err
|
||||
|
||||
//pinski.addRoute("/", "pug/index.pug", "pug")
|
||||
pinski.addRoute("/static/css/main.css", "sass/main.sass", "sass")
|
||||
pinski.addPugDir("pug", dirs)
|
||||
pinski.addAPIDir("html/static/js/templates/api")
|
||||
pinski.addSassDir("sass")
|
||||
pinski.addAPIDir("api")
|
||||
pinski.startServer()
|
||||
pinski.enableWS()
|
||||
|
||||
require("pinski/plugins").setInstance(pinski)
|
||||
|
||||
Object.assign(passthrough, pinski.getExports())
|
||||
})
|
||||
Reference in New Issue
Block a user