1
0
mirror of https://git.sr.ht/~cadence/bibliogram synced 2024-11-22 08:07:30 +00:00

First release

This commit is contained in:
Cadence Fish 2020-01-13 01:50:21 +13:00
parent 32e4f3d854
commit 6fd7cc501e
No known key found for this signature in database
GPG Key ID: 81015DF9AA8607E1
31 changed files with 2759 additions and 348 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
.vscode

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# Bibliogram
## An alternative front-end for Instagram.
Bibliogram works without client-side JavaScript, has no ads or tracking, and doesn't urge you to sign up.
See also: [Invidious, a front-end for YouTube.](https://github.com/omarroth/invidious)
## Features
- [x] View profile and timeline
- [x] User memory cache
- [ ] Image disk cache
- [ ] View post
- [ ] Homepage
- [ ] Optimised for mobile
- [ ] Favicon
- [ ] Infinite scroll
- [ ] Settings (e.g. data saving)
- [ ] Galleries
- [ ] List view
- [ ] Videos
- [ ] IGTV
- [ ] Public API
- [ ] Rate limiting
- [ ] Explore tags
- [ ] Explore locations
- [ ] _more..._
These features may not be able to be implemented for technical reasons:
- Stories
These features will not be added:
- Comments
## Instances
There is currently no official Bibliogram instance. You will have to run your own, or find someone else's.
If you only use one computer, you can install Bibliogram on that computer and then access the instance through localhost.
## Installing
1. `git clone https://github.com/cloudrac3r/bibliogram`
1. `npm install`
1. `npm start`
Bibliogram is now running on `0.0.0.0:10407`.
## User-facing endpoints
- `/u/{username}` - load a user's profile and timeline

View File

@ -0,0 +1,12 @@
; DO NOT EDIT (unless you know what you are doing)
;
; This subdirectory is a git "subrepo", and this file is maintained by the
; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme
;
[subrepo]
remote = /home/cloud/Code/pinski-plugins/templates
branch = master
commit = a768add65e7eb7cfd608ef5672342940b2232b4c
parent = e684a04b0313ed5a511a9e3cc50269ebc2b64691
method = merge
cmdver = 0.4.0

View File

@ -0,0 +1,18 @@
const passthrough = require("../../../../../passthrough")
module.exports = [
{
route: "/api/templates", methods: ["GET"], code: async () => {
const result = {}
const entries = passthrough.instance.pugCache.entries()
for (const [file, value] of entries) {
const match = file.match(/client\/.*?([^/]+)\.pug$/)
if (match) {
const name = match[1]
result[name] = value.client.toString()
}
}
return [200, result]
}
}
]

View File

@ -0,0 +1,14 @@
import {AsyncValueCache} from "../avc/avc.js"
const tc = new AsyncValueCache(true, () => {
return fetch("/api/templates").then(res => res.json()).then(data => {
Object.keys(data).forEach(key => {
let fn = Function(data[key] + "; return template")()
data[key] = fn
})
console.log(`Loaded ${Object.keys(data).length} templates`)
return data
})
})
export {tc}

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "esnext",
"checkJs": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
}
}

1804
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "bibliogram",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "cd src/site && node server.js"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {
"gm": "^1.23.1",
"node-dir": "^0.1.17",
"node-fetch": "^2.6.0",
"pinski": "github:cloudrac3r/pinski#oop"
}
}

66
src/lib/cache.js Normal file
View File

@ -0,0 +1,66 @@
class InstaCache {
/**
* @property {number} ttl time to keep each resource in milliseconds
*/
constructor(ttl) {
this.ttl = ttl
/** @type {Map<string, {data: any, time: number}>} */
this.cache = new Map()
}
clean() {
for (const key of this.cache.keys()) {
const value = this.cache.get(key)
if (Date.now() > value.time + this.ttl) this.cache.delete(key)
}
}
/**
* @param {string} key
*/
get(key) {
return this.cache.get(key).data
}
/**
* @param {string} key
* @param {any} data
*/
set(key, data) {
this.cache.set(key, {data, time: Date.now()})
}
/**
* @param {string} key
* @param {() => Promise<T>} callback
* @returns {Promise<T>}
* @template T
*/
getOrFetch(key, callback) {
this.clean()
if (this.cache.has(key)) return Promise.resolve(this.get(key))
else {
const pending = callback().then(result => {
this.set(key, result)
return result
})
this.set(key, pending)
return pending
}
}
/**
* @param {string} key
* @param {() => Promise<T>} callback
* @returns {Promise<T>}
* @template T
*/
getOrFetchPromise(key, callback) {
return this.getOrFetch(key, callback).then(result => {
this.cache.delete(key)
return result
})
}
}
module.exports = InstaCache

