diff --git a/services/pocketbase/README.md b/services/pocketbase/README.md index 248a83a5..1967f684 100644 --- a/services/pocketbase/README.md +++ b/services/pocketbase/README.md @@ -92,3 +92,15 @@ https://pocketbuilds.com/ how the pros do it 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 \ No newline at end of file diff --git a/services/pocketbase/package-lock.json b/services/pocketbase/package-lock.json index fbe56f47..cd8f5b6c 100644 --- a/services/pocketbase/package-lock.json +++ b/services/pocketbase/package-lock.json @@ -1,15 +1,17 @@ { "name": "futureporn", - "version": "0.0.3", + "version": "3.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "futureporn", - "version": "0.0.3", + "version": "3.0.3", "license": "Unlicense", "dependencies": { "jsonwebtoken": "^9.0.2", + "mime": "^4.1.0", + "nano-spawn": "^2.0.0", "pg": "^8.16.3", "pocketpages": ">=0.22.3", "pocketpages-plugin-auth": "^0.2.2", @@ -1771,6 +1773,21 @@ "dev": true, "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": { "version": "5.0.1", "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_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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", diff --git a/services/pocketbase/package.json b/services/pocketbase/package.json index 4a467d06..45d80348 100644 --- a/services/pocketbase/package.json +++ b/services/pocketbase/package.json @@ -1,6 +1,6 @@ { "name": "futureporn", - "version": "3.0.3", + "version": "3.1.0", "private": true, "description": "Dedication to the preservation of lewdtuber history", "license": "Unlicense", @@ -9,6 +9,8 @@ }, "dependencies": { "jsonwebtoken": "^9.0.2", + "mime": "^4.1.0", + "nano-spawn": "^2.0.0", "pg": "^8.16.3", "pocketpages": ">=0.22.3", "pocketpages-plugin-auth": "^0.2.2", diff --git a/services/pocketbase/pb_hooks/cdn.pb.js b/services/pocketbase/pb_hooks/cdn.pb.js new file mode 100644 index 00000000..3b3e0f3d --- /dev/null +++ b/services/pocketbase/pb_hooks/cdn.pb.js @@ -0,0 +1,107 @@ +/// + + + +/** + * 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() +}) \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/+layout.ejs b/services/pocketbase/pb_hooks/pages/(site)/+layout.ejs index 83cdbe64..1d9b6e6c 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/+layout.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/+layout.ejs @@ -53,10 +53,12 @@ + <%#
+ %> <%- slots.body || slot%> diff --git a/services/pocketbase/pb_hooks/pages/(site)/account/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/account/index.ejs index 76581c37..01f4028f 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/account/index.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/account/index.ejs @@ -7,6 +7,7 @@ Please note: Patron status updates may take up to one minute to synchronize. +

Patreon ID: <%= auth.get('patreonId') %>

Role: <% if (auth.get('patron')) { %> @@ -17,9 +18,8 @@ <% } %>

-

Patreon ID: <%= auth.get('patreonId') %>

- +<% if (auth.get('patron')) { %>

Account Settings

@@ -33,4 +33,5 @@ -
\ No newline at end of file + +<% } %> \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/patrons/+load.js b/services/pocketbase/pb_hooks/pages/(site)/patrons/+load.js new file mode 100644 index 00000000..94f5450c --- /dev/null +++ b/services/pocketbase/pb_hooks/pages/(site)/patrons/+load.js @@ -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'); + } + } +}; diff --git a/services/pocketbase/pb_hooks/pages/(site)/patrons/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/patrons/index.ejs index f289944d..23a643a1 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/patrons/index.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/patrons/index.ejs @@ -1,64 +1,19 @@

\ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/vods/+load.js b/services/pocketbase/pb_hooks/pages/(site)/vods/+load.js index 61079c93..5d242476 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/vods/+load.js +++ b/services/pocketbase/pb_hooks/pages/(site)/vods/+load.js @@ -1,40 +1,22 @@ -// +load.js - -/** @type {import('pocketpages').PageDataLoaderFunc} */ +/** + * @typedef {import('pocketbase').default} PocketBase + * @typedef {import('../pb/pocketbase-types').TypedPocketBase} TypedPocketBase + * @typedef {import('pocketpages').PageDataLoaderFunc} PageDataLoaderFunc + */ module.exports = function (api) { - const { request, response } = api; - + const { request, response, params, pb } = api; try { - // Read query params with PocketPages - const query = request.url.query || {}; - const page = parseInt(query.page) || 1; - const limit = parseInt(query.limit) || 200; - const offset = (page - 1) * limit; + const perPage = params.perPage || 25; + const page = params.page || 1; - // Fetch all VODs, sorted by -streamDate - const allVods = $app.findRecordsByFilter('vods', null, '-streamDate'); + const client = pb({ request }); + 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 - $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, - }, - }; + return { vods }; } catch (e) { console.error('Error fetching VODs:', e.message); diff --git a/services/pocketbase/pb_hooks/pages/(site)/vods/[id]/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/vods/[id]/index.ejs index 0036fdc1..73341fa4 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/vods/[id]/index.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/vods/[id]/index.ejs @@ -67,6 +67,13 @@

Notes:

<%= data.vod?.get('notes') %>
<% } %> + + <% if (data.vod?.get('thumbnail')) { %> +

Thumbnail:

+
+ +
+ <% } %>
\ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/vods/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/vods/index.ejs index 69d926b9..e60d9fde 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/vods/index.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/vods/index.ejs @@ -1,72 +1,3 @@

VODs

-<% if (Array.isArray(data.vods) && data.vods.length > 0) { %> -
- - - - - - - - - <% for (const vod of data.vods) { %> - - - - - <% } %> - -
Stream DateVTubers
- - <%= vod?.get ? vod.get('streamDate') : vod?.streamDate ?? 'Unknown date' %> - - - <% 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 - <% } %> -
-
- - -<% if (data.pagination && data.pagination.totalPages > 1) { %> - -<% } %> - -<% } else { %> -

No VODs available.

-<% } %> \ No newline at end of file +<%- include('vod-list.ejs', data) %> \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+load.js b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+load.js deleted file mode 100644 index 3194a602..00000000 --- a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+load.js +++ /dev/null @@ -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') - - } - } -}; - diff --git a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+middleware.js b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+middleware.js new file mode 100644 index 00000000..8e5f3c24 --- /dev/null +++ b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/+middleware.js @@ -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 }; + + +}; + diff --git a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/index.ejs index 528f17d0..d31545f8 100644 --- a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/index.ejs +++ b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/index.ejs @@ -1,3 +1 @@ -<% if (data.vtuber) { %> -<%- include('vtuber.ejs', { vtuber: data.vtuber }) %> -<% } %> \ No newline at end of file +<%- include('vtuber.ejs', data) %> \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/vods/index.ejs b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/vods/index.ejs new file mode 100644 index 00000000..05f762aa --- /dev/null +++ b/services/pocketbase/pb_hooks/pages/(site)/vt/[slug]/vods/index.ejs @@ -0,0 +1,3 @@ +

<%= data.vtuber.get('displayName') %> VODs

+ +<%- include('vod-list.ejs', data) %> \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/(site)/vt/_private/vtuber.ejs b/services/pocketbase/pb_hooks/pages/(site)/vt/_private/vtuber.ejs new file mode 100644 index 00000000..b5356d60 --- /dev/null +++ b/services/pocketbase/pb_hooks/pages/(site)/vt/_private/vtuber.ejs @@ -0,0 +1,129 @@ +
+
+ +
+ +
+
+ <%# VTuber Name %> +

+ <%= data.vtuber?.get?.('displayName') || 'Unknown VTuber' %> +

+ + + <%# VTuber Image %> +
+ <%= data.vtuber?.get?.('displayName') || 'VTuber' %> +
+
+
+ + +

Theme Color

+ <% const themeColor = data.vtuber?.get('themeColor') || '#999999' %> + +
+ +

<%= themeColor %>

+
+ +
+ + + +
+ + + <% 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) { %> +
+

Socials

+
+ <% for (const [name, url] of definedSocials) { %> + + <%= name.charAt(0).toUpperCase() + name.slice(1) %> + + <% } %> +
+
+ <% } %> + + +
+ +

VODs

