From 700056b5fc137a3cedbcbc1de12e2221e446688b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:21:32 -0400 Subject: [PATCH] Extract video note payload normalization --- js/app.js | 361 +--------------------------- js/services/r2Service.js | 57 +++-- js/services/videoNotePayload.js | 379 ++++++++++++++++++++++++++++++ js/ui/components/UploadModal.js | 56 +++-- tests/video-note-payload.test.mjs | 190 +++++++++++++++ 5 files changed, 651 insertions(+), 392 deletions(-) create mode 100644 js/services/videoNotePayload.js create mode 100644 tests/video-note-payload.test.mjs diff --git a/js/app.js b/js/app.js index b78d72f3..bf75fb2f 100644 --- a/js/app.js +++ b/js/app.js @@ -13,7 +13,6 @@ import { } from "./config.js"; import { accessControl } from "./accessControl.js"; import { safeDecodeMagnet } from "./magnetUtils.js"; -import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; import { URL_FIRST_ENABLED, @@ -46,6 +45,10 @@ import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; import AuthService from "./services/authService.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, +} from "./services/videoNotePayload.js"; import getAuthProvider, { providers as authProviders, } from "./services/authProviders/index.js"; @@ -4562,362 +4565,16 @@ class Application { return false; } - const normalizeString = (value) => { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number") { - return Number.isFinite(value) ? String(value).trim() : ""; - } - return String(value ?? "").trim(); - }; - - const parseNumberOrNull = (value) => { - if (value === null || value === undefined || value === "") { - return null; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - }; - - const parseUnixSeconds = (value) => { - if (value === null || value === undefined || value === "") { - return null; - } - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = value > 1e12 ? value / 1000 : value; - return Math.floor(normalized); - } - const trimmed = normalizeString(value); - if (!trimmed) { - return null; - } - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - const normalized = numeric > 1e12 ? numeric / 1000 : numeric; - return Math.floor(normalized); - } - const timestamp = Date.parse(trimmed); - if (!Number.isNaN(timestamp)) { - return Math.floor(timestamp / 1000); - } - return null; - }; - - const normalizeStringArray = (input) => { - if (!Array.isArray(input)) { - return []; - } - return input - .map((entry) => normalizeString(entry)) - .filter((entry) => Boolean(entry)); - }; - - const normalizeBooleanFlag = (value, defaultValue = false) => { - if (value === true) { - return true; - } - if (value === false) { - return false; - } - return Boolean(defaultValue); - }; - - const normalizeImetaVariant = (variant) => { - if (!variant || typeof variant !== "object") { - return null; - } - const normalized = {}; - const m = normalizeString(variant.m); - if (m) normalized.m = m; - const dim = normalizeString(variant.dim); - if (dim) normalized.dim = dim; - const url = normalizeString(variant.url); - if (url) normalized.url = url; - const x = normalizeString(variant.x); - if (x) normalized.x = x; - const image = normalizeStringArray(variant.image); - if (image.length) normalized.image = image; - const fallback = normalizeStringArray(variant.fallback); - if (fallback.length) normalized.fallback = fallback; - const service = normalizeStringArray(variant.service); - if (service.length) normalized.service = service; - if (variant.autoGenerated === true) { - normalized.autoGenerated = true; - } - - return Object.keys(normalized).length ? normalized : null; - }; - - const normalizeSegments = (segments) => { - if (!Array.isArray(segments)) { - return []; - } - return segments - .map((segment) => { - if (!segment || typeof segment !== "object") { - return null; - } - const startRaw = segment.start; - const endRaw = segment.end; - const start = parseNumberOrNull(startRaw); - const end = parseNumberOrNull(endRaw); - const normalizedSegment = {}; - if (start !== null) { - normalizedSegment.start = start; - } else { - const startString = normalizeString(startRaw); - if (startString) { - normalizedSegment.start = startString; - } - } - if (end !== null) { - normalizedSegment.end = end; - } else { - const endString = normalizeString(endRaw); - if (endString) { - normalizedSegment.end = endString; - } - } - const title = normalizeString(segment.title); - if (title) { - normalizedSegment.title = title; - } - const thumbnail = normalizeString(segment.thumbnail); - if (thumbnail) { - normalizedSegment.thumbnail = thumbnail; - } - return Object.keys(normalizedSegment).length ? normalizedSegment : null; - }) - .filter(Boolean); - }; - - const normalizeTextTracks = (tracks) => { - if (!Array.isArray(tracks)) { - return []; - } - return tracks - .map((track) => { - if (!track || typeof track !== "object") { - return null; - } - const normalizedTrack = {}; - const url = normalizeString(track.url); - if (url) normalizedTrack.url = url; - const type = normalizeString(track.type); - if (type) normalizedTrack.type = type; - const language = normalizeString(track.language); - if (language) normalizedTrack.language = language; - return Object.keys(normalizedTrack).length ? normalizedTrack : null; - }) - .filter(Boolean); - }; - - const normalizeParticipants = (participants) => { - if (!Array.isArray(participants)) { - return []; - } - return participants - .map((participant) => { - if (!participant || typeof participant !== "object") { - return null; - } - const normalizedParticipant = {}; - const pubkey = normalizeString(participant.pubkey); - if (pubkey) normalizedParticipant.pubkey = pubkey; - const relay = normalizeString(participant.relay); - if (relay) normalizedParticipant.relay = relay; - return Object.keys(normalizedParticipant).length - ? normalizedParticipant - : null; - }) - .filter(Boolean); - }; - - const normalizeNip71Metadata = (rawMetadata) => { - if (!rawMetadata || typeof rawMetadata !== "object") { - return null; - } - - const normalized = {}; - - if (rawMetadata.kind !== undefined) { - const kindNumber = parseNumberOrNull(rawMetadata.kind); - if (kindNumber !== null) { - normalized.kind = kindNumber; - } else { - const kindString = normalizeString(rawMetadata.kind); - if (kindString) { - normalized.kind = kindString; - } - } - } - - const summary = normalizeString(rawMetadata.summary); - if (summary) { - normalized.summary = summary; - } - - const publishedAt = parseUnixSeconds(rawMetadata.publishedAt); - if (publishedAt !== null) { - normalized.publishedAt = publishedAt; - } - - const alt = normalizeString(rawMetadata.alt); - if (alt) { - normalized.alt = alt; - } - - const duration = parseNumberOrNull(rawMetadata.duration); - if (duration !== null) { - normalized.duration = duration; - } - - const contentWarning = normalizeString(rawMetadata.contentWarning); - if (contentWarning) { - normalized.contentWarning = contentWarning; - } - - const imeta = Array.isArray(rawMetadata.imeta) - ? rawMetadata.imeta - .map((variant) => normalizeImetaVariant(variant)) - .filter(Boolean) - : []; - if (imeta.length) { - normalized.imeta = imeta; - } - - const textTracks = normalizeTextTracks(rawMetadata.textTracks); - if (textTracks.length) { - normalized.textTracks = textTracks; - } - - const segments = normalizeSegments(rawMetadata.segments); - if (segments.length) { - normalized.segments = segments; - } - - const hashtags = normalizeStringArray(rawMetadata.hashtags); - if (hashtags.length) { - normalized.hashtags = hashtags; - } - - const participants = normalizeParticipants(rawMetadata.participants); - if (participants.length) { - normalized.participants = participants; - } - - const references = normalizeStringArray(rawMetadata.references); - if (references.length) { - normalized.references = references; - } - - return Object.keys(normalized).length ? normalized : null; - }; - - const rawPayload = payload && typeof payload === "object" ? payload : {}; - const legacyPayload = - rawPayload.legacyFormData && - typeof rawPayload.legacyFormData === "object" - ? rawPayload.legacyFormData - : rawPayload; - - const rawNip71 = - (rawPayload.nip71 && typeof rawPayload.nip71 === "object" - ? rawPayload.nip71 - : null) || - (legacyPayload.nip71 && typeof legacyPayload.nip71 === "object" - ? legacyPayload.nip71 - : null); - - const title = normalizeString(legacyPayload?.title || ""); - const url = normalizeString(legacyPayload?.url || ""); - const magnet = normalizeString(legacyPayload?.magnet || ""); - const thumbnail = normalizeString(legacyPayload?.thumbnail || ""); - const description = normalizeString(legacyPayload?.description || ""); - const ws = normalizeString(legacyPayload?.ws || ""); - const xs = normalizeString(legacyPayload?.xs || ""); - const rawMode = normalizeString(legacyPayload?.mode || ""); - const normalizedMode = - rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live"; - const enableComments = normalizeBooleanFlag( - legacyPayload?.enableComments, - true - ); - const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false); - const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false); - const isNsfw = rawIsNsfw; - const isForKids = rawIsNsfw ? false : rawIsForKids; - - const legacyFormData = { - version: 3, - title, - url, - magnet, - thumbnail, - description, - mode: normalizedMode, - enableComments, - isNsfw, - isForKids, - }; - - const normalizedNip71 = normalizeNip71Metadata(rawNip71); - - const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet); - const hasImetaVariant = Boolean( - normalizedNip71?.imeta?.some((variant) => - Boolean( - (variant.url && variant.url.length) || - (variant.m && variant.m.length) || - (variant.x && variant.x.length) || - (variant.dim && variant.dim.length) || - (Array.isArray(variant.image) && variant.image.length > 0) || - (Array.isArray(variant.fallback) && variant.fallback.length > 0) || - (Array.isArray(variant.service) && variant.service.length > 0) - ) - ) + const { payload: publishPayload, errors } = normalizeVideoNotePayload( + payload, ); - if (!legacyFormData.title) { - this.showError("Title is required."); + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.showError(message); return false; } - if (!hasLegacySource && !hasImetaVariant) { - this.showError( - "Provide a hosted URL, magnet link, or an imeta variant before publishing." - ); - return false; - } - - if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) { - this.showError("Hosted video URLs must use HTTPS."); - return false; - } - - if (legacyFormData.magnet) { - const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, { - ws, - xs, - }); - legacyFormData.magnet = normalizedMagnet; - const hints = extractMagnetHints(normalizedMagnet); - legacyFormData.ws = hints.ws; - legacyFormData.xs = hints.xs; - } else { - legacyFormData.ws = ""; - legacyFormData.xs = ""; - } - - const publishPayload = { - legacyFormData, - }; - - if (normalizedNip71) { - publishPayload.nip71 = normalizedNip71; - } - try { await this.nostrService.publishVideoNote(publishPayload, this.pubkey); } catch (err) { diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 48017f8e..2cbb3162 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -22,6 +22,11 @@ import { } from "../storage/r2-s3.js"; import { truncateMiddle } from "../utils/formatters.js"; import { userLogger } from "../utils/logger.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "./videoNotePayload.js"; const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); @@ -654,9 +659,17 @@ class R2Service { return false; } - const title = String(metadata.title || "").trim(); + const rawTitleCandidate = + metadata && typeof metadata === "object" ? metadata.title : metadata; + const title = typeof rawTitleCandidate === "string" + ? rawTitleCandidate.trim() + : String(rawTitleCandidate ?? "").trim(); + if (!title) { - this.setCloudflareUploadStatus("Title is required.", "error"); + this.setCloudflareUploadStatus( + getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE), + "error", + ); return false; } @@ -668,18 +681,6 @@ class R2Service { return false; } - const description = String(metadata.description || "").trim(); - const thumbnail = String(metadata.thumbnail || "").trim(); - const magnet = String(metadata.magnet || "").trim(); - const ws = String(metadata.ws || "").trim(); - const xs = String(metadata.xs || "").trim(); - const enableComments = metadata.enableComments !== false; - const isNsfw = metadata.isNsfw === true; - const isForKids = metadata.isForKids === true && !isNsfw; - - metadata.isNsfw = isNsfw; - metadata.isForKids = isForKids; - const accountId = (this.cloudflareSettings?.accountId || "").trim(); const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim(); const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim(); @@ -766,17 +767,17 @@ class R2Service { ); publishOutcome = false; } else { - const payload = { + const rawVideoPayload = { title, url: publicUrl, - magnet, - thumbnail, - description, - ws, - xs, - enableComments, - isNsfw, - isForKids, + magnet: metadata?.magnet ?? "", + thumbnail: metadata?.thumbnail ?? "", + description: metadata?.description ?? "", + ws: metadata?.ws ?? "", + xs: metadata?.xs ?? "", + enableComments: metadata?.enableComments, + isNsfw: metadata?.isNsfw, + isForKids: metadata?.isForKids, }; const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, { @@ -784,7 +785,15 @@ class R2Service { file, }); if (mergedNip71 && Object.keys(mergedNip71).length) { - payload.nip71 = mergedNip71; + rawVideoPayload.nip71 = mergedNip71; + } + + const { payload, errors } = normalizeVideoNotePayload(rawVideoPayload); + + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.setCloudflareUploadStatus(message, "error"); + return false; } const published = await publishVideoNote(payload, { diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js new file mode 100644 index 00000000..bca2ebab --- /dev/null +++ b/js/services/videoNotePayload.js @@ -0,0 +1,379 @@ +import { extractMagnetHints, normalizeAndAugmentMagnet } from "../magnet.js"; + +export const VIDEO_NOTE_ERROR_CODES = { + MISSING_TITLE: "missing_title", + MISSING_SOURCE: "missing_source", + INVALID_URL_PROTOCOL: "invalid_url_protocol", +}; + +export const VIDEO_NOTE_ERROR_MESSAGES = { + [VIDEO_NOTE_ERROR_CODES.MISSING_TITLE]: "Title is required.", + [VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE]: + "Provide a hosted URL, magnet link, or an imeta variant before publishing.", + [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]: + "Hosted video URLs must use HTTPS.", +}; + +export function getVideoNoteErrorMessage(code) { + return VIDEO_NOTE_ERROR_MESSAGES[code] || "Unable to submit video. Please try again."; +} + +function normalizeString(value) { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value).trim() : ""; + } + return String(value ?? "").trim(); +} + +function parseNumberOrNull(value) { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseUnixSeconds(value) { + if (value === null || value === undefined || value === "") { + return null; + } + if (typeof value === "number" && Number.isFinite(value)) { + const normalized = value > 1e12 ? value / 1000 : value; + return Math.floor(normalized); + } + const trimmed = normalizeString(value); + if (!trimmed) { + return null; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) { + const normalized = numeric > 1e12 ? numeric / 1000 : numeric; + return Math.floor(normalized); + } + const timestamp = Date.parse(trimmed); + if (!Number.isNaN(timestamp)) { + return Math.floor(timestamp / 1000); + } + return null; +} + +function normalizeStringArray(input) { + if (!Array.isArray(input)) { + return []; + } + return input + .map((entry) => normalizeString(entry)) + .filter((entry) => Boolean(entry)); +} + +function normalizeBooleanFlag(value, defaultValue = false) { + if (value === true) { + return true; + } + if (value === false) { + return false; + } + return Boolean(defaultValue); +} + +function normalizeImetaVariant(variant) { + if (!variant || typeof variant !== "object") { + return null; + } + const normalized = {}; + const m = normalizeString(variant.m); + if (m) normalized.m = m; + const dim = normalizeString(variant.dim); + if (dim) normalized.dim = dim; + const url = normalizeString(variant.url); + if (url) normalized.url = url; + const x = normalizeString(variant.x); + if (x) normalized.x = x; + const image = normalizeStringArray(variant.image); + if (image.length) normalized.image = image; + const fallback = normalizeStringArray(variant.fallback); + if (fallback.length) normalized.fallback = fallback; + const service = normalizeStringArray(variant.service); + if (service.length) normalized.service = service; + if (variant.autoGenerated === true) { + normalized.autoGenerated = true; + } + + return Object.keys(normalized).length ? normalized : null; +} + +function normalizeSegments(segments) { + if (!Array.isArray(segments)) { + return []; + } + return segments + .map((segment) => { + if (!segment || typeof segment !== "object") { + return null; + } + const startRaw = segment.start; + const endRaw = segment.end; + const start = parseNumberOrNull(startRaw); + const end = parseNumberOrNull(endRaw); + const normalizedSegment = {}; + if (start !== null) { + normalizedSegment.start = start; + } else { + const startString = normalizeString(startRaw); + if (startString) { + normalizedSegment.start = startString; + } + } + if (end !== null) { + normalizedSegment.end = end; + } else { + const endString = normalizeString(endRaw); + if (endString) { + normalizedSegment.end = endString; + } + } + const title = normalizeString(segment.title); + if (title) { + normalizedSegment.title = title; + } + const thumbnail = normalizeString(segment.thumbnail); + if (thumbnail) { + normalizedSegment.thumbnail = thumbnail; + } + return Object.keys(normalizedSegment).length ? normalizedSegment : null; + }) + .filter(Boolean); +} + +function normalizeTextTracks(tracks) { + if (!Array.isArray(tracks)) { + return []; + } + return tracks + .map((track) => { + if (!track || typeof track !== "object") { + return null; + } + const normalizedTrack = {}; + const url = normalizeString(track.url); + if (url) normalizedTrack.url = url; + const type = normalizeString(track.type); + if (type) normalizedTrack.type = type; + const language = normalizeString(track.language); + if (language) normalizedTrack.language = language; + return Object.keys(normalizedTrack).length ? normalizedTrack : null; + }) + .filter(Boolean); +} + +function normalizeParticipants(participants) { + if (!Array.isArray(participants)) { + return []; + } + return participants + .map((participant) => { + if (!participant || typeof participant !== "object") { + return null; + } + const normalizedParticipant = {}; + const pubkey = normalizeString(participant.pubkey); + if (pubkey) normalizedParticipant.pubkey = pubkey; + const relay = normalizeString(participant.relay); + if (relay) normalizedParticipant.relay = relay; + return Object.keys(normalizedParticipant).length + ? normalizedParticipant + : null; + }) + .filter(Boolean); +} + +function normalizeNip71Metadata(rawMetadata) { + if (!rawMetadata || typeof rawMetadata !== "object") { + return null; + } + + const normalized = {}; + + if (rawMetadata.kind !== undefined) { + const kindNumber = parseNumberOrNull(rawMetadata.kind); + if (kindNumber !== null) { + normalized.kind = kindNumber; + } else { + const kindString = normalizeString(rawMetadata.kind); + if (kindString) { + normalized.kind = kindString; + } + } + } + + const summary = normalizeString(rawMetadata.summary); + if (summary) { + normalized.summary = summary; + } + + const publishedAt = parseUnixSeconds(rawMetadata.publishedAt); + if (publishedAt !== null) { + normalized.publishedAt = publishedAt; + } + + const alt = normalizeString(rawMetadata.alt); + if (alt) { + normalized.alt = alt; + } + + const duration = parseNumberOrNull(rawMetadata.duration); + if (duration !== null) { + normalized.duration = duration; + } + + const contentWarning = normalizeString(rawMetadata.contentWarning); + if (contentWarning) { + normalized.contentWarning = contentWarning; + } + + const imeta = Array.isArray(rawMetadata.imeta) + ? rawMetadata.imeta + .map((variant) => normalizeImetaVariant(variant)) + .filter(Boolean) + : []; + if (imeta.length) { + normalized.imeta = imeta; + } + + const textTracks = normalizeTextTracks(rawMetadata.textTracks); + if (textTracks.length) { + normalized.textTracks = textTracks; + } + + const segments = normalizeSegments(rawMetadata.segments); + if (segments.length) { + normalized.segments = segments; + } + + const hashtags = normalizeStringArray(rawMetadata.hashtags); + if (hashtags.length) { + normalized.hashtags = hashtags; + } + + const participants = normalizeParticipants(rawMetadata.participants); + if (participants.length) { + normalized.participants = participants; + } + + const references = normalizeStringArray(rawMetadata.references); + if (references.length) { + normalized.references = references; + } + + return Object.keys(normalized).length ? normalized : null; +} + +export function normalizeVideoNotePayload(input) { + const rawPayload = input && typeof input === "object" ? input : {}; + const legacyPayload = + rawPayload.legacyFormData && typeof rawPayload.legacyFormData === "object" + ? rawPayload.legacyFormData + : rawPayload; + + const rawNip71 = + (rawPayload.nip71 && typeof rawPayload.nip71 === "object" + ? rawPayload.nip71 + : null) || + (legacyPayload.nip71 && typeof legacyPayload.nip71 === "object" + ? legacyPayload.nip71 + : null); + + const title = normalizeString(legacyPayload?.title || ""); + const url = normalizeString(legacyPayload?.url || ""); + const magnet = normalizeString(legacyPayload?.magnet || ""); + const thumbnail = normalizeString(legacyPayload?.thumbnail || ""); + const description = normalizeString(legacyPayload?.description || ""); + const ws = normalizeString(legacyPayload?.ws || ""); + const xs = normalizeString(legacyPayload?.xs || ""); + const rawMode = normalizeString(legacyPayload?.mode || ""); + const normalizedMode = rawMode && rawMode.toLowerCase() === "dev" ? "dev" : "live"; + const enableComments = normalizeBooleanFlag(legacyPayload?.enableComments, true); + const rawIsNsfw = normalizeBooleanFlag(legacyPayload?.isNsfw, false); + const rawIsForKids = normalizeBooleanFlag(legacyPayload?.isForKids, false); + const isNsfw = rawIsNsfw; + const isForKids = rawIsNsfw ? false : rawIsForKids; + const isPrivate = normalizeBooleanFlag(legacyPayload?.isPrivate, false); + + const legacyFormData = { + version: 3, + title, + url, + magnet, + thumbnail, + description, + mode: normalizedMode, + enableComments, + isNsfw, + isForKids, + }; + + if (isPrivate) { + legacyFormData.isPrivate = true; + } + + const normalizedNip71 = normalizeNip71Metadata(rawNip71); + + const hasLegacySource = Boolean(legacyFormData.url || legacyFormData.magnet); + const hasImetaVariant = Boolean( + normalizedNip71?.imeta?.some((variant) => + Boolean( + (variant.url && variant.url.length) || + (variant.m && variant.m.length) || + (variant.x && variant.x.length) || + (variant.dim && variant.dim.length) || + (Array.isArray(variant.image) && variant.image.length > 0) || + (Array.isArray(variant.fallback) && variant.fallback.length > 0) || + (Array.isArray(variant.service) && variant.service.length > 0), + ), + ), + ); + + const errors = []; + + if (!legacyFormData.title) { + errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE); + } + + if (!hasLegacySource && !hasImetaVariant) { + errors.push(VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE); + } + + if (legacyFormData.url && !/^https:\/\//i.test(legacyFormData.url)) { + errors.push(VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL); + } + + if (legacyFormData.magnet) { + const normalizedMagnet = normalizeAndAugmentMagnet(legacyFormData.magnet, { + ws, + xs, + }); + legacyFormData.magnet = normalizedMagnet; + const hints = extractMagnetHints(normalizedMagnet); + legacyFormData.ws = hints.ws || ""; + legacyFormData.xs = hints.xs || ""; + } else { + legacyFormData.ws = ""; + legacyFormData.xs = ""; + } + + const publishPayload = { + legacyFormData, + }; + + if (normalizedNip71) { + publishPayload.nip71 = normalizedNip71; + } + + return { + payload: publishPayload, + errors, + }; +} diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 697d2c7b..65350aca 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -3,6 +3,11 @@ import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; import { userLogger } from "../../utils/logger.js"; +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "../../services/videoNotePayload.js"; export class UploadModal { constructor({ @@ -717,14 +722,15 @@ export class UploadModal { isNsfw: this.readCheckboxValue(this.customFormInputs.isNsfw, false), isForKids: this.readCheckboxValue(this.customFormInputs.isForKids, false) }); - const payload = { - title: this.customFormInputs.title?.value?.trim() || "", - url: this.customFormInputs.url?.value?.trim() || "", - magnet: this.customFormInputs.magnet?.value?.trim() || "", - ws: this.customFormInputs.ws?.value?.trim() || "", - xs: this.customFormInputs.xs?.value?.trim() || "", - thumbnail: this.customFormInputs.thumbnail?.value?.trim() || "", - description: this.customFormInputs.description?.value?.trim() || "", + + const rawPayload = { + title: this.customFormInputs.title?.value ?? "", + url: this.customFormInputs.url?.value ?? "", + magnet: this.customFormInputs.magnet?.value ?? "", + ws: this.customFormInputs.ws?.value ?? "", + xs: this.customFormInputs.xs?.value ?? "", + thumbnail: this.customFormInputs.thumbnail?.value ?? "", + description: this.customFormInputs.description?.value ?? "", enableComments: this.readCheckboxValue( this.customFormInputs.enableComments, true @@ -733,7 +739,7 @@ export class UploadModal { }; if (this.customFormInputs.isPrivate) { - payload.isPrivate = this.readCheckboxValue( + rawPayload.isPrivate = this.readCheckboxValue( this.customFormInputs.isPrivate, false ); @@ -741,7 +747,15 @@ export class UploadModal { const nip71Metadata = this.nip71FormManager.collectSection("custom"); if (nip71Metadata) { - payload.nip71 = nip71Metadata; + rawPayload.nip71 = nip71Metadata; + } + + const { payload, errors } = normalizeVideoNotePayload(rawPayload); + + if (errors.length) { + const message = getVideoNoteErrorMessage(errors[0]); + this.showError(message); + return; } this.emit("upload:submit", { payload }); @@ -1028,12 +1042,12 @@ export class UploadModal { isForKids: this.readCheckboxValue(this.cloudflareIsForKidsInput, false) }); const metadata = { - title: this.cloudflareTitleInput?.value?.trim() || "", - description: this.cloudflareDescriptionInput?.value?.trim() || "", - thumbnail: this.cloudflareThumbnailInput?.value?.trim() || "", - magnet: this.cloudflareMagnetInput?.value?.trim() || "", - ws: this.cloudflareWsInput?.value?.trim() || "", - xs: this.cloudflareXsInput?.value?.trim() || "", + title: this.cloudflareTitleInput?.value ?? "", + description: this.cloudflareDescriptionInput?.value ?? "", + thumbnail: this.cloudflareThumbnailInput?.value ?? "", + magnet: this.cloudflareMagnetInput?.value ?? "", + ws: this.cloudflareWsInput?.value ?? "", + xs: this.cloudflareXsInput?.value ?? "", enableComments: this.readCheckboxValue( this.cloudflareEnableCommentsInput, true @@ -1041,6 +1055,16 @@ export class UploadModal { ...audienceFlags }; + metadata.title = metadata.title?.trim() || ""; + + if (!metadata.title) { + this.setCloudflareUploadStatus( + getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE), + "error" + ); + return; + } + const nip71Metadata = this.nip71FormManager.collectSection("cloudflare"); if (nip71Metadata) { const imetaList = Array.isArray(nip71Metadata.imeta) diff --git a/tests/video-note-payload.test.mjs b/tests/video-note-payload.test.mjs new file mode 100644 index 00000000..25b4adac --- /dev/null +++ b/tests/video-note-payload.test.mjs @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getVideoNoteErrorMessage, + normalizeVideoNotePayload, + VIDEO_NOTE_ERROR_CODES, +} from "../js/services/videoNotePayload.js"; + +test("normalizes minimal payload with hosted URL", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: " Example Title ", + url: " https://cdn.example.com/video.mp4 ", + }); + + assert.deepEqual(errors, []); + assert.ok(payload, "Expected payload result"); + assert.equal(payload.legacyFormData.version, 3); + assert.equal(payload.legacyFormData.title, "Example Title"); + assert.equal( + payload.legacyFormData.url, + "https://cdn.example.com/video.mp4", + ); + assert.equal(payload.legacyFormData.magnet, ""); + assert.equal(payload.legacyFormData.ws, ""); + assert.equal(payload.legacyFormData.xs, ""); + assert.equal(payload.legacyFormData.mode, "live"); + assert.equal(payload.legacyFormData.enableComments, true); + assert.equal(payload.legacyFormData.isNsfw, false); + assert.equal(payload.legacyFormData.isForKids, false); +}); + +test("normalizes mode and boolean flags", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Flag Demo", + url: "https://videos.example.com/watch.mp4", + mode: "DEV", + enableComments: false, + isNsfw: true, + isForKids: true, + isPrivate: true, + }); + + assert.deepEqual(errors, []); + assert.equal(payload.legacyFormData.mode, "dev"); + assert.equal(payload.legacyFormData.enableComments, false); + assert.equal(payload.legacyFormData.isNsfw, true); + assert.equal(payload.legacyFormData.isForKids, false); + assert.equal(payload.legacyFormData.isPrivate, true); +}); + +test("augments magnets with ws/xs hints", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Magnet Upload", + magnet: "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01", + ws: "https://cdn.example.com/video.mp4", + xs: "https://cdn.example.com/video.torrent", + }); + + assert.deepEqual(errors, []); + const { magnet, ws, xs } = payload.legacyFormData; + assert.ok(magnet.startsWith("magnet:?xt=urn:btih:")); + assert.ok( + magnet.includes("ws=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4") || + magnet.includes("ws=https://cdn.example.com/video.mp4"), + "Normalized magnet should include ws hint", + ); + assert.ok( + magnet.includes("xs=https://cdn.example.com/video.torrent") || + magnet.includes("xs=https%3A%2F%2Fcdn.example.com%2Fvideo.torrent"), + "Normalized magnet should include xs hint", + ); + assert.equal(ws, "https://cdn.example.com/video.mp4"); + assert.equal(xs, "https://cdn.example.com/video.torrent"); +}); + +test("normalizes nip71 metadata collections", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "NIP-71 Rich Metadata", + nip71: { + imeta: [ + { + url: " https://cdn.example.com/hls/playlist.m3u8 ", + service: [" wss://relay.example.com "], + }, + ], + segments: [ + { + start: " 00:30 ", + end: 90000, + title: " Intro ", + thumbnail: " https://cdn.example.com/thumb.jpg ", + }, + { + start: "bad", + end: null, + title: "", + }, + ], + textTracks: [ + { url: " https://cdn.example.com/captions.vtt ", type: " text/vtt ", language: " en " }, + null, + ], + participants: [ + { pubkey: " npub123 ", relay: " wss://relay.example.com " }, + {}, + ], + hashtags: [" bitvid ", ""], + references: [" nostr:note1abc "], + duration: "900", + publishedAt: "2023-01-01T00:00:00Z", + summary: " Summary copy ", + }, + }); + + assert.deepEqual(errors, []); + const nip71 = payload.nip71; + assert.ok(nip71, "Expected nip71 payload"); + assert.equal(nip71.summary, "Summary copy"); + assert.equal(nip71.duration, 900); + assert.equal(nip71.publishedAt, 1672531200); + assert.deepEqual(nip71.hashtags, ["bitvid"]); + assert.deepEqual(nip71.references, ["nostr:note1abc"]); + assert.equal(nip71.segments.length, 2); + assert.deepEqual(nip71.segments[0], { + start: "00:30", + end: 90000, + title: "Intro", + thumbnail: "https://cdn.example.com/thumb.jpg", + }); + assert.deepEqual(nip71.segments[1], { start: "bad" }); + assert.deepEqual(nip71.textTracks, [ + { + url: "https://cdn.example.com/captions.vtt", + type: "text/vtt", + language: "en", + }, + ]); + assert.deepEqual(nip71.participants, [ + { + pubkey: "npub123", + relay: "wss://relay.example.com", + }, + ]); + assert.equal(nip71.imeta[0].url, "https://cdn.example.com/hls/playlist.m3u8"); + assert.deepEqual(nip71.imeta[0].service, ["wss://relay.example.com"]); +}); + +test("reports validation errors for missing fields", () => { + const { errors } = normalizeVideoNotePayload({ + title: " ", + url: "", + }); + + assert.deepEqual(errors, [ + VIDEO_NOTE_ERROR_CODES.MISSING_TITLE, + VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE, + ]); + assert.equal( + getVideoNoteErrorMessage(errors[0]), + "Title is required.", + ); +}); + +test("rejects insecure hosted URLs", () => { + const { errors } = normalizeVideoNotePayload({ + title: "Sample", + url: "http://example.com/video.mp4", + }); + + assert.deepEqual(errors, [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]); +}); + +test("allows publishing with only imeta variants", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Imeta Only", + nip71: { + imeta: [ + { + url: "https://cdn.example.com/video.m3u8", + image: ["https://cdn.example.com/poster.jpg"], + }, + ], + }, + }); + + assert.deepEqual(errors, []); + assert.ok(payload.nip71); + assert.equal(payload.legacyFormData.url, ""); +});