43
src/lib/collectors.js Normal file
View File

@ -0,0 +1,43 @@
const constants = require("./constants")
const {request} = require("./utils/request")
const {extractSharedData} = require("./utils/body")
const InstaCache = require("./cache")
const {User} = require("./structures")
require("./testimports")(constants, request, extractSharedData, InstaCache, User)
const cache = new InstaCache(600e3)
function fetchUser(username) {
return cache.getOrFetch("user/"+username, () => {
return request(`https://www.instagram.com/${username}/`).then(res => res.text()).then(text => {
const sharedData = extractSharedData(text)
const user = new User(sharedData.entry_data.ProfilePage[0].graphql.user)
return user
})
})
}
/**
* @param {string} userID
* @param {string} after
* @returns {Promise<import("./types").PagedEdges<import("./types").GraphImage>>}
*/
function fetchTimelinePage(userID, after) {
const p = new URLSearchParams()
p.set("query_hash", constants.external.timeline_query_hash)
p.set("variables", JSON.stringify({
id: userID,
first: constants.external.timeline_fetch_first,
after: after
}))
return cache.getOrFetchPromise("page/"+after, () => {
return request(`https://www.instagram.com/graphql/query/?${p.toString()}`).then(res => res.json()).then(root => {
/** @type {import("./types").PagedEdges<import("./types").GraphImage>} */
const timeline = root.data.user.edge_owner_to_timeline_media
return timeline
})
})
}
module.exports.fetchUser = fetchUser
module.exports.fetchTimelinePage = fetchTimelinePage

12
src/lib/constants.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
image_cache_control: `public, max-age=${7*24*60*60}`,
external: {
timeline_query_hash: "e769aa130647d2354c40ea6a439bfc08",
timeline_fetch_first: 12
},
symbols: {
NO_MORE_PAGES: Symbol("NO_MORE_PAGES")
}
}

View File

@ -0,0 +1,45 @@
const constants = require("../constants")
const TimelineImage = require("./TimelineImage")
const collectors = require("../collectors")
require("../testimports")(constants, TimelineImage)
function transformEdges(edges) {
return edges.map(e => new TimelineImage(e.node))
}
class Timeline {
/**
* @param {import("./User")} user
*/
constructor(user) {
this.user = user
this.pages = []
this.addPage(this.user.data.edge_owner_to_timeline_media)
this.page_info = this.user.data.edge_owner_to_timeline_media.page_info
}
hasNextPage() {
return this.page_info.has_next_page
}
fetchNextPage() {
if (!this.hasNextPage()) return constants.symbols.NO_MORE_PAGES
return collectors.fetchTimelinePage(this.user.data.id, this.page_info.end_cursor).then(page => {
this.addPage(page)
return this.pages.slice(-1)[0]
})
}
async fetchUpToPage(index) {
while (this.pages[index] === undefined && this.hasNextPage()) {
await this.fetchNextPage()
}
}
addPage(page) {
this.pages.push(transformEdges(page.edges))
this.page_info = page.page_info
}
}
module.exports = Timeline

View File

