mirror of
https://git.sr.ht/~cadence/cloudtube
synced 2026-03-02 10:41:36 +00:00
Add database and subscribe button
This commit is contained in:
parent
2af05e43a9
commit
f24e1bb39c
25 changed files with 972 additions and 44 deletions
|
|
@ -1,12 +1,16 @@
|
|||
const fetch = require("node-fetch")
|
||||
const {render} = require("pinski/plugins")
|
||||
const constants = require("./utils/constants")
|
||||
const {fetchChannel} = require("./utils/youtube")
|
||||
const {getUser} = require("./utils/getuser")
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
route: "/channel/([A-Za-z0-9-_]+)", methods: ["GET"], code: async ({fill}) => {
|
||||
route: `/channel/(${constants.regex.ucid})`, methods: ["GET"], code: async ({req, fill}) => {
|
||||
const id = fill[0]
|
||||
const data = await fetch(`http://localhost:3000/api/v1/channels/${id}`).then(res => res.json())
|
||||
return render(200, "pug/channel.pug", {data})
|
||||
const data = await fetchChannel(id)
|
||||
const user = getUser(req)
|
||||
const subscribed = user.isSubscribed(id)
|
||||
return render(200, "pug/channel.pug", {data, subscribed})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
56
api/formapi.js
Normal file
56
api/formapi.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const {redirect} = require("pinski/plugins")
|
||||
const db = require("./utils/db")
|
||||
const constants = require("./utils/constants")
|
||||
const {getToken} = require("./utils/getuser")
|
||||
const validate = require("./utils/validate")
|
||||
const V = validate.V
|
||||
const {fetchChannel} = require("./utils/youtube")
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
route: `/formapi/(un|)subscribe/(${constants.regex.ucid})`, methods: ["POST"], upload: true, code: async ({req, fill, body}) => {
|
||||
const add = !fill[0]
|
||||
const ucid = fill[1]
|
||||
|
||||
return new V()
|
||||
.with(validate.presetLoad({body}))
|
||||
.with(validate.presetURLParamsBody())
|
||||
.last(async state => {
|
||||
const {params} = state
|
||||
const responseHeaders = {}
|
||||
const token = getToken(req, responseHeaders)
|
||||
|
||||
if (add) {
|
||||
await fetchChannel(ucid)
|
||||
db.prepare("INSERT OR IGNORE INTO Subscriptions (token, ucid) VALUES (?, ?)").run(token, ucid)
|
||||
} else {
|
||||
db.prepare("DELETE FROM Subscriptions WHERE token = ? AND ucid = ?").run(token, ucid)
|
||||
}
|
||||
|
||||
if (params.has("referrer")) {
|
||||
return {
|
||||
statusCode: 303,
|
||||
contentType: "application/json",
|
||||
headers: Object.assign(responseHeaders, {
|
||||
Location: params.get("referrer")
|
||||
}),
|
||||
content: {
|
||||
status: "ok"
|
||||
}
|
||||
}
|
||||
return redirect(params.get("referrer"), 303)
|
||||
} else {
|
||||
return {
|
||||
statusCode: 200,
|
||||
contentType: "application/json",
|
||||
headers: responseHeaders,
|
||||
content: {
|
||||
status: "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.go()
|
||||
}
|
||||
}
|
||||
]
|
||||
12
api/utils/constants.js
Normal file
12
api/utils/constants.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
const constants = {
|
||||
caching: {
|
||||
csrf_time: 4*60*60*1000
|
||||
},
|
||||
|
||||
regex: {
|
||||
ucid: "[A-Za-z0-9-_]+",
|
||||
video_id: "[A-Za-z0-9-_]+"
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = constants
|
||||
8
api/utils/db.js
Normal file
8
api/utils/db.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const sqlite = require("better-sqlite3")
|
||||
const pj = require("path").join
|
||||
const fs = require("fs")
|
||||
|
||||
const dir = pj(__dirname, "../../db")
|
||||
fs.mkdirSync(pj(dir, "backups"), {recursive: true})
|
||||
const db = new sqlite(pj(dir, "cloudtube.db"))
|
||||
module.exports = db
|
||||
78
api/utils/getuser.js
Normal file
78
api/utils/getuser.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
const crypto = require("crypto")
|
||||
const {parse: parseCookie} = require("cookie")
|
||||
|
||||
const constants = require("./constants")
|
||||
const db = require("./db")
|
||||
|
||||
function getToken(req, responseHeaders) {
|
||||
if (!req.headers.cookie) req.headers.cookie = ""
|
||||
const cookie = parseCookie(req.headers.cookie)
|
||||
const token = cookie.token
|
||||
if (token) return token
|
||||
if (responseHeaders) { // we should create a token
|
||||
const setCookie = responseHeaders["set-cookie"] || []
|
||||
const token = crypto.randomBytes(18).toString("base64").replace(/\W/g, "_")
|
||||
setCookie.push(`token=${token}; Path=/; Max-Age=2147483648; HttpOnly; SameSite=Lax`)
|
||||
responseHeaders["set-cookie"] = setCookie
|
||||
return token
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class User {
|
||||
constructor(token) {
|
||||
this.token = token
|
||||
}
|
||||
|
||||
getSubscriptions() {
|
||||
if (this.token) {
|
||||
return db.prepare("SELECT ucid FROM Subscriptions WHERE token = ?").pluck().all(ucid)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
isSubscribed(ucid) {
|
||||
if (this.token) {
|
||||
return !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([this.token, ucid])
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} responseHeaders supply this to create a token
|
||||
*/
|
||||
function getUser(req, responseHeaders) {
|
||||
const token = getToken(req, responseHeaders)
|
||||
return new User(token)
|
||||
}
|
||||
|
||||
function generateCSRF() {
|
||||
const token = crypto.randomBytes(16).toString("hex")
|
||||
const expires = Date.now() + constants.caching.csrf_time
|
||||
db.prepare("INSERT INTO CSRFTokens (token, expires) VALUES (?, ?)").run(token, expires)
|
||||
return token
|
||||
}
|
||||
|
||||
function checkCSRF(token) {
|
||||
const row = db.prepare("SELECT * FROM CSRFTokens WHERE token = ? AND expires > ?").get(token, Date.now())
|
||||
if (row) {
|
||||
db.prepare("DELETE FROM CSRFTokens WHERE token = ?").run(token)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function cleanCSRF() {
|
||||
db.prepare("DELETE FROM CSRFTokens WHERE expires <= ?").run(Date.now())
|
||||
}
|
||||
cleanCSRF()
|
||||
setInterval(cleanCSRF, constants.caching.csrf_time).unref()
|
||||
|
||||
module.exports.getToken = getToken
|
||||
module.exports.generateCSRF = generateCSRF
|
||||
module.exports.checkCSRF = checkCSRF
|
||||
module.exports.getUser = getUser
|
||||
55
api/utils/upgradedb.js
Normal file
55
api/utils/upgradedb.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
const pj = require("path").join
|
||||
const db = require("./db")
|
||||
|
||||
const deltas = [
|
||||
// 0: from empty file, +DatabaseVersion, +Subscriptions
|
||||
function() {
|
||||
db.prepare("CREATE TABLE DatabaseVersion (version INTEGER NOT NULL, PRIMARY KEY (version))")
|
||||
.run()
|
||||
db.prepare("CREATE TABLE Subscriptions (token TEXT NOT NULL, ucid TEXT NOT NULL, PRIMARY KEY (token, ucid))")
|
||||
.run()
|
||||
db.prepare("CREATE TABLE Channels (ucid TEXT NOT NULL, name TEXT NOT NULL, icon_url TEXT, PRIMARY KEY (ucid))")
|
||||
.run()
|
||||
db.prepare("CREATE TABLE CSRFTokens (token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (token))")
|
||||
.run()
|
||||
}
|
||||
]
|
||||
|
||||
async function createBackup(entry) {
|
||||
const filename = `db/backups/cloudtube.db.bak-v${entry-1}`
|
||||
process.stdout.write(`Backing up current to ${filename}... `)
|
||||
await db.backup(pj(__dirname, "../../", filename))
|
||||
process.stdout.write("done.\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} entry
|
||||
* @param {boolean} log
|
||||
*/
|
||||
function runDelta(entry, log) {
|
||||
process.stdout.write(`Upgrading database to version ${entry}... `)
|
||||
deltas[entry]()
|
||||
db.prepare("DELETE FROM DatabaseVersion").run()
|
||||
db.prepare("INSERT INTO DatabaseVersion (version) VALUES (?)").run(entry)
|
||||
process.stdout.write("done.\n")
|
||||
}
|
||||
|
||||
module.exports = async function() {
|
||||
let currentVersion = -1
|
||||
const newVersion = deltas.length - 1
|
||||
|
||||
try {
|
||||
currentVersion = db.prepare("SELECT version FROM DatabaseVersion").pluck().get()
|
||||
} catch (e) {} // if the table doesn't exist yet then we don't care
|
||||
|
||||
if (currentVersion !== newVersion) {
|
||||
// go through the entire upgrade sequence
|
||||
for (let entry = currentVersion+1; entry <= newVersion; entry++) {
|
||||
// Back up current version
|
||||
if (entry > 0) await createBackup(entry)
|
||||
|
||||
// Run delta
|
||||
runDelta(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
api/utils/validate.js
Normal file
83
api/utils/validate.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
class V {
|
||||
constructor() {
|
||||
this.chain = []
|
||||
this.state = {}
|
||||
this.finished = false
|
||||
this.endValue = null
|
||||
}
|
||||
|
||||
with(preset) {
|
||||
this.check(...preset)
|
||||
return this
|
||||
}
|
||||
|
||||
check(conditionCallback, elseCallback) {
|
||||
this.chain.push(() => {
|
||||
if (!conditionCallback(this.state)) this._end(elseCallback(this.state))
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
last(callback) {
|
||||
this.chain.push(() => {
|
||||
this._end(callback(this.state))
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
go() {
|
||||
for (const s of this.chain) {
|
||||
s()
|
||||
if (this.finished) return this.endValue
|
||||
}
|
||||
return {
|
||||
statusCode: 500,
|
||||
contentType: "application/json",
|
||||
content: {
|
||||
error: "Reached end of V chain without response"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_end(value) {
|
||||
this.finished = true
|
||||
this.endValue = value
|
||||
}
|
||||
}
|
||||
|
||||
function presetLoad(additions) {
|
||||
return [
|
||||
state => {
|
||||
Object.assign(state, additions)
|
||||
return true
|
||||
},
|
||||
null
|
||||
]
|
||||
}
|
||||
|
||||
function presetURLParamsBody() {
|
||||
return [
|
||||
state => {
|
||||
try {
|
||||
state.params = new URLSearchParams(state.body.toString())
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
statusCode: 400,
|
||||
contentType: "application/json",
|
||||
content: {
|
||||
error: "Could not parse body as URLSearchParams"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
module.exports.V = V
|
||||
module.exports.presetLoad = presetLoad
|
||||
module.exports.presetURLParamsBody = presetURLParamsBody
|
||||
15
api/utils/youtube.js
Normal file
15
api/utils/youtube.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const fetch = require("node-fetch")
|
||||
const db = require("./db")
|
||||
|
||||
async function fetchChannel(ucid) {
|
||||
// fetch
|
||||
const channel = await fetch(`http://localhost:3000/api/v1/channels/${ucid}`).then(res => res.json())
|
||||
// update database
|
||||
const bestIcon = channel.authorThumbnails.slice(-1)[0]
|
||||
const iconURL = bestIcon ? bestIcon.url : null
|
||||
db.prepare("REPLACE INTO Channels (ucid, name, icon_url) VALUES (?, ?, ?)").run([channel.authorId, channel.author, iconURL])
|
||||
// return
|
||||
return channel
|
||||
}
|
||||
|
||||
module.exports.fetchChannel = fetchChannel
|
||||
19
api/video.js
Normal file
19
api/video.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const fetch = require("node-fetch")
|
||||
const {render} = require("pinski/plugins")
|
||||
const db = require("./utils/db")
|
||||
const {getToken} = require("./utils/getuser")
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
route: "/watch", methods: ["GET"], code: async ({req, url}) => {
|
||||
const id = url.searchParams.get("v")
|
||||
const video = await fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json())
|
||||
let subscribed = false
|
||||
const token = getToken(req)
|
||||
if (token) {
|
||||
subscribed = !!db.prepare("SELECT * FROM Subscriptions WHERE token = ? AND ucid = ?").get([token, video.authorId])
|
||||
}
|
||||
return render(200, "pug/video.pug", {video, subscribed})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -177,14 +177,14 @@ function fetchChannel(channelID, ignoreCache) {
|
|||
}
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
/*{
|
||||
route: "/watch", methods: ["GET"], code: async ({url}) => {
|
||||
const id = url.searchParams.get("v")
|
||||
const video = await videoCache.getAs(id, () => fetch(`http://localhost:3000/api/v1/videos/${id}`).then(res => res.json()))
|
||||
return render(200, "pug/video.pug", {video})
|
||||
}
|
||||
}
|
||||
/*
|
||||
},
|
||||
|
||||
{
|
||||
route: "/v/(.*)", methods: ["GET"], code: async ({fill}) => {
|
||||
let id;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue