add /vods and /vt/:slug/vods
Some checks failed
ci / test (push) Failing after 9m42s
fp/our CI/CD / build (push) Successful in 1m19s

This commit is contained in:
CJ_Clippy 2025-11-08 12:36:26 -08:00
parent fe1f318424
commit 6caf2dbcc3
34 changed files with 1178 additions and 258 deletions

View File

@ -92,3 +92,15 @@ https://pocketbuilds.com/
how the pros do it how the pros do it
https://github.com/benallfree/pocketpages/blob/5bc48d4f8df75b2f78ca61fa18c792d814b926e8/packages/starters/deploy-pockethost-manual/deploy-pockethost.ts#L5 https://github.com/benallfree/pocketpages/blob/5bc48d4f8df75b2f78ca61fa18c792d814b926e8/packages/starters/deploy-pockethost-manual/deploy-pockethost.ts#L5
## Avoid proxying files via pocketbase
https://github.com/pocketbase/pocketbase/discussions/5995
## realtime updates on db changes
https://github.com/pocketbase/pocketbase/discussions/4427#discussioncomment-8585118
## get a random item
https://github.com/pocketbase/pocketbase/discussions/2725

View File

@ -1,15 +1,17 @@
{ {
"name": "futureporn", "name": "futureporn",
"version": "0.0.3", "version": "3.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "futureporn", "name": "futureporn",
"version": "0.0.3", "version": "3.0.3",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mime": "^4.1.0",
"nano-spawn": "^2.0.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"pocketpages": ">=0.22.3", "pocketpages": ">=0.22.3",
"pocketpages-plugin-auth": "^0.2.2", "pocketpages-plugin-auth": "^0.2.2",
@ -1771,6 +1773,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mimic-function": { "node_modules/mimic-function": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
@ -1870,6 +1887,18 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/onetime": { "node_modules/onetime": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "futureporn", "name": "futureporn",
"version": "3.0.3", "version": "3.1.0",
"private": true, "private": true,
"description": "Dedication to the preservation of lewdtuber history", "description": "Dedication to the preservation of lewdtuber history",
"license": "Unlicense", "license": "Unlicense",
@ -9,6 +9,8 @@
}, },
"dependencies": { "dependencies": {
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mime": "^4.1.0",
"nano-spawn": "^2.0.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"pocketpages": ">=0.22.3", "pocketpages": ">=0.22.3",
"pocketpages-plugin-auth": "^0.2.2", "pocketpages-plugin-auth": "^0.2.2",

View File

@ -0,0 +1,107 @@
/// <reference path="../pb_data/types.d.ts" />
/**
* onFileDownloadRequest hook is triggered before each API File download request. Could be used to validate or modify the file response before returning it to the client.
* @see https://pocketbase.io/docs/js-event-hooks/#onfiledownloadrequest
*
* We use this to return a 302 to the CDN asset instead of having the asset proxied via Pocketbase
* @see https://github.com/pocketbase/pocketbase/discussions/5995
*
*/
onFileDownloadRequest((e) => {
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// e.next()
// e.app
// e.collection
// e.record
// e.fileField
// e.servedPath
// e.servedName
// and all RequestEvent fields...
const securityKey = process.env?.BUNNY_TOKEN_KEY;
const baseUrl = process.env?.BUNNY_ZONE_URL;
console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`)
if (!securityKey) {
console.error('BUNNY_TOKEN_KEY was missing from env');
return e.next();
}
if (!baseUrl) {
console.error('BUNNY_ZONE_URL was missing from env');
return e.next();
}
/**
* Generates a BunnyCDN-style signed URL using directory tokens.
*
* We sign URLs to make hotlinking difficult
* @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
* @see https://github.com/pocketbase/pocketbase/discussions/5983#discussioncomment-11426659 // HMAC in pocketbase
* @see https://github.com/pocketbase/pocketbase/discussions/6772 // base64 encode the hex
*/
function signUrl(securityKey, baseUrl, path, expires) {
if (!path.startsWith('/')) path = '/' + path;
if (baseUrl.endsWith('/')) throw new Error(`baseUrl must not end with a slash. got baseUrl=${baseUrl}`);
const hashableBase = securityKey + path + expires;
// Generate and encode the token
const tokenH = $security.sha256(hashableBase);
const token = Buffer.from(tokenH, "hex")
.toString("base64")
.replace(/\n/g, "") // Remove newlines
.replace(/\+/g, "-") // Replace + with -
.replace(/\//g, "_") // Replace / with _
.replace(/=/g, ""); // Remove =
// Generate the URL
const signedUrl = baseUrl + path + '?token=' + token + '&expires=' + expires;
return signedUrl;
}
console.log(`record: ${JSON.stringify(e.record)}`)
console.log(`collection: ${JSON.stringify(e.collection)}`)
console.log(`app: ${JSON.stringify(e.app)}`)
console.log(`fileField: ${JSON.stringify(e.fileField)}`)
console.log(`servedPath: ${JSON.stringify(e.servedPath)}`)
console.log(`servedName: ${JSON.stringify(e.servedName)}`)
// Our job here is to take the servedPath, and sign it using bunnycdn method
// Then serve a 302 redirect instead of serving the file proxied thru PB
const path = e.servedPath;
const expires = Math.round(Date.now() / 1000) + 3600;
const signedUrl = signUrl(securityKey, baseUrl, path, expires);
console.log(`signedUrl=${signedUrl}`);
// This redirect is a tricky thing. We do this to avoid proxying file requests via our pocketbase origin server.
// The idea is to reduce load.
// HOWEVER, this redirect slows down image loading because it now takes 2 requests per image.
e.redirect(302, signedUrl);
e.next()
})

View File

@ -53,10 +53,12 @@
</nav> </nav>
<%#
<div class="notification is-info" data-signals='{"showNotif": false}' data-show="$showNotif"> <div class="notification is-info" data-signals='{"showNotif": false}' data-show="$showNotif">
<button class="delete" data-on-click="$showNotif = false"></button> <button class="delete" data-on-click="$showNotif = false"></button>
<span data-text="$message"></span> <span data-text="$message"></span>
</div> </div>
%>
<%- slots.body || slot%> <%- slots.body || slot%>

View File

@ -7,6 +7,7 @@
Please note: Patron status updates may take up to one minute to synchronize. Please note: Patron status updates may take up to one minute to synchronize.
</div> </div>
<p><strong>Patreon ID:</strong> <%= auth.get('patreonId') %></p>
<p> <p>
<strong>Role:</strong> <strong>Role:</strong>
<% if (auth.get('patron')) { %> <% if (auth.get('patron')) { %>
@ -17,9 +18,8 @@
<% } %> <% } %>
</p> </p>
<p><strong>Patreon ID:</strong> <%= auth.get('patreonId') %></p>
<% if (auth.get('patron')) { %>
<div class="mt-5"> <div class="mt-5">
<h3 class="title is-3">Account Settings</h3> <h3 class="title is-3">Account Settings</h3>
@ -34,3 +34,4 @@
<input class="button" type="submit" value="Save" /> --> <input class="button" type="submit" value="Save" /> -->
</div> </div>
<% } %>

View File

@ -0,0 +1,37 @@
// +load.js
/** @type {import('pocketpages').PageDataLoaderFunc} */
module.exports = function (api) {
const { response } = api;
try {
// Find all users who have both a publicUsername and are patrons
const patronsRaw = $app.findRecordsByFilter(
'users',
'publicUsername != "" && patron = true',
'-created', // sort (optional)
50, // limit
0, // offset
null // params (none used)
);
// Map to plain JSON-safe objects
const patrons = patronsRaw.map((p) => ({
name: p.get('name'),
username: p.get('publicUsername'),
}));
console.log('Patrons:', JSON.stringify(patrons, null, 2));
return { patrons };
} catch (e) {
console.error('error!', e.message);
if (e.message.match(/no rows/)) {
return response.html(404, 'Patrons not found');
} else {
return response.html(500, 'Unknown internal error while fetching patrons');
}
}
};

View File

@ -1,64 +1,19 @@
<p></p> <p></p>
<div class="menu"> <div class="menu">
<p class="subtitle">Thank you to our wonderful patrons who keep this site running</p> <p class="subtitle">
Thank you to our wonderful patrons who keep this site running 💖
</p>
<% if (Array.isArray(data.patrons) && data.patrons.length > 0) { %>
<ul class="menu-list"> <ul class="menu-list">
<li>Tyrs</li> <% for (const patron of data.patrons) { %>
<li>StubbstheZombie</li> <li>
<li>Delques1843</li> <%= patron.name %>
<li>Cray Shay</li> </li>
<li>Jay black</li> <% } %>
<li>E T 4321</li>
<li>ProfessionalUwU</li>
<li>LzyAsn</li>
<li>RettichDerGeile</li>
<li>Vladislav Šlechta</li>
<li>FluffyPenguins527</li>
<li>Enderwolf</li>
<li>Kieren</li>
<li>Captain Highlighter</li>
<li>Jonas andreas Gulbrandsen-Efteland</li>
<li>Drefsab</li>
<li>Randernizer</li>
<li>sheldmaster</li>
<li>Joe Thelizard</li>
<li>IshikawaTXC</li>
<li>Alex</li>
<li>BioAct1ve</li>
<li>AverageYuriEnjoyer</li>
<li>Virted</li>
<li>MechiPlat</li>
<li>JustSleep</li>
<li>Cjyoubusta</li>
<li>Bravo The Chonk</li>
<li>LaoPao</li>
<li>Toffse</li>
<li>Rubén Ruiz</li>
<li>Brewhλwk</li>
<li>BusterMachine7</li>
<li>Lucas</li>
<li>Jogabbagabba</li>
<li>Tony</li>
<li>Mydus</li>
<li>Wubb wubb</li>
<li>François d'Aruna</li>
<li>Cheese And Crackers</li>
<li>Shift</li>
<li>Addable Gravy61</li>
<li>Heiko7761</li>
<li>DustyGreenTea</li>
<li>Lolithia</li>
<li>Aicome 2000</li>
<li>The Gameinator</li>
<li>Dice Loynes</li>
<li>MilkMoblin</li>
<li>Turnip Toss</li>
<li>C4425</li>
<li>TheSlimiestKing</li>
<li>R4z0rSh4rP</li>
<li>Lumbar247</li>
<li>Vilkki</li>
<li>Chiko</li>
<li>Djinn</li>
</ul> </ul>
<% } else { %>
<p class="has-text-grey-light">Patron names are private by default. Become the first supporter and <a href="/account#settings">opt-in</a> to have your name shown!</p>
<% } %>
</div> </div>

View File

@ -1,40 +1,22 @@
// +load.js /**
* @typedef {import('pocketbase').default} PocketBase
/** @type {import('pocketpages').PageDataLoaderFunc} */ * @typedef {import('../pb/pocketbase-types').TypedPocketBase} TypedPocketBase
* @typedef {import('pocketpages').PageDataLoaderFunc} PageDataLoaderFunc
*/
module.exports = function (api) { module.exports = function (api) {
const { request, response } = api; const { request, response, params, pb } = api;
try { try {
// Read query params with PocketPages const perPage = params.perPage || 25;
const query = request.url.query || {}; const page = params.page || 1;
const page = parseInt(query.page) || 1;
const limit = parseInt(query.limit) || 200;
const offset = (page - 1) * limit;
// Fetch all VODs, sorted by -streamDate const client = pb({ request });
const allVods = $app.findRecordsByFilter('vods', null, '-streamDate'); const vods = client.collection('vods').getList(page, perPage, {
expand: 'vtubers',
sort: '-streamDate'
})
// Slice according to pagination
const vods = allVods.slice(offset, offset + limit);
// Expand related vtubers return { vods };
$app.expandRecords(vods, ['vtubers'], null);
// Pagination metadata
const total = allVods.length;
const totalPages = Math.ceil(total / limit);
return {
vods,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
} catch (e) { } catch (e) {
console.error('Error fetching VODs:', e.message); console.error('Error fetching VODs:', e.message);

View File

@ -67,6 +67,13 @@
<p><b>Notes:</b></p> <p><b>Notes:</b></p>
<pre class="p-2"><%= data.vod?.get('notes') %></pre> <pre class="p-2"><%= data.vod?.get('notes') %></pre>
<% } %> <% } %>
<% if (data.vod?.get('thumbnail')) { %>
<p><b>Thumbnail:</b></p>
<figure class="image">
<img src="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('thumbnail') %>" />
</figure>
<% } %>
</div> </div>
<div class="mb-5"></div> <div class="mb-5"></div>

View File

@ -1,72 +1,3 @@
<h2 class="title is-2">VODs</h2> <h2 class="title is-2">VODs</h2>
<% if (Array.isArray(data.vods) && data.vods.length > 0) { %> <%- include('vod-list.ejs', data) %>
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>Stream Date</th>
<th>VTubers</th>
</tr>
</thead>
<tbody>
<% for (const vod of data.vods) { %>
<tr>
<td>
<a href="/vods/<%= vod?.id %>" class="is-small is-link">
<%= vod?.get ? vod.get('streamDate') : vod?.streamDate ?? 'Unknown date' %>
</a>
</td>
<td>
<% const vtubers = vod?.get ? vod.get('expand')?.vtubers ?? [] : vod?.vtubers ?? []; %>
<% if (vtubers.length > 0) { %>
<% for (let i = 0; i < vtubers.length; i++) { %>
<%= vtubers[i]?.get ? vtubers[i].get('displayName') : vtubers[i]?.displayName ?? 'Unknown' %>
<%= i < vtubers.length - 1 ? ', ' : '' %>
<% } %>
<% } else { %>
None
<% } %>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<% if (data.pagination && data.pagination.totalPages > 1) { %>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<% const currentPage = data.pagination.page; %>
<% const totalPages = data.pagination.totalPages; %>
<!-- Previous Page -->
<% if (data.pagination.hasPrev) { %>
<a class="pagination-previous" href="?page=<%= currentPage - 1 %>&limit=<%= data.pagination.limit %>">Previous</a>
<% } else { %>
<a class="pagination-previous" disabled>Previous</a>
<% } %>
<!-- Next Page -->
<% if (data.pagination.hasNext) { %>
<a class="pagination-next" href="?page=<%= currentPage + 1 %>&limit=<%= data.pagination.limit %>">Next</a>
<% } else { %>
<a class="pagination-next" disabled>Next</a>
<% } %>
<!-- Page Numbers -->
<ul class="pagination-list">
<% for (let i = 1; i <= totalPages; i++) { %>
<li>
<a class="pagination-link <%= i === currentPage ? 'is-current' : '' %>" href="?page=<%= i %>&limit=<%= data.pagination.limit %>">
<%= i %>
</a>
</li>
<% } %>
</ul>
</nav>
<% } %>
<% } else { %>
<p>No VODs available.</p>
<% } %>

View File

@ -1,25 +0,0 @@
// +load.js
/** @type {import('pocketpages').PageDataLoaderFunc} */
module.exports = function (api) {
const { params, response } = api;
try {
const vtuber = $app.findFirstRecordByData('vtubers', 'slug', params.slug);
$app.expandRecord(vtuber, ["vods"], null);
// console.log(JSON.stringify(vtuber))
return { vtuber };
} catch (e) {
console.error('error!', e.message);
if (e.message.match(/no rows/)) {
console.log('we are sending 404')
return response.html(404, 'VTuber not found')
} else {
console.log('we are sending error 500')
return response.html(500, 'Unknown internal error while fetching vtuber')
}
}
};

View File

@ -0,0 +1,40 @@
// +middleware.js
/** @type {import('pocketpages').PageDataLoaderFunc} */
module.exports = function (api) {
const { params, request, pb } = api;
const perPage = params.perPage || 25;
const page = params.page || 1;
const vtuber = $app.findFirstRecordByData('vtubers', 'slug', params.slug);
const client = pb({ request });
const vods = client.collection('vods').getList(page, perPage, {
expand: 'vtubers',
sort: '-streamDate',
filter: `vtubers.id ?= "${vtuber.id}"`
})
// await pb.collection("posts").getList(1, 30, {
// filter: "comments_via_post.message ?~ 'hello'"
// expand: "comments_via_post.user",
// })
// console.log(JSON.stringify(vods, null, 2));
// findRecordsByFilter(collectionModelOrIdentifier, filter, sort, limit, offset, ...params): core.Record[]
// $app.expandRecord(vtuber, ['vods_via_vtubers'], null);
// $app.expandRecord(vod, ["vtubers"], null);
// console.log(eerrs)
return { vods, vtuber };
};

View File

@ -1,3 +1 @@
<% if (data.vtuber) { %> <%- include('vtuber.ejs', data) %>
<%- include('vtuber.ejs', { vtuber: data.vtuber }) %>
<% } %>

View File

@ -0,0 +1,3 @@
<h2 class="title is-2"><%= data.vtuber.get('displayName') %> VODs</h2>
<%- include('vod-list.ejs', data) %>

View File

@ -0,0 +1,129 @@
<div id="vtuber" class="">
<div class="flex items-center justify-between">
<section class="section">
<div class="columns">
<div class="column">
<%# VTuber Name %>
<h2 class="title is-2">
<%= data.vtuber?.get?.('displayName') || 'Unknown VTuber' %>
</h2>
<%# VTuber Image %>
<figure class="image is-128x128">
<img src="/api/files/vtubers/<%= data.vtuber?.id %>/<%= data.vtuber?.get('image') %>?thumb=128x128" alt="<%= data.vtuber?.get?.('displayName') || 'VTuber' %>" />
</figure>
</div>
<div class="column">
<h3 class="title is-3">Theme Color</h3>
<% const themeColor = data.vtuber?.get('themeColor') || '#999999' %>
<div class="theme-color" style="
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
border: 1px solid #ccc;
background-color: <%= themeColor %>
" title="<%= themeColor %>"></div>
<p class="has-text-grey is-size-7 mb-5"><%= themeColor %></p>
</div>
</div>
</section>
<% const socials = {
chaturbate: data.vtuber?.get('chaturbate'),
twitter: data.vtuber?.get('twitter'),
patreon: data.vtuber?.get('patreon'),
twitch: data.vtuber?.get('twitch'),
tiktok: data.vtuber?.get('tiktok'),
onlyfans: data.vtuber?.get('onlyfans'),
youtube: data.vtuber?.get('youtube'),
linktree: data.vtuber?.get('linktree'),
carrd: data.vtuber?.get('carrd'),
fansly: data.vtuber?.get('fansly'),
pornhub: data.vtuber?.get('pornhub'),
discord: data.vtuber?.get('discord'),
reddit: data.vtuber?.get('reddit'),
throne: data.vtuber?.get('throne'),
instagram: data.vtuber?.get('instagram'),
facebook: data.vtuber?.get('facebook'),
merch: data.vtuber?.get('merch')
} %>
<% const definedSocials = Object.entries(socials).filter(([_, v]) => v) %>
<% if (definedSocials.length > 0) { %>
<section class="section">
<h3 class="title is-3">Socials</h3>
<div class="tags are-medium">
<% for (const [name, url] of definedSocials) { %>
<a href="<%= url %>" class="tag is-link is-light" target="_blank" rel="noopener">
<%= name.charAt(0).toUpperCase() + name.slice(1) %>
</a>
<% } %>
</div>
</section>
<% } %>
<section class="section">
<h3 class="title is-3">VODs</h3>
<%
const vods = data.vods.items
if (vods.length > 0) {
%>
<div class="columns is-multiline">
<% for (const vod of vods) { %>
<div class="column is-one-quarter-desktop is-half-tablet is-full-mobile">
<a href="/vods/<%= vod.id %>" class="box has-text-centered">
<!-- Thumbnail -->
<% if (vod.thumbnail) { %>
<figure class="image is-16by9 mb-2">
<img src="/api/files/vods/<%= vod.id %>/<%= vod.thumbnail %>?thumb=400x225" alt="VOD thumbnail for <%= vod.id %>" />
</figure>
<% } else { %>
<div class="has-background-grey-lighter py-6 mb-2">
<span class="has-text-grey">No thumbnail</span>
</div>
<% } %>
<!-- Title / ID -->
<p class="is-size-6 has-text-weight-semibold">
<%= vod.title || vod.id %>
</p>
<!-- Date -->
<% if (vod.streamDate) { %>
<p class="is-size-7 has-text-grey">
<%= new Date(vod.streamDate).toLocaleDateString() %>
</p>
<% } %>
</a>
</div>
<% } %>
</div>
<a href="<%= request.url.pathname.replace(/\/$/, '') %>/vods">See all <%= data.vtuber?.get?.('displayName') %> vods</a>
<% } else { %>
<p>No VODs available for this VTuber.</p>
<% } %>
</section>
</div>
</div>

View File

@ -0,0 +1,79 @@
<% if (Array.isArray(data.vods.items) && data.vods.items.length > 0) { %>
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>Stream Date</th>
<th>VTuber</th>
<th>Thumbnail</th>
</tr>
</thead>
<tbody>
<% for (const vod of data.vods.items) { %>
<tr>
<td>
<a href="/vods/<%= vod.id %>" class="is-small is-link">
<%= vod.streamDate ? new Date(vod.streamDate).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : 'Unknown date' %>
</a>
</td>
<td>
<% const vtubers = vod.expand?.vtubers || []; %>
<% if (vtubers.length) { %>
<% vtubers.forEach(function(v, i){ %>
<a href="/vt/<%= v.slug %>" class="is-small"><%= v.displayName %></a><%= (i === vtubers.length - 2 ? (vtubers.length > 2 ? ', and ' : ' and ') : (i === vtubers.length - 1 ? '' : ', ')) %>
<% }) %>
<% } else { %>
<span>Unknown</span>
<% } %>
</td>
<td style="width: 160px;">
<% if (vod.thumbnail) { %>
<figure class="image is-3by2">
<img src="/api/files/<%= vod.collectionId %>/<%= vod.id %>/<%= vod.thumbnail %>" alt="Thumbnail" style="width: 120px; border-radius: 8px;">
</figure>
<% } else { %>
<span>No thumbnail</span>
<% } %>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<% if (data.vods.totalPages > 1) { %>
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<% const currentPage = data.vods.page; %>
<% const totalPages = data.vods.totalPages; %>
<!-- Previous Page -->
<% if (currentPage > 1) { %>
<a class="pagination-previous" href="?page=<%= currentPage - 1 %>&perPage=<%= data.vods.perPage %>">Previous</a>
<% } else { %>
<a class="pagination-previous" disabled>Previous</a>
<% } %>
<!-- Next Page -->
<% if (currentPage < totalPages) { %>
<a class="pagination-next" href="?page=<%= currentPage + 1 %>&perPage=<%= data.vods.perPage %>">Next</a>
<% } else { %>
<a class="pagination-next" disabled>Next</a>
<% } %>
<!-- Page Numbers -->
<ul class="pagination-list">
<% for (let i = 1; i <= totalPages; i++) { %>
<li>
<a class="pagination-link <%= i === currentPage ? 'is-current' : '' %>" href="?page=<%= i %>&perPage=<%= data.vods.perPage %>">
<%= i %>
</a>
</li>
<% } %>
</ul>
</nav>
<% } %>
<% } else { %>
<p>No VODs available.</p>
<% } %>

View File

@ -1,62 +0,0 @@
<div id="vtuber" class="">
<div class="flex items-center justify-between">
<!-- VTuber Image -->
<figure class="image is-128x128">
<img src="/api/files/vtubers/<%= data.vtuber?.id %>/<%= data.vtuber?.image %>?thumb=128x128" alt="<%= data.vtuber?.get?.('displayName') || 'VTuber' %>" />
</figure>
<!-- VTuber Name -->
<span class="title is-6">
<%= data.vtuber?.displayName || data.vtuber?.get?.('displayName') || 'Unknown VTuber' %>
</span>
<section class="section">
<h3 class="title is-4 mb-4">VODs</h3>
<%
const vods = data.vtuber.get('expand')?.vods || [];
if (vods.length > 0) {
%>
<div class="columns is-multiline">
<% for (const vod of vods) { %>
<div class="column is-one-quarter-desktop is-half-tablet is-full-mobile">
<a href="/vods/<%= vod.get('id') %>" class="box has-text-centered">
<!-- Thumbnail -->
<% if (vod.get('thumbnail')) { %>
<figure class="image is-16by9 mb-2">
<img src="/api/files/vods/<%= vod.get('id') %>/<%= vod.get('thumbnail') %>?thumb=400x225" alt="VOD thumbnail for <%= vod.get('id') %>" />
</figure>
<% } else { %>
<div class="has-background-grey-lighter py-6 mb-2">
<span class="has-text-grey">No thumbnail</span>
</div>
<% } %>
<!-- Title / ID -->
<p class="is-size-6 has-text-weight-semibold">
<%= vod.get('title') || vod.get('id') %>
</p>
<!-- Date -->
<% if (vod.get('streamDate')) { %>
<p class="is-size-7 has-text-grey">
<%= new Date(vod.get('streamDate')).toLocaleDateString() %>
</p>
<% } %>
</a>
</div>
<% } %>
</div>
<% } else { %>
<p>No VODs available for this VTuber.</p>
<% } %>
</section>
</div>
</div>

View File

@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3872109612")
// remove field
collection.fields.removeById("relation3825607268")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3872109612")
// add field
collection.fields.addAt(3, new Field({
"cascadeDelete": false,
"collectionId": "pbc_144770472",
"hidden": false,
"id": "relation3825607268",
"maxSelect": 999,
"minSelect": 0,
"name": "vods",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View File

@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("text3277268710")
// add field
collection.fields.addAt(15, new Field({
"hidden": false,
"id": "file3277268710",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "thumbnail",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(10, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3277268710",
"max": 0,
"min": 0,
"name": "thumbnail",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// remove field
collection.fields.removeById("file3277268710")
return app.save(collection)
})

View File

@ -0,0 +1,49 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "_clone_oC8t",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}
],
"id": "pbc_3009055234",
"indexes": [],
"listRule": null,
"name": "vtuber_vods",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n vods.id,\n vods.streamDate\nFROM vods\nLEFT JOIN json_each(vods.vtubers) ON json_each.value = 'el_xox'",
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234");
return app.delete(collection);
})

View File

@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234")
// update collection data
unmarshal({
"viewQuery": "SELECT\n vods.id,\n vods.streamDate\nFROM vods\nLEFT JOIN json_each(vods.vtubers) ON json_each.value = 'udqmxs649ajf2mk'"
}, collection)
// remove field
collection.fields.removeById("_clone_oC8t")
// add field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "_clone_1BVl",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234")
// update collection data
unmarshal({
"viewQuery": "SELECT\n vods.id,\n vods.streamDate\nFROM vods\nLEFT JOIN json_each(vods.vtubers) ON json_each.value = 'el_xox'"
}, collection)
// add field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "_clone_oC8t",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}))
// remove field
collection.fields.removeById("_clone_1BVl")
return app.save(collection)
})

View File

@ -0,0 +1,71 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234")
// update collection data
unmarshal({
"viewQuery": "SELECT\n vods.id,\n vods.streamDate,\n vtubers.displayName\nFROM vods\nLEFT JOIN vtubers"
}, collection)
// remove field
collection.fields.removeById("_clone_1BVl")
// add field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "_clone_h4Aw",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}))
// add field
collection.fields.addAt(2, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_yNn2",
"max": 0,
"min": 0,
"name": "displayName",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234")
// update collection data
unmarshal({
"viewQuery": "SELECT\n vods.id,\n vods.streamDate\nFROM vods\nLEFT JOIN json_each(vods.vtubers) ON json_each.value = 'udqmxs649ajf2mk'"
}, collection)
// add field
collection.fields.addAt(1, new Field({
"hidden": false,
"id": "_clone_1BVl",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}))
// remove field
collection.fields.removeById("_clone_h4Aw")
// remove field
collection.fields.removeById("_clone_yNn2")
return app.save(collection)
})

View File

@ -0,0 +1,63 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3009055234");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3208210256",
"max": 0,
"min": 0,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "_clone_h4Aw",
"max": "",
"min": "",
"name": "streamDate",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "_clone_yNn2",
"max": 0,
"min": 0,
"name": "displayName",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}
],
"id": "pbc_3009055234",
"indexes": [],
"listRule": null,
"name": "vtuber_vods",
"system": false,
"type": "view",
"updateRule": null,
"viewQuery": "SELECT\n vods.id,\n vods.streamDate,\n vtubers.displayName\nFROM vods\nLEFT JOIN vtubers",
"viewRule": null
});
return app.save(collection);
})

View File

@ -0,0 +1,20 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"viewRule": null
}, collection)
return app.save(collection)
})

View File

@ -0,0 +1,20 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": null
}, collection)
return app.save(collection)
})

View File

@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
})

View File

@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})

View File

@ -0,0 +1,22 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3872109612")
// update collection data
unmarshal({
"listRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3872109612")
// update collection data
unmarshal({
"listRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})

View File

@ -0,0 +1,64 @@
/**
* Migration Script: Assign missing VOD vtubers to "projektmelody"
* ---------------------------------------------------------------
* This script connects to a PocketBase instance and updates all VOD records
* that do not have a `vtuber` set, assigning them to the vtuber with slug "projektmelody".
*
* Environment variables:
* PB_URL Base URL of your PocketBase instance (e.g. "http://127.0.0.1:8090")
* PB_ADMIN_EMAIL PocketBase admin email
* PB_ADMIN_PASS PocketBase admin password
*
* Usage:
* $ npx @dotenvx/dotenvx run -f .env.local -- node ./2025-11-05-fix-vod-vtuber.js
*/
import PocketBase from 'pocketbase';
const pb = new PocketBase(process.env.PB_URL || 'http://127.0.0.1:8090');
async function main() {
console.log('Authenticating with PocketBase...');
await pb
.collection("_superusers")
.authWithPassword(process.env.PB_USERNAME, process.env.PB_PASSWORD);
console.log('Fetching vtuber "projektmelody"...');
const projekt = await pb.collection('vtubers').getFirstListItem('slug="projektmelody"');
if (!projekt) {
throw new Error('Could not find vtuber with slug "projektelody"');
}
console.log('Fetching VODs...');
let page = 1;
const perPage = 50;
let updatedCount = 0;
while (true) {
const vods = await pb.collection('vods').getList(page, perPage);
if (vods.items.length === 0) break;
for (const vod of vods.items) {
// Only update if vtuber is missing or empty
if (!vod.vtubers || vod.vtubers.length === 0) {
await pb.collection('vods').update(vod.id, {
vtubers: [projekt.id],
});
console.log(`✅ Updated VOD ${vod.id} → projektmelody`);
updatedCount++;
}
}
if (vods.items.length < perPage) break;
page++;
}
console.log(`Done! Updated ${updatedCount} VODs that had no vtuber set.`);
pb.authStore.clear();
}
main().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@ -0,0 +1,21 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase(process.env.PB_URL || 'http://127.0.0.1:8090');
async function main() {
console.log('Authenticating with PocketBase...');
await pb
.collection("_superusers")
.authWithPassword(process.env.PB_USERNAME, process.env.PB_PASSWORD);
const name = 'mirakink'
const vt = await pb.collection('vtubers').getFirstListItem(`slug="${name}"`);
if (!vt) {
throw new Error(`Could not find vtuber with slug "${name}"`);
}
console.log(`image internals as follows`)
console.log(vt)
}
main()

View File

@ -0,0 +1,118 @@
import PocketBase from 'pocketbase';
import { readFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import spawn from 'nano-spawn';
import { tmpdir } from 'node:os';
import mime from 'mime';
const pb = new PocketBase(process.env.PB_URL || 'http://127.0.0.1:8090');
if (!process.env.PB_USERNAME) throw new Error('PB_USERNAME missing');
if (!process.env.PB_PASSWORD) throw new Error('PB_PASSWORD missing');
if (!process.env.B2_BUCKET_FROM) throw new Error('B2_BUCKET_FROM missing');
if (!process.env.V1_DATA_FILE) throw new Error('V1_DATA_FILE missing');
const vodsCollectionName = 'pbc_144770472';
// pbc_144770472 is the vods collection
// cv6m31vj98gmtsx is a sample vod id
async function main() {
console.log('Authenticating with PocketBase...');
await pb
.collection("_superusers")
.authWithPassword(process.env.PB_USERNAME, process.env.PB_PASSWORD);
// Use vod.streamDate to find the correct entry
// load v1 datafile
const v1VodDataRaw = readFileSync(process.env.V1_DATA_FILE, { encoding: 'utf-8' });
const v1VodData = JSON.parse(v1VodDataRaw);
// console.log('v1VodData sample', v1VodData[0])
// # BUILD A MANIFEST
// For each vod
// Get the expected pocketbase s3 key format, ex: `pbc_144770472/cv6m31vj98gmtsx/projektmelody-fansly-2025-09-17-thumb.png`
// Get the v1 thumbnail s3 key from the v1 export json
let manifest = [];
const vods = await pb.collection('vods').getFullList();
for (const vod of vods) {
// console.log('v2VodData sample', vod)
// console.log(`for this vod`, vod)
const v1Vod = v1VodData.find((vod1) => new Date(vod1.streamDate).getTime() === new Date(vod.streamDate).getTime());
console.log(`v1vod`, v1Vod)
if (!v1Vod) throw new Error(`failed to find matching v1 data vod for vod`);
// skip if there is no thumbnail in the v1 vod
if (!v1Vod.thumbnail) continue;
const inputS3Key = v1Vod.thumbnail.replace('content/', '');
const outputFile = join(tmpdir(), basename(inputS3Key));
const entry = {
vodId: vod.id,
inputS3Key,
outputFile,
};
manifest.push(entry);
}
const invalidVods = manifest.filter((m) => m.inputS3Key.includes('mp4'))
if (invalidVods.length > 0) {
console.error('invalid thumbnails found', invalidVods)
throw new Error('invalid. mp4s found in thumbnails');
}
console.log('manifest', manifest);
// # ACTUAL WORK
// Copy the file from B2_BUCKET_FROM to tmp file
// Update the pocketbase vod.thumbnail entry
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function retry(fn, retries = 6, delayMs = 500) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
console.warn(`Attempt ${attempt} failed: ${err}. Retrying in ${delayMs}ms...`);
if (attempt < retries) await sleep(delayMs);
}
}
throw lastError;
}
for (const [i, m] of manifest.entries()) {
var from = "b2://" + process.env.B2_BUCKET_FROM + "/" + m.inputS3Key;
var to = m.outputFile;
var vodId = m.vodId;
console.log("processing thumbnail " + i + ". " + from + " -> " + to + " (vodId=" + vodId + ")");
// Retry the download
await retry(function () {
return spawn('b2', ['file', 'download', from, to]);
});
// Retry the PocketBase upload
await retry(async function () {
var fileData = readFileSync(m.outputFile);
var mimetype = mime.getType(m.outputFile) || undefined;
var file = new File([fileData], basename(m.outputFile), { type: mimetype });
var form = new FormData();
form.set("thumbnail", file);
return pb.collection('vods').update(vodId, form);
});
}
console.log("All done.");
}
main()

View File

@ -143,7 +143,7 @@
"streamDate": "2025-10-29T08:16:00.000Z", "streamDate": "2025-10-29T08:16:00.000Z",
"notes": "Recorded using ffmpeg. Recorded via Chaturbate. \n\nIPFS CID bafybeicc3vkvyptu522gt4lgr65xadk2l6y7irwkqepsggzmuci64xxlvu\n\nMagnet link magnet:?xt=urn:btih:b4ce3c94cd90e620025e1710ad7f4e47cbba1e4e&dn=projektmelody-chaturbate-2025-10-29.mp4", "notes": "Recorded using ffmpeg. Recorded via Chaturbate. \n\nIPFS CID bafybeicc3vkvyptu522gt4lgr65xadk2l6y7irwkqepsggzmuci64xxlvu\n\nMagnet link magnet:?xt=urn:btih:b4ce3c94cd90e620025e1710ad7f4e47cbba1e4e&dn=projektmelody-chaturbate-2025-10-29.mp4",
"sourceVideo": "content/projektmelody-chaturbate-2025-10-29.mp4", "sourceVideo": "content/projektmelody-chaturbate-2025-10-29.mp4",
"thumbnail": "content/projektmelody-chaturbate-2025-10-29.mp4", "thumbnail": "content/js.png",
"ipfsCid": null, "ipfsCid": null,
"videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2025-10-29.mp4", "videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2025-10-29.mp4",
"muxAssetId": "ET8FXN00guyrpugug00dSMU00m4odQayzhVT3zAHpAMWo00", "muxAssetId": "ET8FXN00guyrpugug00dSMU00m4odQayzhVT3zAHpAMWo00",
@ -183,7 +183,7 @@
"streamDate": "2023-01-02T07:03:49.000Z", "streamDate": "2023-01-02T07:03:49.000Z",
"notes": "This VOD is not original HD quality. We are searching for a better version.", "notes": "This VOD is not original HD quality. We are searching for a better version.",
"sourceVideo": "content/projektmelody-chaturbate-2023-01-01.mp4", "sourceVideo": "content/projektmelody-chaturbate-2023-01-01.mp4",
"thumbnail": "content/projektmelody-chaturbate-2023-01-01.mp4", "thumbnail": "content/js.png",
"ipfsCid": "bafybeie3oynhomwdwvkdwgef2viii7kccpegnk6zvfprl4fyz6p52qwh6u", "ipfsCid": "bafybeie3oynhomwdwvkdwgef2viii7kccpegnk6zvfprl4fyz6p52qwh6u",
"videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2023-01-01.mp4", "videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2023-01-01.mp4",
"muxAssetId": null, "muxAssetId": null,

View File

@ -0,0 +1,73 @@
var queryString = require("querystring");
var crypto = require("crypto");
function addCountries(url, a, b) {
var tempUrl = url;
if (a != null) {
var tempUrlOne = new URL(tempUrl);
tempUrl += ((tempUrlOne.search == "") ? "?" : "&") + "token_countries=" + a;
}
if (b != null) {
var tempUrlTwo = new URL(tempUrl);
tempUrl += ((tempUrlTwo.search == "") ? "?" : "&") + "token_countries_blocked=" + b;
}
return tempUrl;
}
function signUrl(url, securityKey, expirationTime = 103600, userIp, isDirectory = false, pathAllowed, countriesAllowed, countriesBlocked) {
/*
url: CDN URL w/o the trailing '/' - exp. http://test.b-cdn.net/file.png
securityKey: Security token found in your pull zone
expirationTime: Authentication validity (default. 86400 sec/24 hrs)
userIp: Optional parameter if you have the User IP feature enabled
isDirectory: Optional parameter - "true" returns a URL separated by forward slashes (exp. (domain)/bcdn_token=...)
pathAllowed: Directory to authenticate (exp. /path/to/images)
countriesAllowed: List of countries allowed (exp. CA, US, TH)
countriesBlocked: List of countries blocked (exp. CA, US, TH)
*/
var parameterData = "", parameterDataUrl = "", signaturePath = "", hashableBase = "", token = "";
// var expires = Math.floor(new Date() / 1000) + expirationTime;
const expires = expirationTime;
var url = addCountries(url, countriesAllowed, countriesBlocked);
var parsedUrl = new URL(url);
var parameters = (new URL(url)).searchParams;
if (pathAllowed != "") {
signaturePath = pathAllowed;
parameters.set("token_path", signaturePath);
} else {
signaturePath = decodeURIComponent(parsedUrl.pathname);
}
parameters.sort();
if (Array.from(parameters).length > 0) {
parameters.forEach(function (value, key) {
if (value == "") {
return;
}
if (parameterData.length > 0) {
parameterData += "&";
}
parameterData += key + "=" + value;
parameterDataUrl += "&" + key + "=" + queryString.escape(value);
});
}
hashableBase = securityKey + signaturePath + expires + ((userIp != null) ? userIp : "") + parameterData;
console.log(`hashableBase=${hashableBase}`)
token = Buffer.from(crypto.createHash("sha256").update(hashableBase).digest()).toString("base64");
token = token.replace(/\n/g, "").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
if (isDirectory) {
return parsedUrl.protocol + "//" + parsedUrl.host + "/bcdn_token=" + token + parameterDataUrl + "&expires=" + expires + parsedUrl.pathname;
} else {
return parsedUrl.protocol + "//" + parsedUrl.host + parsedUrl.pathname + "?token=" + token + parameterDataUrl + "&expires=" + expires;
}
}
const securityKey = 'd5814175-cc56-4098-ae63-1096301fb3c1';
const sampleUrl = 'https://fppbdev.b-cdn.net/pbc_3872109612/z0bpy5cwxi1uksv/g4ot5_omb_qaei_o7_y_tuzdlcn1se.jpeg';
// const expires = Math.round(Date.now() / 1000) + 3600;
const expires = 3524904301;
const signedUrl = signUrl(sampleUrl, securityKey, expires, null, false, '');
console.log(`signedUrl=${signedUrl}`);
module.exports = { signUrl };