@ -0,0 +1,41 @@
class GraphImage {
/**
* @param {import("../types").GraphImage} data
*/
constructor(data) {
this.data = data
}
/**
* @param {number} size
*/
getSuggestedThumbnail(size) {
let found = null
for (const tr of this.data.thumbnail_resources) {
found = tr
if (tr.config_width >= size) break
}
return found
}
getSrcset() {
return this.data.thumbnail_resources.map(tr => {
const p = new URLSearchParams()
p.set("width", String(tr.config_width))
p.set("url", tr.src)
return `/imageproxy?${p.toString()} ${tr.config_width}w`
}).join(", ")
}
getCaption() {
if (this.data.edge_media_to_caption.edges[0]) return this.data.edge_media_to_caption.edges[0].node.text
else return null
}
getAlt() {
// For some reason, pages 2+ don't contain a11y data. Instagram web client falls back to image caption.
return this.data.accessibility_caption || this.getCaption() || "No image description available."
}
}
module.exports = GraphImage

View File

@ -0,0 +1,21 @@
const Timeline = require("./Timeline")
require("../testimports")(Timeline)
class User {
/**
* @param {import("../types").GraphUser} data
*/
constructor(data) {
this.data = data
this.following = data.edge_follow.count
this.followedBy = data.edge_followed_by.count
this.posts = data.edge_owner_to_timeline_media.count
this.timeline = new Timeline(this)
}
export() {
return this.data
}
}
module.exports = User

View File

@ -0,0 +1,3 @@
module.exports = {
User: require("./User")
}

7
src/lib/testimports.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = function(...items) {
items.forEach(item => {
if (item === undefined || (item && item.constructor && item.constructor.name == "Object" && Object.keys(item).length == 0)) {
throw new Error("Bad import: item looks like this: "+JSON.stringify(item))
}
})
}

64
src/lib/types.js Normal file
View File

@ -0,0 +1,64 @@
/**
* @typedef GraphEdgeCount
* @property {number} count
*/
/**
* @typedef GraphEdgesText
* @type {{edges: {node: {text: string}}[]}}
*/
/**
* @typedef PagedEdges<T>
* @property {number} count
* @property {{has_next_page: boolean, end_cursor: string}} page_info
* @property {{node: T}[]} edges
* @template T
*/
/**
* @typedef GraphUser
* @property {string} biography
* @property {string} external_url
* @property {GraphEdgeCount} edge_followed_by
* @property {GraphEdgeCount} edge_follow
* @property {string} full_name
* @property {string} id
* @property {boolean} is_business_account
* @property {boolean} is_joined_recently
* @property {boolean} is_verified
* @property {string} profile_pic_url
* @property {string} profile_pic_url_hd
* @property {string} username
*
* @property {any} edge_felix_video_timeline
* @property {PagedEdges<GraphImage>} edge_owner_to_timeline_media
* @property {any} edge_saved_media
* @property {any} edge_media_collections
*/
/**
* @typedef Thumbnail
* @property {string} src
* @property {number} config_width
* @property {number} config_height
*/
/**
* @typedef GraphImage
* @property {string} id
* @property {GraphEdgesText} edge_media_to_caption
* @property {string} shortcode
* @property {GraphEdgeCount} edge_media_to_comment
* @property {number} taken_at_timestamp No milliseconds
* @property {GraphEdgeCount} edge_liked_by
* @property {GraphEdgeCount} edge_media_preview_like
* @property {{width: number, height: number}} dimensions
* @property {string} display_url
* @property {{id: string, username: string}} owner
* @property {string} thumbnail_src
* @property {Thumbnail[]} thumbnail_resources
* @property {string} accessibility_caption
*/
module.exports = {}

17
src/lib/utils/body.js Normal file
View File

@ -0,0 +1,17 @@
const {Parser} = require("./parser/parser")
/**
* @param {string} text
*/
function extractSharedData(text) {
const parser = new Parser(text)
parser.seek("window._sharedData = ", {moveToMatch: true, useEnd: true})
parser.store()
const end = parser.seek(";</script>")
parser.restore()
const sharedDataString = parser.slice(end - parser.cursor)
const sharedData = JSON.parse(sharedDataString)
return sharedData
}
module.exports.extractSharedData = extractSharedData

View File