+ + + <% + const vods = data.vods.items + if (vods.length > 0) { + %> + + See all <%= data.vtuber?.get?.('displayName') %> vods + <% } else { %> +

No VODs available for this VTuber.

+ <% } %> + + +
+ + +
+
\ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/_private/vod-list.ejs b/services/pocketbase/pb_hooks/pages/_private/vod-list.ejs new file mode 100644 index 00000000..0944fbf6 --- /dev/null +++ b/services/pocketbase/pb_hooks/pages/_private/vod-list.ejs @@ -0,0 +1,79 @@ +<% if (Array.isArray(data.vods.items) && data.vods.items.length > 0) { %> +
+ + + + + + + + + + <% for (const vod of data.vods.items) { %> + + + + + + <% } %> + +
Stream DateVTuberThumbnail
+ + <%= vod.streamDate ? new Date(vod.streamDate).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : 'Unknown date' %> + + + <% const vtubers = vod.expand?.vtubers || []; %> + <% if (vtubers.length) { %> + <% vtubers.forEach(function(v, i){ %> + <%= v.displayName %><%= (i === vtubers.length - 2 ? (vtubers.length > 2 ? ', and ' : ' and ') : (i === vtubers.length - 1 ? '' : ', ')) %> + <% }) %> + <% } else { %> + Unknown + <% } %> + + <% if (vod.thumbnail) { %> +
+ Thumbnail +
+ <% } else { %> + No thumbnail + <% } %> +
+
+ + +<% if (data.vods.totalPages > 1) { %> + +<% } %> + +<% } else { %> +

No VODs available.

+<% } %> \ No newline at end of file diff --git a/services/pocketbase/pb_hooks/pages/_private/vtuber.ejs b/services/pocketbase/pb_hooks/pages/_private/vtuber.ejs deleted file mode 100644 index 76a9ec50..00000000 --- a/services/pocketbase/pb_hooks/pages/_private/vtuber.ejs +++ /dev/null @@ -1,62 +0,0 @@ -
-
- - -
- <%= data.vtuber?.get?.('displayName') || 'VTuber' %> -
- - - - <%= data.vtuber?.displayName || data.vtuber?.get?.('displayName') || 'Unknown VTuber' %> - - - - - -
-

VODs

