1
0
mirror of https://git.sr.ht/~cadence/bibliogram synced 2024-11-22 16:17:29 +00:00

Add request quota system

This commit is contained in:
Cadence Ember 2020-07-23 00:58:21 +12:00
parent 2095be2742
commit 112d9cc90e
No known key found for this signature in database
GPG Key ID: 128B99B1B74A6412
17 changed files with 253 additions and 38 deletions

View File

@ -106,15 +106,15 @@ class RequestCache extends TtlCache {
/** /**
* @param {string} key * @param {string} key
* @param {() => Promise<T>} callback * @param {() => Promise<T>} callback
* @returns {Promise<T>} * @returns {Promise<{result: T, fromCache: boolean}>}
*/ */
getOrFetch(key, callback) { getOrFetch(key, callback) {
this.cleanKey(key) this.cleanKey(key)
if (this.cache.has(key)) return Promise.resolve(this.get(key)) if (this.cache.has(key)) return Promise.resolve({result: this.get(key), fromCache: true})
else { else {
const pending = callback().then(result => { const pending = callback().then(result => {
this.set(key, result) this.set(key, result)
return result return {result, fromCache: false}
}) })
this.set(key, pending) this.set(key, pending)
return pending return pending
@ -124,7 +124,7 @@ class RequestCache extends TtlCache {
/** /**
* @param {string} key * @param {string} key
* @param {() => Promise<T>} callback * @param {() => Promise<T>} callback
* @returns {Promise<T>} * @returns {Promise<{result: T, fromCache: boolean}>}
*/ */
getOrFetchPromise(key, callback) { getOrFetchPromise(key, callback) {
return this.getOrFetch(key, callback).then(result => { return this.getOrFetch(key, callback).then(result => {

View File

@ -83,7 +83,7 @@ async function fetchUser(username, context) {
/** /**
* @param {string} username * @param {string} username
* @returns {Promise<import("./structures/User")>} * @returns {Promise<{user: import("./structures/User"), quotaUsed: number}>}
*/ */
function fetchUserFromHTML(username) { function fetchUserFromHTML(username) {
if (constants.caching.self_blocked_status.enabled) { if (constants.caching.self_blocked_status.enabled) {
@ -151,7 +151,7 @@ function fetchUserFromHTML(username) {
} }
throw error throw error
}) })
}) }).then(user => ({user, quotaUsed: 0}))
} }
/** /**
@ -190,7 +190,7 @@ function updateProfilePictureFromReel(userID) {
/** /**
* @param {string} userID * @param {string} userID
* @param {string} username * @param {string} username
* @returns {Promise<import("./structures/ReelUser")|import("./structures/User")>} * @returns {Promise<{user: import("./structures/ReelUser")|import("./structures/User"), quotaUsed: number}>}
*/ */
function fetchUserFromCombined(userID, username) { function fetchUserFromCombined(userID, username) {
// Fetch basic user information // Fetch basic user information
@ -220,11 +220,13 @@ function fetchUserFromCombined(userID, username) {
}) })
}).then(async user => { }).then(async user => {
// Add first timeline page // Add first timeline page
let quotaUsed = 0
if (!user.timeline.pages[0]) { if (!user.timeline.pages[0]) {
const page = await fetchTimelinePage(userID, "") const fetched = await fetchTimelinePage(userID, "")
user.timeline.addPage(page) if (!fetched.fromCache) quotaUsed++
user.timeline.addPage(fetched.result)
} }
return user return {user, quotaUsed}
}).catch(error => { }).catch(error => {
if (error === constants.symbols.RATE_LIMITED) { if (error === constants.symbols.RATE_LIMITED) {
history.report("reel", false) history.report("reel", false)
@ -234,6 +236,7 @@ function fetchUserFromCombined(userID, username) {
} }
function fetchUserFromSaved(saved) { function fetchUserFromSaved(saved) {
let quotaUsed = 0
return userRequestCache.getOrFetch("user/"+saved.username, false, true, async () => { return userRequestCache.getOrFetch("user/"+saved.username, false, true, async () => {
// require down here or have to deal with require loop. require cache will take care of it anyway. // require down here or have to deal with require loop. require cache will take care of it anyway.
// ReelUser -> Timeline -> TimelineEntry -> collectors -/> ReelUser // ReelUser -> Timeline -> TimelineEntry -> collectors -/> ReelUser
@ -252,17 +255,20 @@ function fetchUserFromSaved(saved) {
}) })
// Add first timeline page // Add first timeline page
if (!user.timeline.pages[0]) { if (!user.timeline.pages[0]) {
const page = await fetchTimelinePage(user.data.id, "") const {result: page, fromCache} = await fetchTimelinePage(user.data.id, "")
if (!fromCache) quotaUsed++
user.timeline.addPage(page) user.timeline.addPage(page)
} }
return user return user
}).then(user => {
return {user, quotaUsed}
}) })
} }
/** /**
* @param {string} userID * @param {string} userID
* @param {string} after * @param {string} after
* @returns {Promise<import("./types").PagedEdges<import("./types").TimelineEntryN2>>} * @returns {Promise<{result: import("./types").PagedEdges<import("./types").TimelineEntryN2>, fromCache: boolean}>}
*/ */
function fetchTimelinePage(userID, after) { function fetchTimelinePage(userID, after) {
const p = new URLSearchParams() const p = new URLSearchParams()
@ -298,7 +304,7 @@ function fetchTimelinePage(userID, after) {
/** /**
* @param {string} userID * @param {string} userID
* @param {string} after * @param {string} after
* @returns {Promise<import("./types").PagedEdges<import("./types").TimelineEntryN2>>} * @returns {Promise<{result: import("./types").PagedEdges<import("./types").TimelineEntryN2>, fromCache: boolean}>}
*/ */
function fetchIGTVPage(userID, after) { function fetchIGTVPage(userID, after) {
const p = new URLSearchParams() const p = new URLSearchParams()
@ -329,7 +335,7 @@ function fetchIGTVPage(userID, after) {
/** /**
* @param {string} userID * @param {string} userID
* @param {string} username * @param {string} username
* @returns {Promise<boolean>} * @returns {Promise<{result: boolean, fromCache: boolean}>}
*/ */
function verifyUserPair(userID, username) { function verifyUserPair(userID, username) {
// Fetch basic user information // Fetch basic user information
@ -378,14 +384,14 @@ async function getOrFetchShortcode(shortcode) {
} else { } else {
const data = await fetchShortcodeData(shortcode) const data = await fetchShortcodeData(shortcode)
const entry = getOrCreateShortcode(shortcode) const entry = getOrCreateShortcode(shortcode)
entry.applyN3(data) entry.applyN3(data.result)
return entry return entry
} }
} }
/** /**
* @param {string} shortcode * @param {string} shortcode
* @returns {Promise<import("./types").TimelineEntryN3>} * @returns {Promise<{result: import("./types").TimelineEntryN3, fromCache: boolean}>}
*/ */
function fetchShortcodeData(shortcode) { function fetchShortcodeData(shortcode) {
// example actual query from web: // example actual query from web:

View File

@ -112,6 +112,14 @@ let constants = {
rewrite_twitter: "nitter.net" rewrite_twitter: "nitter.net"
}, },
quota: {
enabled: false,
timeframe: 20*60*60*1000,
count: 50,
ip_mode: "header", // one of: "header", "address"
ip_header: "x-forwarded-for"
},
user_settings: [ user_settings: [
{ {
name: "language", name: "language",
@ -255,6 +263,7 @@ let constants = {
RATE_LIMITED: Symbol("RATE_LIMITED"), RATE_LIMITED: Symbol("RATE_LIMITED"),
ENDPOINT_OVERRIDDEN: Symbol("ENDPOINT_OVERRIDDEN"), ENDPOINT_OVERRIDDEN: Symbol("ENDPOINT_OVERRIDDEN"),
NO_ASSISTANTS_AVAILABLE: Symbol("NO_ASSISTANTS_AVAILABLE"), NO_ASSISTANTS_AVAILABLE: Symbol("NO_ASSISTANTS_AVAILABLE"),
QUOTA_REACHED: Symbol("QUOTA_REACHED"),
extractor_results: { extractor_results: {
SUCCESS: Symbol("SUCCESS"), SUCCESS: Symbol("SUCCESS"),
AGE_RESTRICTED: Symbol("AGE_RESTRICTED"), AGE_RESTRICTED: Symbol("AGE_RESTRICTED"),

View File

@ -0,0 +1,54 @@
const constants = require("../constants")
class Frame {
constructor(identifier) {
this.identifier = identifier
this.frameStartedAt = 0
this.count = 0
}
refresh() {
if (Date.now() > this.frameStartedAt + constants.quota.timeframe) {
this.count = 0
}
}
remaining() {
this.refresh()
return Math.max(constants.quota.count - this.count, 0)
}
add(count) {
this.refresh()
if (this.count === 0) this.frameStartedAt = Date.now()
this.count += count
return this.remaining()
}
}
class LimitByFrame {
constructor() {
/** @type {Map<string, Frame>} */
this.frames = new Map()
}
getOrCreateFrame(identifier) {
if (!this.frames.has(identifier)) {
const frame = new Frame(identifier)
this.frames.set(identifier, frame)
return frame
} else {
return this.frames.get(identifier)
}
}
remaining(identifier) {
return this.getOrCreateFrame(identifier).remaining()
}
add(identifier, count) {
return this.getOrCreateFrame(identifier).add(count)
}
}
module.exports = LimitByFrame

View File

@ -0,0 +1,25 @@
const {request} = require("../utils/request")
const {log} = require("pinski/util/common")
let addresses = []
request("https://check.torproject.org/torbulkexitlist").text().then(text => {
const lines = text.split("\n").filter(l => l)
addresses = addresses.concat(lines)
log(`Loaded Tor exit node list (${addresses.length} total)`, "spam")
})
/*
request("https://meta.bibliogram.art/ip_proxy_list.txt").text().then(text => {
const lines = text.split("\n").filter(l => l)
addresses = addresses.concat(lines)
log(`Loaded Bibliogram proxy list (${addresses.length} total)`, "spam")
})
*/
function getIdentifier(address) {
if (addresses.includes(address)) return "proxy"
else return address
}
module.exports.getIdentifier = getIdentifier

33
src/lib/quota/index.js Normal file
View File

@ -0,0 +1,33 @@
const constants = require("../constants")
const LimitByFrame = require("./LimitByFrame")
const {getIdentifier} = require("./get_identifier")
require("../testimports")(LimitByFrame, getIdentifier)
const limiter = new LimitByFrame()
function getIPFromReq(req) {
if (constants.quota.ip_mode === "header") {
return req.headers[constants.quota.ip_header]
} else { // constants.quota.ip_mode === "address"
return req.connection.remoteAddress
}
}
function remaining(req) {
if (!constants.quota.enabled) return Infinity // sure.
const ip = getIPFromReq(req)
const identifier = getIdentifier(ip)
return limiter.remaining(identifier)
}
function add(req, count) {
if (!constants.quota.enabled) return Infinity // why not.
const ip = getIPFromReq(req)
const identifier = getIdentifier(ip)
return limiter.add(identifier, count)
}
module.exports.remaining = remaining
module.exports.add = add

View File

@ -45,17 +45,23 @@ class Timeline {
: this.type === "igtv" ? collectors.fetchIGTVPage : this.type === "igtv" ? collectors.fetchIGTVPage
: null : null
const after = this.page_info ? this.page_info.end_cursor : "" const after = this.page_info ? this.page_info.end_cursor : ""
return method(this.user.data.id, after).then(page => { return method(this.user.data.id, after).then(({result: page, fromCache}) => {
const quotaUsed = fromCache ? 0 : 1
this.addPage(page) this.addPage(page)
return this.pages.slice(-1)[0] return {page: this.pages.slice(-1)[0], quotaUsed}
}) })
} }
async fetchUpToPage(index) { async fetchUpToPage(index) {
let quotaUsed = 0
while (this.pages[index] === undefined && this.hasNextPage()) { while (this.pages[index] === undefined && this.hasNextPage()) {
await this.fetchNextPage() const result = await this.fetchNextPage()
if (typeof result !== "symbol") {
quotaUsed += result.quotaUsed
} }
} }
return quotaUsed
}
addPage(page) { addPage(page) {
// update whether the user should be private // update whether the user should be private

View File

@ -32,7 +32,7 @@ class TimelineEntry extends TimelineBaseMethods {
async update() { async update() {
return collectors.fetchShortcodeData(this.data.shortcode).then(data => { return collectors.fetchShortcodeData(this.data.shortcode).then(data => {
this.applyN3(data) this.applyN3(data.result)
}).catch(error => { }).catch(error => {
console.error("TimelineEntry could not self-update; trying to continue anyway...") console.error("TimelineEntry could not self-update; trying to continue anyway...")
console.error("E:", error) console.error("E:", error)

View File

@ -57,7 +57,7 @@ module.exports = [
route: `/u/(${constants.external.username_regex})/(rss|atom)\\.xml`, methods: ["GET"], code: ({fill}) => { route: `/u/(${constants.external.username_regex})/(rss|atom)\\.xml`, methods: ["GET"], code: ({fill}) => {
const kind = fill[1] const kind = fill[1]
if (constants.feeds.enabled) { if (constants.feeds.enabled) {
return fetchUser(fill[0], constants.symbols.fetch_context.RSS).then(async user => { return fetchUser(fill[0], constants.symbols.fetch_context.RSS).then(async ({user}) => {
const feed = await user.timeline.fetchFeed() const feed = await user.timeline.fetchFeed()
if (constants.feeds.feed_message.enabled) { if (constants.feeds.feed_message.enabled) {
addAnnouncementFeedItem(feed) addAnnouncementFeedItem(feed)

View File

@ -6,6 +6,7 @@ const {render, redirect, getStaticURL} = require("pinski/plugins")
const {pugCache} = require("../passthrough") const {pugCache} = require("../passthrough")
const {getSettings} = require("./utils/getsettings") const {getSettings} = require("./utils/getsettings")
const {getSettingsReferrer} = require("./utils/settingsreferrer") const {getSettingsReferrer} = require("./utils/settingsreferrer")
const quota = require("../../lib/quota")
/** @param {import("../../lib/structures/TimelineEntry")} post */ /** @param {import("../../lib/structures/TimelineEntry")} post */
function getPageTitle(post) { function getPageTitle(post) {
@ -66,21 +67,38 @@ module.exports = [
} }
}, },
{ {
route: `/u/(${constants.external.username_regex})(/channel)?`, methods: ["GET"], code: ({req, url, fill}) => { route: `/u/(${constants.external.username_regex})(/channel)?`, methods: ["GET"], code: async ({req, url, fill}) => {
const username = fill[0] const username = fill[0]
const type = fill[1] ? "igtv" : "timeline" const type = fill[1] ? "igtv" : "timeline"
if (username !== username.toLowerCase()) { // some capital letters if (username !== username.toLowerCase()) { // some capital letters
return Promise.resolve(redirect(`/u/${username.toLowerCase()}`, 301)) return redirect(`/u/${username.toLowerCase()}`, 301)
} }
const settings = getSettings(req) const settings = getSettings(req)
const params = url.searchParams const params = url.searchParams
return fetchUser(username).then(async user => {
try {
if (quota.remaining(req) === 0) {
throw constants.symbols.QUOTA_REACHED
}
const {user, quotaUsed} = await fetchUser(username)
let remaining = quota.add(req, quotaUsed)
const selectedTimeline = user[type] const selectedTimeline = user[type]
let pageNumber = +params.get("page") let pageNumber = +params.get("page")
if (isNaN(pageNumber) || pageNumber < 1) pageNumber = 1 if (isNaN(pageNumber) || pageNumber < 1) pageNumber = 1
await selectedTimeline.fetchUpToPage(pageNumber - 1) const pageIndex = pageNumber - 1
const pagesNeeded = pageNumber - selectedTimeline.pages.length
if (pagesNeeded > remaining) {
throw constants.symbols.QUOTA_REACHED
}
const quotaUsed2 = await selectedTimeline.fetchUpToPage(pageIndex)
remaining = quota.add(req, quotaUsed2)
const followerCountsAvailable = !(user.constructor.name === "ReelUser" && user.following === 0 && user.followedBy === 0) const followerCountsAvailable = !(user.constructor.name === "ReelUser" && user.following === 0 && user.followedBy === 0)
return render(200, "pug/user.pug", { return render(200, "pug/user.pug", {
url, url,
@ -90,9 +108,10 @@ module.exports = [
followerCountsAvailable, followerCountsAvailable,
constants, constants,
settings, settings,
settingsReferrer: getSettingsReferrer(req.url) settingsReferrer: getSettingsReferrer(req.url),
remaining
}) })
}).catch(error => { } catch (error) {
if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) { if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) {
return render(404, "pug/friendlyerror.pug", { return render(404, "pug/friendlyerror.pug", {
statusCode: 404, statusCode: 404,
@ -119,10 +138,21 @@ module.exports = [
} }
} else if (error === constants.symbols.extractor_results.AGE_RESTRICTED) { } else if (error === constants.symbols.extractor_results.AGE_RESTRICTED) {
return render(403, "pug/age_gated.pug", {settings}) return render(403, "pug/age_gated.pug", {settings})
} else if (error === constants.symbols.QUOTA_REACHED) {
return render(429, "pug/friendlyerror.pug", {
title: "Quota reached",
statusCode: 429,
message: "Quota reached",
explanation:
"Each person has a limited number of requests to Bibliogram."
+"\nYou have reached that limit."
+"\nWait a while to for your counter to reset.\n",
withInstancesLink: true
})
} else { } else {
throw error throw error
} }
}) }
} }
}, },
{ {
@ -139,11 +169,27 @@ module.exports = [
let type = url.searchParams.get("type") let type = url.searchParams.get("type")
if (!["timeline", "igtv"].includes(type)) type = "timeline" if (!["timeline", "igtv"].includes(type)) type = "timeline"
try {
if (quota.remaining(req) === 0) {
throw constants.symbols.QUOTA_REACHED
}
const settings = getSettings(req) const settings = getSettings(req)
return fetchUser(username).then(async user => {
const {user, quotaUsed} = await fetchUser(username)
const remaining = quota.add(req, quotaUsed)
const pageIndex = pageNumber - 1 const pageIndex = pageNumber - 1
const selectedTimeline = user[type] const selectedTimeline = user[type]
await selectedTimeline.fetchUpToPage(pageIndex)
const pagesNeeded = pageNumber - selectedTimeline.pages.length
if (pagesNeeded > remaining) {
throw constants.symbols.QUOTA_REACHED
}
const quotaUsed2 = await selectedTimeline.fetchUpToPage(pageIndex)
quota.add(req, quotaUsed2)
if (selectedTimeline.pages[pageIndex]) { if (selectedTimeline.pages[pageIndex]) {
return render(200, "pug/fragments/timeline_page.pug", {page: selectedTimeline.pages[pageIndex], selectedTimeline, type, pageIndex, user, url, settings}) return render(200, "pug/fragments/timeline_page.pug", {page: selectedTimeline.pages[pageIndex], selectedTimeline, type, pageIndex, user, url, settings})
} else { } else {
@ -153,7 +199,7 @@ module.exports = [
content: "That page does not exist." content: "That page does not exist."
} }
} }
}).catch(error => { } catch (error) {
if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) { if (error === constants.symbols.NOT_FOUND || error === constants.symbols.ENDPOINT_OVERRIDDEN) {
return render(404, "pug/friendlyerror.pug", { return render(404, "pug/friendlyerror.pug", {
statusCode: 404, statusCode: 404,
@ -163,10 +209,12 @@ module.exports = [
}) })
} else if (error === constants.symbols.INSTAGRAM_DEMANDS_LOGIN || error === constants.symbols.RATE_LIMITED) { } else if (error === constants.symbols.INSTAGRAM_DEMANDS_LOGIN || error === constants.symbols.RATE_LIMITED) {
return render(503, "pug/fragments/timeline_loading_blocked.pug") return render(503, "pug/fragments/timeline_loading_blocked.pug")
} else if (error === constants.symbols.QUOTA_REACHED) {
return render(429, "pug/fragments/timeline_quota_reached.pug")
} else { } else {
throw error throw error
} }
}) }
} }
}, },
{ {

View File

@ -65,7 +65,7 @@ module.exports = [
delete saved.updated_version delete saved.updated_version
return Promise.resolve(replyWithUserData(saved)) return Promise.resolve(replyWithUserData(saved))
} else { } else {
return collectors.fetchUser(username, constants.symbols.fetch_context.ASSISTANT).then(user => { return collectors.fetchUser(username, constants.symbols.fetch_context.ASSISTANT).then(({user}) => {
return replyWithUserData({ return replyWithUserData({
username: user.data.username, username: user.data.username,
user_id: user.data.id, user_id: user.data.id,

View File

@ -1,4 +1,5 @@
import {ElemJS, q} from "./elemjs/elemjs.js" import {ElemJS, q} from "./elemjs/elemjs.js"
import {quota} from "./quota.js"
class FreezeWidth extends ElemJS { class FreezeWidth extends ElemJS {
freeze(text) { freeze(text) {
@ -79,6 +80,7 @@ class NextPage extends FreezeWidth {
const type = this.element.getAttribute("data-type") const type = this.element.getAttribute("data-type")
return fetch(`/fragment/user/${this.element.getAttribute("data-username")}/${this.nextPageNumber}?type=${type}`).then(res => res.text()).then(text => { return fetch(`/fragment/user/${this.element.getAttribute("data-username")}/${this.nextPageNumber}?type=${type}`).then(res => res.text()).then(text => {
quota.change(-1)
q("#next-page-container").remove() q("#next-page-container").remove()
this.observer.disconnect() this.observer.disconnect()
q("#timeline").insertAdjacentHTML("beforeend", text) q("#timeline").insertAdjacentHTML("beforeend", text)

View File

@ -0,0 +1,18 @@
import {ElemJS, q} from "./elemjs/elemjs.js"
class Quota extends ElemJS {
constructor() {
super(q("#quota"))
this.value = +this.element.textContent
}
change(difference) {
this.value += difference
this.value = Math.max(0, this.value)
this.text(this.value)
}
}
const quota = new Quota()
export {quota}

View File

@ -0,0 +1,7 @@
.error-fragment
.message Quota reached
.explanation.
Each person has a limited number of requests to Bibliogram.
You have reached that limit. You cannot load any more data on this instance.
Your quota will reset automatically after some time has passed.
Or, you could try #[a(href="https://git.sr.ht/~cadence/bibliogram-docs/tree/master/docs/Instances.md") browsing Bibliogram on another instance.]

View File

@ -95,6 +95,8 @@ html
.links .links
a(href="/")= ll.t_home a(href="/")= ll.t_home
a(href=settingsReferrer)= ll.t_settings a(href=settingsReferrer)= ll.t_settings
if constants.quota.enabled
.quota Quota left: #[span#quota= remaining]
- const hasPosts = !user.data.is_private && selectedTimeline.pages.length && selectedTimeline.pages[0].length - const hasPosts = !user.data.is_private && selectedTimeline.pages.length && selectedTimeline.pages[0].length
.timeline-section .timeline-section

View File

@ -163,6 +163,9 @@ body
> * > *
margin: 5px 8px margin: 5px 8px
.quota
margin: 15px 0px
.bibliogram-meta .bibliogram-meta
margin: 20px 10px margin: 20px 10px
border-top: map-get($theme, "edge-context-divider") border-top: map-get($theme, "edge-context-divider")

View File

@ -5,10 +5,15 @@ const fs = require("fs").promises
const Jimp = require("jimp") const Jimp = require("jimp")
const commands = require("./screenshots/commands") const commands = require("./screenshots/commands")
const child_process = require("child_process") const child_process = require("child_process")
const constants = require("../src/lib/constants")
const browser = "firefox" const browser = "firefox"
const origin = "http://localhost:10407" const port = 10407 + (+process.env.TAP_CHILD_ID)
constants.port = port
constants.request_backend = "saved" // predictable request results
const origin = `http://localhost:${port}`
constants.website_origin = origin
const dimensions = new Map([ const dimensions = new Map([
["firefox", { ["firefox", {
@ -25,9 +30,6 @@ const dimensions = new Map([
const browserDimensions = dimensions.get(browser) const browserDimensions = dimensions.get(browser)
const constants = require("../src/lib/constants")
constants.request_backend = "saved" // predictable request results
process.chdir("src/site") process.chdir("src/site")
const server = require("../src/site/server") const server = require("../src/site/server")