@ -0,0 +1,160 @@
/**
* @typedef GetOptions
* @property {string} [split] Characters to split on
* @property {string} [mode] "until" or "between"; choose where to get the content from
* @property {function} [transform] Transformation to apply to result before returning
*/
const tf = {
lc: s => s.toLowerCase()
}
class Parser {
constructor(string) {
this.string = string;
this.substore = [];
this.cursor = 0;
this.cursorStore = [];
this.mode = "until";
this.transform = s => s;
this.split = " ";
}
/**
* Return all the remaining text from the buffer, without updating the cursor
* @return {String}
*/
remaining() {
return this.string.slice(this.cursor);
}
/**
* Have we reached the end of the string yet?
* @return {boolean}
*/
hasRemaining() {
return this.cursor < this.string.length
}
/**
* @param {GetOptions} [options]
* @returns {String}
*/
get(options = {}) {
["mode", "split", "transform"].forEach(o => {
if (!options[o]) options[o] = this[o];
});
if (options.mode == "until") {
let next = this.string.indexOf(options.split, this.cursor+options.split.length);
if (next == -1) {
let result = this.remaining();
this.cursor = this.string.length;
return result;
} else {
let result = this.string.slice(this.cursor, next);
this.cursor = next + options.split.length;
return options.transform(result);
}
} else if (options.mode == "between") {
let start = this.string.indexOf(options.split, this.cursor);
let end = this.string.indexOf(options.split, start+options.split.length);
let result = this.string.slice(start+options.split.length, end);
this.cursor = end + options.split.length;
return options.transform(result);
}
}
/**
* Get a number of chars from the buffer.
* @param {number} length Number of chars to get
* @param {boolean} [move] Whether to update the cursor
*/
slice(length, move = false) {
let result = this.string.slice(this.cursor, this.cursor+length);
if (move) this.cursor += length;
return result;
}
/**
* Repeatedly swallow a character.
* @param {String} char
*/
swallow(char) {
let before = this.cursor;
while (this.string[this.cursor] == char) this.cursor++;
return this.cursor - before;
}
/**
* Push the current cursor position to the store
*/
store() {
this.cursorStore.push(this.cursor);
}
/**
* Pop the previous cursor position from the store
*/
restore() {
this.cursor = this.cursorStore.pop();
}
/**
* Run a get operation, test against an input, return success or failure, and restore the cursor.
* @param {String} value The value to test against
* @param {Object} options Options for get
*/
test(value, options) {
this.store();
let next = this.get(options);
let result = next == value;
this.restore();
return result;
}
/**
* Run a get operation, test against an input, and throw an error if it doesn't match.
* @param {String} value
* @param {GetOptions} [options]
*/
expect(value, options = {}) {
let next = this.get(options);
if (next != value) throw new Error("Expected "+value+", got "+next);
}
/**
* Seek past the next occurance of the string.
* @param {string} toFind
* @param {{moveToMatch?: boolean, useEnd?: boolean}} options both default to false
*/
seek(toFind, options = {}) {
if (options.moveToMatch === undefined) options.moveToMatch = false
if (options.useEnd === undefined) options.useEnd = false
let index = this.string.indexOf(toFind, this.cursor)
if (index !== -1) {
if (options.useEnd) index += toFind.length
if (options.moveToMatch) this.cursor = index
}
return index
}
/**
* Replace the current string, adding the old one to the substore.
* @param {string} string
*/
unshiftSubstore(string) {
this.substore.unshift({string: this.string, cursor: this.cursor, cursorStore: this.cursorStore})
this.string = string
this.cursor = 0
this.cursorStore = []
}
/**
* Replace the current string with the first entry from the substore.
*/
shiftSubstore() {
Object.assign(this, this.substore.shift())
}
}
module.exports.Parser = Parser

11
src/lib/utils/request.js Normal file
View File

@ -0,0 +1,11 @@
const fetch = require("node-fetch").default
function request(url) {
return fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"
}
})
}
module.exports.request = request

17
src/site/api/api.js Normal file
View 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
View 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
View 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
View 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

View File

@ -0,0 +1,5 @@
mixin image(url)
-
let params = new URLSearchParams()
params.set("url", url)
img(src="/imageproxy?"+params.toString())&attributes(attributes)

View 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
View 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
View 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
View 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())
})

View File