- - <% - const vods = data.vtuber.get('expand')?.vods || []; - if (vods.length > 0) { - %> - - <% } else { %> -

No VODs available for this VTuber.

- <% } %> -
- - -
-
\ No newline at end of file diff --git a/services/pocketbase/pb_migrations/1762408434_updated_vtubers.js b/services/pocketbase/pb_migrations/1762408434_updated_vtubers.js new file mode 100644 index 00000000..11df968c --- /dev/null +++ b/services/pocketbase/pb_migrations/1762408434_updated_vtubers.js @@ -0,0 +1,28 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762500151_updated_vods.js b/services/pocketbase/pb_migrations/1762500151_updated_vods.js new file mode 100644 index 00000000..7db884eb --- /dev/null +++ b/services/pocketbase/pb_migrations/1762500151_updated_vods.js @@ -0,0 +1,48 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762530625_created_vtuber_vods.js b/services/pocketbase/pb_migrations/1762530625_created_vtuber_vods.js new file mode 100644 index 00000000..4600a5c5 --- /dev/null +++ b/services/pocketbase/pb_migrations/1762530625_created_vtuber_vods.js @@ -0,0 +1,49 @@ +/// +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); +}) diff --git a/services/pocketbase/pb_migrations/1762530642_updated_vtuber_vods.js b/services/pocketbase/pb_migrations/1762530642_updated_vtuber_vods.js new file mode 100644 index 00000000..4254d84e --- /dev/null +++ b/services/pocketbase/pb_migrations/1762530642_updated_vtuber_vods.js @@ -0,0 +1,52 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762530828_updated_vtuber_vods.js b/services/pocketbase/pb_migrations/1762530828_updated_vtuber_vods.js new file mode 100644 index 00000000..4be00fd4 --- /dev/null +++ b/services/pocketbase/pb_migrations/1762530828_updated_vtuber_vods.js @@ -0,0 +1,71 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762531591_deleted_vtuber_vods.js b/services/pocketbase/pb_migrations/1762531591_deleted_vtuber_vods.js new file mode 100644 index 00000000..5c43d9a4 --- /dev/null +++ b/services/pocketbase/pb_migrations/1762531591_deleted_vtuber_vods.js @@ -0,0 +1,63 @@ +/// +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); +}) diff --git a/services/pocketbase/pb_migrations/1762622017_updated_vods.js b/services/pocketbase/pb_migrations/1762622017_updated_vods.js new file mode 100644 index 00000000..92fb8ef1 --- /dev/null +++ b/services/pocketbase/pb_migrations/1762622017_updated_vods.js @@ -0,0 +1,20 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762622042_updated_vods.js b/services/pocketbase/pb_migrations/1762622042_updated_vods.js new file mode 100644 index 00000000..8cea92be --- /dev/null +++ b/services/pocketbase/pb_migrations/1762622042_updated_vods.js @@ -0,0 +1,20 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762622148_updated_vods.js b/services/pocketbase/pb_migrations/1762622148_updated_vods.js new file mode 100644 index 00000000..a6b129b1 --- /dev/null +++ b/services/pocketbase/pb_migrations/1762622148_updated_vods.js @@ -0,0 +1,22 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762622255_updated_vods.js b/services/pocketbase/pb_migrations/1762622255_updated_vods.js new file mode 100644 index 00000000..b61b310d --- /dev/null +++ b/services/pocketbase/pb_migrations/1762622255_updated_vods.js @@ -0,0 +1,22 @@ +/// +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) +}) diff --git a/services/pocketbase/pb_migrations/1762622465_updated_vtubers.js b/services/pocketbase/pb_migrations/1762622465_updated_vtubers.js new file mode 100644 index 00000000..595db17f --- /dev/null +++ b/services/pocketbase/pb_migrations/1762622465_updated_vtubers.js @@ -0,0 +1,22 @@ +/// +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) +}) diff --git a/services/pocketbase/utils/data_migrations/2025-11-05-populate-vtuber-field.js b/services/pocketbase/utils/data_migrations/2025-11-05-populate-vtuber-field.js new file mode 100644 index 00000000..b160ac77 --- /dev/null +++ b/services/pocketbase/utils/data_migrations/2025-11-05-populate-vtuber-field.js @@ -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); +}); diff --git a/services/pocketbase/utils/data_migrations/2025-11-05-see-image-internal.js b/services/pocketbase/utils/data_migrations/2025-11-05-see-image-internal.js new file mode 100644 index 00000000..98244926 --- /dev/null +++ b/services/pocketbase/utils/data_migrations/2025-11-05-see-image-internal.js @@ -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() \ No newline at end of file diff --git a/services/pocketbase/utils/data_migrations/2025-11-07-import-thumbnails.js b/services/pocketbase/utils/data_migrations/2025-11-07-import-thumbnails.js new file mode 100644 index 00000000..2f7e6ff4 --- /dev/null +++ b/services/pocketbase/utils/data_migrations/2025-11-07-import-thumbnails.js @@ -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() \ No newline at end of file diff --git a/services/pocketbase/utils/data_migrations/vods.json b/services/pocketbase/utils/data_migrations/vods.json index 87fcdb83..196bd68e 100644 --- a/services/pocketbase/utils/data_migrations/vods.json +++ b/services/pocketbase/utils/data_migrations/vods.json @@ -143,7 +143,7 @@ "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", "sourceVideo": "content/projektmelody-chaturbate-2025-10-29.mp4", - "thumbnail": "content/projektmelody-chaturbate-2025-10-29.mp4", + "thumbnail": "content/js.png", "ipfsCid": null, "videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2025-10-29.mp4", "muxAssetId": "ET8FXN00guyrpugug00dSMU00m4odQayzhVT3zAHpAMWo00", @@ -183,7 +183,7 @@ "streamDate": "2023-01-02T07:03:49.000Z", "notes": "This VOD is not original HD quality. We are searching for a better version.", "sourceVideo": "content/projektmelody-chaturbate-2023-01-01.mp4", - "thumbnail": "content/projektmelody-chaturbate-2023-01-01.mp4", + "thumbnail": "content/js.png", "ipfsCid": "bafybeie3oynhomwdwvkdwgef2viii7kccpegnk6zvfprl4fyz6p52qwh6u", "videoSrcB2": "https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2023-01-01.mp4", "muxAssetId": null, diff --git a/services/pocketbase/utils/sign-url.js b/services/pocketbase/utils/sign-url.js new file mode 100644 index 00000000..e574a7d2 --- /dev/null +++ b/services/pocketbase/utils/sign-url.js @@ -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 }; \ No newline at end of file