mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Deleting a video removed the Nostr note but left the actual file (plus its thumbnail and .torrent) publicly downloadable at its R2/S3 URL forever — a privacy gap and ongoing storage cost. Now the delete flow also removes the backing objects, best-effort. - collectVideoStorageKeys() (storagePointer.js): pure helper that derives the object keys (video + sibling .torrent + thumbnail) from a note, ONLY for URLs under the owner's bucket base — external/3rd-party URLs are never returned. - R2Service.deleteVideoStorage(): resolves the owner's connection, and deletes those keys. Skips cleanly when storage is locked / no creds / no owned objects, and never throws (failures are recorded, not propagated) so storage cleanup can't block note deletion. R2Service class now exported for testing. - app.handleDeleteModalConfirm: calls it after handleFullDeleteVideo succeeds. Tests: tests/storage-key-collection.test.mjs (4), tests/services/r2-delete-video-storage.test.mjs (4). Full suite green. Note: superseded versions orphaned by past edits aren't retroactively cleaned; edit-time cleanup (next) prevents new orphans going forward. Co-Authored-By: Claude Opus 4.8 <[email protected]>
220 lines
5.7 KiB
JavaScript
220 lines
5.7 KiB
JavaScript
const INFO_JSON_SUFFIX = ".info.json";
|
|
|
|
function safeTrim(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
export function normalizeStoragePointer(value) {
|
|
return safeTrim(value);
|
|
}
|
|
|
|
function parseStoragePointer(value) {
|
|
const normalized = normalizeStoragePointer(value);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const separatorIndex = normalized.indexOf(":");
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const provider = normalized.slice(0, separatorIndex).trim().toLowerCase();
|
|
const prefix = normalized.slice(separatorIndex + 1).trim();
|
|
if (!provider || !prefix) {
|
|
return null;
|
|
}
|
|
|
|
return { provider, prefix };
|
|
}
|
|
|
|
export function buildStoragePointerValue({ provider, prefix } = {}) {
|
|
const normalizedProvider = safeTrim(provider).toLowerCase();
|
|
const normalizedPrefix = safeTrim(prefix);
|
|
if (!normalizedProvider || !normalizedPrefix) {
|
|
return "";
|
|
}
|
|
|
|
return `${normalizedProvider}:${normalizedPrefix}`;
|
|
}
|
|
|
|
function deriveStoragePrefixFromUrl(url) {
|
|
const trimmed = safeTrim(url);
|
|
if (!trimmed || !/^https?:\/\//i.test(trimmed)) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
const path = parsed.pathname || "";
|
|
if (!path || path === "/") {
|
|
return "";
|
|
}
|
|
const normalizedPath = path.replace(/\/+$/, "");
|
|
const withoutExtension = normalizedPath.replace(/\.[^/.]+$/, "");
|
|
const cleanedPath = withoutExtension.startsWith("/")
|
|
? withoutExtension
|
|
: `/${withoutExtension}`;
|
|
return `${parsed.origin}${cleanedPath}`;
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function deriveStoragePointerFromUrl(url, provider = "url") {
|
|
const prefix = deriveStoragePrefixFromUrl(url);
|
|
return buildStoragePointerValue({ provider, prefix });
|
|
}
|
|
|
|
export function buildStoragePrefixFromKey({ publicBaseUrl, key } = {}) {
|
|
const base = safeTrim(publicBaseUrl).replace(/\/+$/, "");
|
|
const normalizedKey = safeTrim(key).replace(/^\//, "");
|
|
if (!base || !normalizedKey) {
|
|
return "";
|
|
}
|
|
const baseKey = normalizedKey.replace(/\.[^/.]+$/, "");
|
|
if (!baseKey) {
|
|
return "";
|
|
}
|
|
return `${base}/${baseKey}`;
|
|
}
|
|
|
|
export function getStoragePointerFromTags(tags = []) {
|
|
if (!Array.isArray(tags)) {
|
|
return "";
|
|
}
|
|
const tag = tags.find(
|
|
(entry) =>
|
|
Array.isArray(entry) &&
|
|
entry[0] === "s" &&
|
|
typeof entry[1] === "string"
|
|
);
|
|
return normalizeStoragePointer(tag?.[1]);
|
|
}
|
|
|
|
function normalizeInfoJsonBase(base) {
|
|
const trimmed = safeTrim(base);
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
if (trimmed.toLowerCase().endsWith(INFO_JSON_SUFFIX)) {
|
|
return trimmed;
|
|
}
|
|
return `${trimmed.replace(/\/+$/, "")}${INFO_JSON_SUFFIX}`;
|
|
}
|
|
|
|
export function resolveInfoJsonUrl({ storagePointer, url } = {}) {
|
|
const parsed = parseStoragePointer(storagePointer);
|
|
if (!parsed) {
|
|
return "";
|
|
}
|
|
|
|
const prefix = parsed.prefix;
|
|
if (/^https?:\/\//i.test(prefix)) {
|
|
return normalizeInfoJsonBase(prefix);
|
|
}
|
|
|
|
const fallbackPrefix = deriveStoragePrefixFromUrl(url);
|
|
if (fallbackPrefix) {
|
|
return normalizeInfoJsonBase(fallbackPrefix);
|
|
}
|
|
|
|
return normalizeInfoJsonBase(prefix);
|
|
}
|
|
|
|
export function resolveStoragePointerValue({
|
|
storagePointer,
|
|
url,
|
|
infoHash,
|
|
fallbackId,
|
|
provider,
|
|
} = {}) {
|
|
const normalized = normalizeStoragePointer(storagePointer);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
|
|
const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url");
|
|
if (derivedFromUrl) {
|
|
return derivedFromUrl;
|
|
}
|
|
|
|
if (typeof infoHash === "string" && infoHash.trim()) {
|
|
return buildStoragePointerValue({
|
|
provider: "btih",
|
|
prefix: infoHash.trim().toLowerCase(),
|
|
});
|
|
}
|
|
|
|
if (typeof fallbackId === "string" && fallbackId.trim()) {
|
|
return buildStoragePointerValue({
|
|
provider: "nostr",
|
|
prefix: fallbackId.trim(),
|
|
});
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Given one or more parsed video notes and the owner's bucket public base URL,
|
|
* returns the set of object KEYS backing them — the video object, its sibling
|
|
* `.torrent`, and the thumbnail — but ONLY for URLs that live under that base
|
|
* URL. URLs pointing at other hosts/buckets (e.g. external links) are ignored,
|
|
* so callers can never delete an object they don't own. Pure + deterministic.
|
|
*
|
|
* @param {object} params
|
|
* @param {Array|object} params.videos - parsed video note(s) (need url/thumbnail)
|
|
* @param {string} params.publicBaseUrl - the bucket's public base URL
|
|
* @returns {string[]} unique object keys (decoded, no leading slash)
|
|
*/
|
|
export function collectVideoStorageKeys({ videos, publicBaseUrl } = {}) {
|
|
const base = safeTrim(publicBaseUrl).replace(/\/+$/, "");
|
|
if (!base) {
|
|
return [];
|
|
}
|
|
const prefix = `${base}/`;
|
|
|
|
const keyFromUrl = (value) => {
|
|
const url = safeTrim(value);
|
|
if (!url || !url.startsWith(prefix)) {
|
|
return "";
|
|
}
|
|
let rel = url.slice(prefix.length).split(/[?#]/)[0];
|
|
if (!rel) {
|
|
return "";
|
|
}
|
|
try {
|
|
rel = rel
|
|
.split("/")
|
|
.map((segment) => decodeURIComponent(segment))
|
|
.join("/");
|
|
} catch (error) {
|
|
// Keep the raw relative path if it isn't valid percent-encoding.
|
|
}
|
|
return rel.replace(/^\/+/, "");
|
|
};
|
|
|
|
const list = Array.isArray(videos) ? videos : [videos];
|
|
const keys = new Set();
|
|
for (const video of list) {
|
|
if (!video || typeof video !== "object") {
|
|
continue;
|
|
}
|
|
const videoKey = keyFromUrl(video.url);
|
|
if (videoKey) {
|
|
keys.add(videoKey);
|
|
// The sibling torrent is uploaded as "<key-without-extension>.torrent".
|
|
const torrentKey = `${videoKey.replace(/\.[^/.]+$/, "")}.torrent`;
|
|
if (torrentKey && torrentKey !== videoKey) {
|
|
keys.add(torrentKey);
|
|
}
|
|
}
|
|
const thumbnailKey = keyFromUrl(video.thumbnail);
|
|
if (thumbnailKey) {
|
|
keys.add(thumbnailKey);
|
|
}
|
|
}
|
|
return [...keys];
|
|
}
|