@ -1,348 +0,0 @@
/**
* @typedef {Object} GetOptions
* @property {String} split Characters to split on
* @property {String} mode "until" or "between"; choose where to get the content from
* @property {Function} transform Transformation to apply to result before returning
*/
const tf = {
lc: s => s.toLowerCase()
}
class Parser {
constructor(string) {
this.string = string;
this.substore = [];
this.cursor = 0;
this.cursorStore = [];
this.mode = "until";
this.transform = s => s;
this.split = " ";
}
/**
* Return all the remaining text from the buffer, without updating the cursor
* @return {String}
*/
remaining() {
return this.string.slice(this.cursor);
}
/**
* Have we reached the end of the string yet?
* @return {boolean}
*/
hasRemaining() {
return this.cursor < this.string.length
}
/**
* @param {GetOptions} options
* @returns {String}
*/
get(options = {}) {
["mode", "split", "transform"].forEach(o => {
if (!options[o]) options[o] = this[o];
});
if (options.mode == "until") {
let next = this.string.indexOf(options.split, this.cursor+options.split.length);
if (next == -1) {
let result = this.remaining();
this.cursor = this.string.length;
return result;
} else {
let result = this.string.slice(this.cursor, next);
this.cursor = next + options.split.length;
return options.transform(result);
}
} else if (options.mode == "between") {
let start = this.string.indexOf(options.split, this.cursor);
let end = this.string.indexOf(options.split, start+options.split.length);
let result = this.string.slice(start+options.split.length, end);
this.cursor = end + options.split.length;
return options.transform(result);
} else if (options.mode == "length" || options.length != undefined) {
let result = this.string.slice(this.cursor, this.cursor+options.length);
this.cursor += options.length
return options.transform(result)
}
}
/**
* Get a number of chars from the buffer.
* @param {Number} length Number of chars to get
* @param {Boolean} move Whether to update the cursor
*/
slice(length, move) {
let result = this.string.slice(this.cursor, this.cursor+length);
if (move) this.cursor += length;
return result;
}
/**
* Repeatedly swallow a character.
* @param {String} char
*/
swallow(char) {
let before = this.cursor;
while (this.string[this.cursor] == char) this.cursor++;
return this.cursor - before;
}
/**
* Push the current cursor position to the store
*/
store() {
this.cursorStore.push(this.cursor);
}
/**
* Pop the previous cursor position from the store
*/
restore() {
this.cursor = this.cursorStore.pop();
}
/**
* Run a get operation, test against an input, return success or failure, and restore the cursor.
* @param {String} value The value to test against
* @param {Object} options Options for get
*/
test(value, options) {
this.store();
let next = this.get(options);
let result = next == value;
this.restore();
return result;
}
/**
* Run a get operation, test against an input, and throw an error if it doesn't match.
* @param {String} value
* @param {GetOptions} options
*/
expect(value, options) {
let next = this.get(options);
if (next != value) throw new Error("Expected "+value+", got "+next);
}
/**
* Replace the current string, adding the old one to the substore.
* @param {string} string
*/
unshiftSubstore(string) {
this.substore.unshift({string: this.string, cursor: this.cursor, cursorStore: this.cursorStore})
this.string = string
this.cursor = 0
this.cursorStore = []
}
/**
* Replace the current string with the first entry from the substore.
*/
shiftSubstore() {
Object.assign(this, this.substore.shift())
}
}
class SQLParser extends Parser {
collectAssignments(operators = ["="]) {
let assignments = [];
let done = false;
while (!done) {
// Also build up a raw string.
let raw = "";
// Next word is the field name/index.
let name = this.get();
raw += name;
// Next word should be "=".
let operator = this.get();
if (!operators.includes(operator)) throw new Error("Invalid operator: received "+operator+", expected one from "+JSON.stringify(operators));
raw += " "+operator;
// Next word is the value
let extraction = this.extractValue();
let value = extraction.value;
raw += " "+value;
assignments.push({name, value, raw});
done = extraction.done;
this.swallow(" ");
}
return assignments;
}
extractValue() {
// Next word is the value, which may or may not be in quotes.
if (`"'`.includes(this.slice(1))) {
// Is between quotes
let value = this.get({mode: "between", split: this.slice(1)});
// Check end
let done = this.swallow(",") == 0;
return {value, done};
} else {
// Is not between quotes
let value = this.get();
// Check end (
let done = !value.endsWith(",") || value.endsWith(")");
value = value.replace(/(,|\))$/, "");
return {value, done};
}
}
collectList() {
let items = [];
let done = false;
while (!done) {
let extraction = this.extractValue();
items.push(extraction.value);
done = extraction.done;
this.swallow(" ");
}
return items;
}
parseOptions() {
let options = {};
while (this.remaining().length) { // While there's still options to collect...
// What option are we processing?
let optype = this.get({transform: tf.lc});
// Limit
if (optype == "limit") {
// How many are we limiting to?
options.limit = +this.get();
}
// Where
else if (optype == "where") {
// We'll just pass the filter directly in.
if (!options.filters) options.filters = [];
options.filters = options.filters.concat(this.collectAssignments([
"=", "==", "<", ">", "<=", ">=", "!=", "<>",
"#=", "#<", "#>", "#<=", "#>=", "#!=", "#<>"
]));
}
// Single
else if (optype == "single") {
// Return first row only.
options.single = true;
}
// Join
else if (["left", "right", "inner", "outer"].includes(optype)) {
if (!options.joins) options.joins = [];
// SELECT * FROM Purchases WHERE purchaseID = 2 INNER JOIN PurchaseItems ON Purchases.purchaseID = PurchaseItems.purchaseID
// SELECT * FROM Purchases WHERE purchaseID = 2 INNER JOIN PurchaseItems USING (purchaseID)
// Left, right, inner, outer
let direction = optype;
// Join
this.expect("join", {transform: tf.lc});
// Table
let target = this.get();
// Mode (on/using)
let mode = this.get({transform: tf.lc});
if (mode == "using") {
this.swallow("(");
let field = this.extractValue().value;
this.swallow(")");
var fields = new Array(2).fill().map(() => ({
table: null,
field
}));
} else if (mode == "on") {
var fields = [];
const extractCombo = () => {
// Extract from Table.field
let value = this.extractValue().value;
let fragments = value.split(".");
// Field only
if (fragments.length == 1) {
return {
table: null,
field: fragments[0]
}
}
// Table.field
else {
return {
table: fragments[0],
field: fragments[1]
}
}
}
fields.push(extractCombo());
this.expect("=");
fields.push(extractCombo());
} else {
throw new Error("Invalid join mode: "+mode);
}
options.joins.push({direction, target, fields});
}
// Unknown option
else {
throw new Error("Unknown query optype: "+optype);
}
}
return options;
}
parseStatement() {
let operation = this.get({transform: tf.lc});
// Select
if (operation == "select") {
// Fields
let fields = this.collectList();
// From
this.expect("from", {transform: tf.lc});
// Table
let table = this.get();
// Where, limit, join, single, etc
let options = this.parseOptions();
return {operation, fields, table, options};
}
// Insert
else if (operation == "insert") {
// Into
this.expect("into", {transform: tf.lc});
// Table
let table = this.get();
// Fields?
let fields = [];
this.swallow("(");
if (!this.test("values", {transform: tf.lc})) {
fields = this.collectList();
}
// End of fields
this.swallow(")");
this.swallow(" ");
// Values
this.expect("values", {transform: tf.lc});
this.swallow("(");
let values = this.collectList();
this.swallow(")");
return {operation, table, fields, values};
}
// Update
else if (operation == "update") {
// Table
let table = this.get();
// Set
this.expect("set", {transform: tf.lc});
// Assignments
let assignments = this.collectAssignments();
// Where, limit, join, single, etc
let options = this.parseOptions();
return {operation, table, assignments, options};
}
// Delete
else if (operation == "delete") {
// From
this.expect("from", {transform: tf.lc});
// Table
let table = this.get();
// Where, limit, join, single, etc
let options = this.parseOptions();
return {operation, table, options};
}
throw new Error("Unknown operation: "+operation);
}
}
module.exports.Parser = Parser
module.exports.SQLParser = SQLParser