UNPKG

cloudapp-dl

Version:

CloudApp/Zight API client and CLI. Use as a CLI tool to download videos or as a programmatic library to interact with the Zight API.

1,329 lines (1,149 loc) 40.2 kB
import axios from 'axios'; import * as cheerio from 'cheerio'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { loadConfig, updateConfig } from './config.js'; import { ensureValidSession, updateSessionFromResponse, relogin, extractSessionFromCookies, performLogin } from './auth.js'; const BASE_URL = 'https://share.zight.com'; const API_BASE = `${BASE_URL}/api/v5`; const ITEMS_PER_PAGE = 12; /** * Session context for programmatic usage * When null, uses CLI config file-based sessions * @typedef {Object} SessionContext * @property {string} sessionId - Current session ID * @property {string} sessionExpiry - Session expiry ISO string * @property {string} [email] - Email for re-authentication * @property {string} [password] - Password for re-authentication * @property {Function} [onSessionUpdate] - Callback when session is updated */ /** * Get session ID from context or config * @param {SessionContext|null} ctx - Session context (null for CLI mode) * @returns {Promise<string>} - Session ID */ const getSessionId = async (ctx = null) => { if (ctx && ctx.sessionId) { return ctx.sessionId; } // Fall back to CLI config-based session return await ensureValidSession(); }; /** * Handle session update from response * @param {Object} response - Axios response * @param {SessionContext|null} ctx - Session context * @returns {Object|null} - Session info if updated */ const handleSessionUpdate = (response, ctx = null) => { const sessionInfo = extractSessionFromCookies(response); if (sessionInfo) { if (ctx && ctx.onSessionUpdate) { // Programmatic mode - call callback ctx.onSessionUpdate(sessionInfo); } else if (!ctx) { // CLI mode - update config file updateConfig({ sessionId: sessionInfo.sessionId, sessionExpiry: sessionInfo.sessionExpiry }); } return sessionInfo; } return null; }; /** * Handle re-authentication * @param {SessionContext|null} ctx - Session context * @returns {Promise<string>} - New session ID */ const handleReauth = async (ctx = null) => { if (ctx && ctx.email && ctx.password) { // Programmatic mode - re-login and update context const result = await performLogin(ctx.email, ctx.password); if (ctx.onSessionUpdate) { ctx.onSessionUpdate({ sessionId: result.sessionId, sessionExpiry: result.sessionExpiry }); } return result.sessionId; } // CLI mode await relogin(); return loadConfig().sessionId; }; /** * MIME type mapping for common file extensions */ const MIME_TYPES = { // Video '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.webm': 'video/webm', '.mkv': 'video/x-matroska', '.m4v': 'video/x-m4v', // Images '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', // Audio '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', // Documents '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json', '.xml': 'application/xml', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', // Archives '.zip': 'application/zip', '.rar': 'application/x-rar-compressed', '.7z': 'application/x-7z-compressed', '.tar': 'application/x-tar', '.gz': 'application/gzip', // Default 'default': 'application/octet-stream' }; /** * Get MIME type for a file based on extension * @param {string} filename - The filename * @returns {string} - MIME type */ const getMimeType = (filename) => { const ext = path.extname(filename).toLowerCase(); return MIME_TYPES[ext] || MIME_TYPES['default']; }; /** * Default headers for API requests */ const defaultHeaders = { 'accept': 'application/json', 'accept-language': 'en-US,en;q=0.9', 'cache-control': 'no-cache', 'pragma': 'no-cache', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' }; /** * Default headers for HTML page requests */ const htmlHeaders = { 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9', 'cache-control': 'no-cache', 'pragma': 'no-cache', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' }; /** * Create an axios instance for API requests * @param {string} sessionId - The session ID to use * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Object} - Axios instance */ const createApiClient = (sessionId, ctx = null) => { const client = axios.create({ baseURL: API_BASE, headers: { ...defaultHeaders, 'cookie': `_session_id=${sessionId};` } }); // Add response interceptor to update session from cookies client.interceptors.response.use( (response) => { handleSessionUpdate(response, ctx); return response; }, (error) => { if (error.response) { handleSessionUpdate(error.response, ctx); } return Promise.reject(error); } ); return client; }; /** * Make an authenticated API request with auto session refresh * @param {string} method - HTTP method * @param {string} endpoint - API endpoint * @param {Object} data - Request data (for POST/PUT) * @param {Object} params - Query parameters * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - API response data */ export const apiRequest = async (method, endpoint, data = null, params = {}, ctx = null) => { try { // Ensure we have a valid session const sessionId = await getSessionId(ctx); const client = createApiClient(sessionId, ctx); const config = { method, url: endpoint, params }; if (data && ['post', 'put', 'patch'].includes(method.toLowerCase())) { config.data = data; } const response = await client.request(config); return response.data; } catch (error) { // If we get a 401, try to re-login and retry the request if (error.response && error.response.status === 401) { if (!ctx) { console.log('Session invalid, attempting re-authentication...'); } else if (ctx.log) { ctx.log('Session invalid, attempting re-authentication...'); } const newSessionId = await handleReauth(ctx); // Retry the request with new session const client = createApiClient(newSessionId, ctx); const config = { method, url: endpoint, params }; if (data && ['post', 'put', 'patch'].includes(method.toLowerCase())) { config.data = data; } const response = await client.request(config); return response.data; } throw error; } }; /** * Get current user account details * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - User account data */ export const getAccountDetails = async (ctx = null) => { const response = await apiRequest('GET', '/users', null, {}, ctx); // Update config with user info (CLI mode only) if (!ctx && response.data && response.data.user) { const user = response.data.user; updateConfig({ userId: user.id, userName: user.attributes.name }); } return response; }; /** * Get list of drops (files/items) * @param {Object} options - Query options * @param {number} options.page - Page number (default: 1) * @param {number} options.perPage - Items per page (default: 20) * @param {string} options.filter - Filter type * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Drops data */ export const getDrops = async (options = {}, ctx = null) => { const { page = 1, perPage = 20, filter = null } = options; const params = { page, per_page: perPage }; if (filter) { params.filter = filter; } return await apiRequest('GET', '/drops', null, params, ctx); }; /** * Get details of a specific drop * @param {string} dropId - The drop ID * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Drop data */ export const getDrop = async (dropId, ctx = null) => { return await apiRequest('GET', `/drops/${dropId}`, null, {}, ctx); }; /** * Get organization details * @param {string} orgId - Organization ID (optional, uses last accessed if not provided) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Organization data */ export const getOrganization = async (orgId = null, ctx = null) => { if (!orgId) { // Try to get org ID from previous account fetch const accountData = await getAccountDetails(ctx); const orgs = accountData.data?.user?.relationships?.organizations?.data; if (orgs && orgs.length > 0) { orgId = orgs[0].id; } } if (!orgId) { throw new Error('No organization ID available'); } return await apiRequest('GET', `/organizations/${orgId}`, null, {}, ctx); }; /** * Search drops * @param {string} query - Search query * @param {Object} options - Search options * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Search results */ export const searchDrops = async (query, options = {}, ctx = null) => { const { page = 1, perPage = 20 } = options; return await apiRequest('GET', '/drops/search', null, { q: query, page, per_page: perPage }, ctx); }; /** * Fetch dashboard HTML page * @param {number} page - Page number (default: 1) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<string>} - HTML content */ export const fetchDashboardPage = async (page = 1, ctx = null) => { try { const sessionId = await getSessionId(ctx); const response = await axios.get(`${BASE_URL}/dashboard`, { headers: { ...htmlHeaders, 'cookie': `_session_id=${sessionId};` }, params: { page } }); // Update session from response cookies handleSessionUpdate(response, ctx); return response.data; } catch (error) { if (error.response && error.response.status === 401) { if (!ctx) { console.log('Session invalid, attempting re-authentication...'); } else if (ctx.log) { ctx.log('Session invalid, attempting re-authentication...'); } const newSessionId = await handleReauth(ctx); const response = await axios.get(`${BASE_URL}/dashboard`, { headers: { ...htmlHeaders, 'cookie': `_session_id=${newSessionId};` }, params: { page } }); handleSessionUpdate(response, ctx); return response.data; } throw error; } }; /** * Parse items from dashboard HTML * @param {string} html - Dashboard HTML content * @returns {Array<Object>} - Array of parsed items */ export const parseItemsFromDashboard = (html) => { const $ = cheerio.load(html); const items = []; // Find all list items in the #items container $('#items .zt-dashboard-listitem').each((index, element) => { const $item = $(element); // Get the link URL from data-clipboard-text const linkElement = $item.find('a[data-clipboard-text]').first(); const url = linkElement.attr('data-clipboard-text') || ''; // Extract item ID from the URL const itemId = url.split('/').pop() || ''; // Get the title const title = $item.find(`[data-testid="item-name-${index}"]`).text().trim() || $item.find('.zt-listitem-title-truncate').first().text().trim() || 'Untitled'; // Get the file extension const fileExt = $item.find(`[data-testid="item-file-ext-${index}"]`).text().trim() || $item.find('.zt-listitem-col4 p').first().text().trim() || ''; // Get the created date const createdAt = $item.find(`[data-testid="item-created-at-${index}"]`).text().trim() || $item.find('.zt-listitem-col5 p').first().text().trim() || ''; // Get the view count const viewCountText = $item.find(`[data-testid="item-view-count-${index}"]`).text().trim() || $item.find('.zt-listitem-col6 p').first().text().trim() || '0 views'; const viewCount = parseInt(viewCountText.replace(/[^0-9]/g, '')) || 0; // Get thumbnail URL if available const thumbnail = $item.find('.zt-listitem-thumbnail img').attr('src') || ''; // Determine if it's a video (has play button) const isVideo = $item.find('.zt-video-play-button').length > 0; if (itemId || url) { items.push({ id: itemId, url, title, fileExt, createdAt, viewCount, thumbnail, isVideo }); } }); return items; }; /** * Delay helper function * @param {number} ms - Milliseconds to delay */ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * Get random delay between min and max milliseconds * @param {number} min - Minimum delay in ms * @param {number} max - Maximum delay in ms */ const randomDelay = (min = 500, max = 1500) => { return Math.floor(Math.random() * (max - min + 1)) + min; }; /** * Get list of items from dashboard with pagination * @param {Object} options - Options * @param {number} options.page - Starting page number (default: 1) * @param {number} options.perPage - Simulated items per page (default: 12, multiples of 12) * @param {Function} options.onProgress - Progress callback (page, totalPagesToFetch) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Items and pagination info */ export const getItemsFromDashboard = async (options = {}, ctx = null) => { const { page = 1, perPage = ITEMS_PER_PAGE, onProgress = null } = options; // Get account details to know total item count const accountData = await getAccountDetails(ctx); const totalItems = accountData.data?.user?.attributes?.item_count || 0; const totalRealPages = Math.ceil(totalItems / ITEMS_PER_PAGE); // Calculate how many real pages we need to fetch for the requested perPage const pagesToFetch = Math.ceil(perPage / ITEMS_PER_PAGE); // Calculate the starting real page based on the virtual page number const startRealPage = ((page - 1) * pagesToFetch) + 1; // Make sure we don't exceed total pages const endRealPage = Math.min(startRealPage + pagesToFetch - 1, totalRealPages); const actualPagesToFetch = endRealPage - startRealPage + 1; if (startRealPage > totalRealPages) { return { items: [], pagination: { currentPage: page, totalPages: Math.ceil(totalRealPages / pagesToFetch), totalItems, itemsPerPage: perPage, realPagesPerRequest: pagesToFetch, hasNextPage: false, hasPrevPage: page > 1 } }; } const allItems = []; // Fetch multiple pages with delays for (let realPage = startRealPage; realPage <= endRealPage; realPage++) { if (onProgress) { onProgress(realPage - startRealPage + 1, actualPagesToFetch, realPage); } // Fetch the dashboard page const html = await fetchDashboardPage(realPage, ctx); // Parse items from HTML const items = parseItemsFromDashboard(html); allItems.push(...items); // Add delay between requests (except for the last one) if (realPage < endRealPage) { const delayMs = randomDelay(500, 1500); await delay(delayMs); } } // Calculate virtual pagination const virtualTotalPages = Math.ceil(totalRealPages / pagesToFetch); return { items: allItems, pagination: { currentPage: page, totalPages: virtualTotalPages, totalItems, itemsPerPage: perPage, realPagesPerRequest: pagesToFetch, realPagesFetched: actualPagesToFetch, startRealPage, endRealPage, hasNextPage: page < virtualTotalPages, hasPrevPage: page > 1 } }; }; /** * Get ALL items from dashboard (fetches all pages with delays) * @param {Object} options - Options * @param {Function} options.onProgress - Progress callback (currentPage, totalPages, itemsFetched) * @param {number} options.startPage - Start from this page (default: 1) * @param {number} options.endPage - End at this page (default: all pages) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - All items and metadata */ export const getAllItems = async (options = {}, ctx = null) => { const { onProgress = null, startPage = 1, endPage = null } = options; // Get account details to know total item count const accountData = await getAccountDetails(ctx); const totalItems = accountData.data?.user?.attributes?.item_count || 0; const totalRealPages = Math.ceil(totalItems / ITEMS_PER_PAGE); // Determine the actual end page const actualEndPage = endPage ? Math.min(endPage, totalRealPages) : totalRealPages; const actualStartPage = Math.max(1, startPage); if (actualStartPage > totalRealPages) { return { items: [], metadata: { totalItems, totalPages: totalRealPages, fetchedPages: 0, startPage: actualStartPage, endPage: actualEndPage } }; } const allItems = []; const pagesToFetch = actualEndPage - actualStartPage + 1; // Fetch all pages with delays for (let page = actualStartPage; page <= actualEndPage; page++) { if (onProgress) { onProgress(page - actualStartPage + 1, pagesToFetch, allItems.length, page); } // Fetch the dashboard page const html = await fetchDashboardPage(page, ctx); // Parse items from HTML const items = parseItemsFromDashboard(html); allItems.push(...items); // Add delay between requests (except for the last one) if (page < actualEndPage) { const delayMs = randomDelay(500, 1500); await delay(delayMs); } } return { items: allItems, metadata: { totalItems, totalPages: totalRealPages, fetchedPages: pagesToFetch, startPage: actualStartPage, endPage: actualEndPage, itemsFetched: allItems.length } }; }; /** * Get list of collections * @param {Object} options - Options * @param {number} options.page - Page number (default: 1) * @param {number} options.perPage - Items per page (default: 50) * @param {string} options.sort - Sort field (default: 'name') * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Collections data */ export const getCollections = async (options = {}, ctx = null) => { const { page = 1, perPage = 50, sort = 'name' } = options; return await apiRequest('GET', '/collections', null, { page, per_page: perPage, sort }, ctx); }; /** * Get all collections (fetches all pages) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Array>} - Array of all collections */ export const getAllCollections = async (ctx = null) => { const allCollections = []; let page = 1; let hasMore = true; while (hasMore) { const response = await getCollections({ page, perPage: 50 }, ctx); const collections = response.data?.collections || []; if (collections.length === 0) { hasMore = false; } else { allCollections.push(...collections); const totalCount = response.data?.total_count || 0; hasMore = allCollections.length < totalCount; page++; // Add small delay between pages if (hasMore) { await delay(randomDelay(300, 800)); } } } return allCollections; }; /** * Create a video recording request link * @param {Object} options - Request options * @param {string} options.name - Title of the request (required) * @param {string} options.message - Message/instructions for recorder (required) * @param {string} options.customId - Custom identifier (optional) * @param {string} options.expiresAt - Expiration date in ISO format (optional) * @param {string} options.collectionId - Collection ID to add recording to (optional) * @param {boolean} options.collectLogs - Whether to collect logs (default: false) * @param {string} options.logCollectionDomain - Domain to collect logs from (optional) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Request content data with link */ export const createVideoRequest = async (options = {}, ctx = null) => { const { name, message, customId = null, expiresAt = null, collectionId = null, collectLogs = false, logCollectionDomain = null } = options; if (!name) { throw new Error('Name/title is required'); } if (!message) { throw new Error('Message is required'); } const payload = { name, message, collect_logs: collectLogs, log_collection_domain: logCollectionDomain }; // Add optional fields if provided if (customId) { payload.custom_id = customId; } if (expiresAt) { payload.expires_at = expiresAt; } if (collectionId) { payload.collection_id = collectionId; } return await apiRequest('POST', '/request_contents', payload, {}, ctx); }; /** * Get list of video requests * @param {Object} options - Options * @param {number} options.page - Page number (default: 1) * @param {number} options.perPage - Items per page (default: 50) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Video requests data */ export const getVideoRequests = async (options = {}, ctx = null) => { const { page = 1, perPage = 50 } = options; return await apiRequest('GET', '/request_contents', null, { page, per_page: perPage, sort: 'name' }, ctx); }; /** * Update/edit a video request * @param {string} requestId - The request ID to update * @param {Object} options - Update options * @param {string} options.name - New title * @param {string} options.message - New message * @param {string} options.customId - New custom ID * @param {string} options.expiresAt - New expiration date (ISO format) * @param {string} options.collectionId - New collection ID * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Updated request content data */ export const updateVideoRequest = async (requestId, options = {}, ctx = null) => { const { name, message, customId, expiresAt, collectionId } = options; const payload = {}; if (name !== undefined) payload.name = name; if (message !== undefined) payload.message = message; if (customId !== undefined) payload.custom_id = customId; if (expiresAt !== undefined) payload.expires_at = expiresAt; if (collectionId !== undefined) payload.collection_id = collectionId; return await apiRequest('PATCH', `/request_contents/${requestId}`, payload, {}, ctx); }; /** * Delete a video request * @param {string} requestId - The request ID to delete * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Deleted request content data */ export const deleteVideoRequest = async (requestId, ctx = null) => { return await apiRequest('DELETE', `/request_contents/${requestId}`, null, {}, ctx); }; /** * Get single item/drop details (uses v4 API) * @param {string} itemId - The item ID * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Item details */ export const getItem = async (itemId, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const response = await axios.get(`https://share.zight.com/api/v4/items/${itemId}`, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, accept: 'application/json' } }); // Update session from response handleSessionUpdate(response, ctx); return response.data; }; /** * Get a specific video request by ID (finds in list) * @param {string} requestId - The request ID * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object|null>} - Request content data or null */ export const getVideoRequest = async (requestId, ctx = null) => { // Fetch all requests and find the one we want const response = await getVideoRequests({ page: 1, perPage: 100 }, ctx); const requests = response.data?.request_contents || []; return requests.find(r => r.id === requestId) || null; }; /** * Step 1: Create an upload request to get S3 credentials * @param {string} filename - The filename * @param {number} fileSize - File size in bytes * @param {string} contentType - MIME type * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Upload credentials and item info */ export const createUploadRequest = async (filename, fileSize, contentType, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } // Create form data const formData = new URLSearchParams(); formData.append('name', filename); formData.append('content_type', contentType); formData.append('file_size', fileSize.toString()); formData.append('action_type', 'fu'); const response = await axios.post('https://share.zight.com/api/v4/items', formData, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, 'content-type': 'application/x-www-form-urlencoded', 'x-requested-with': 'XMLHttpRequest' } }); handleSessionUpdate(response, ctx); return response.data; }; /** * Step 2: Upload file to S3 using signed credentials * @param {string} s3Url - The S3 upload URL * @param {Object} s3Params - The S3 signing parameters * @param {Buffer|Stream} fileData - The file data * @param {Function} onProgress - Progress callback (optional) * @returns {Promise<Object>} - S3 response with ETag */ export const uploadToS3 = async (s3Url, s3Params, filePath, onProgress) => { const FormData = (await import('form-data')).default; const form = new FormData(); // Add S3 signing fields in the correct order form.append('key', s3Params.key); form.append('acl', s3Params.acl); form.append('Content-Type', s3Params['Content-Type']); form.append('Cache-Control', s3Params['Cache-Control']); form.append('policy', s3Params.policy); form.append('x-amz-credential', s3Params['x-amz-credential']); form.append('x-amz-algorithm', s3Params['x-amz-algorithm']); form.append('x-amz-date', s3Params['x-amz-date']); form.append('x-amz-signature', s3Params['x-amz-signature']); form.append('success_action_status', s3Params.success_action_status); // Add the file last const fileStream = fs.createReadStream(filePath); const stats = fs.statSync(filePath); form.append('file', fileStream, { filename: path.basename(filePath), contentType: s3Params['Content-Type'], knownLength: stats.size }); const response = await axios.post(s3Url, form, { headers: { ...form.getHeaders(), 'Origin': 'https://share.zight.com', 'Referer': 'https://share.zight.com/' }, maxContentLength: Infinity, maxBodyLength: Infinity, onUploadProgress: onProgress }); // S3 returns XML, extract ETag from headers const etag = response.headers['etag'] || response.headers['ETag']; return { etag, status: response.status, data: response.data }; }; /** * Step 3: Confirm upload completion * @param {string} slug - The item slug/ID * @param {string} s3Key - The S3 key * @param {string} checksum - The ETag/checksum from S3 (with quotes) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Completed item data */ export const confirmUploadComplete = async (slug, s3Key, checksum, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const formData = new URLSearchParams(); formData.append('key', s3Key); formData.append('checksum', checksum); const response = await axios.put(`https://share.zight.com/api/v4/items/${slug}/completed`, formData, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 'x-requested-with': 'XMLHttpRequest', 'origin': 'https://share.zight.com', 'referer': 'https://share.zight.com/dashboard' } }); handleSessionUpdate(response, ctx); return response.data; }; /** * Upload a file to Zight (all 3 steps combined) * @param {string} filePath - Path to the file to upload * @param {Function} onProgress - Progress callback (optional) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Uploaded item data with share URL */ export const uploadFile = async (filePath, onProgress = null, ctx = null) => { // Validate file exists if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const stats = fs.statSync(filePath); const filename = path.basename(filePath); const contentType = getMimeType(filename); const fileSize = stats.size; // Step 1: Create upload request const uploadRequest = await createUploadRequest(filename, fileSize, contentType, ctx); if (!uploadRequest.s3 || !uploadRequest.url) { throw new Error('Failed to get upload credentials'); } // Step 2: Upload to S3 const s3Response = await uploadToS3(uploadRequest.url, uploadRequest.s3, filePath, onProgress); // Get checksum - use ETag from S3 response headers OR XML body let checksum = s3Response.etag; // S3 POST returns ETag in XML body: <PostResponse><ETag>"..."</ETag></PostResponse> if (!checksum && s3Response.data) { const xmlData = typeof s3Response.data === 'string' ? s3Response.data : ''; const etagMatch = xmlData.match(/<ETag>([^<]+)<\/ETag>/); if (etagMatch) { checksum = etagMatch[1]; } } if (!checksum) { // Calculate MD5 using streaming to avoid loading entire file into memory if (!ctx) { console.log('\nCalculating checksum (streaming)...'); } else if (ctx.log) { ctx.log('Calculating checksum (streaming)...'); } checksum = await new Promise((resolve, reject) => { const hash = crypto.createHash('md5'); const stream = fs.createReadStream(filePath); stream.on('data', chunk => hash.update(chunk)); stream.on('end', () => resolve(`"${hash.digest('hex')}"`)); stream.on('error', reject); }); } // Step 3: Confirm upload complete if (!ctx) { console.log('\nConfirming upload with Zight...'); } else if (ctx.log) { ctx.log('Confirming upload with Zight...'); } const result = await confirmUploadComplete( uploadRequest.slug, uploadRequest.s3.key, checksum, ctx ); return { ...result, share_url: uploadRequest.share_url || result.share_url }; }; /** * Delete an item (move to trash or permanent delete) * @param {string} itemId - The item ID to delete * @param {boolean} permanent - If true, permanently delete (no recovery) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Delete response */ export const deleteItem = async (itemId, permanent = false, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const url = permanent ? `https://share.zight.com/api/v4/items/${itemId}?permanent=true` : `https://share.zight.com/api/v4/items/${itemId}`; const response = await axios.delete(url, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, 'x-requested-with': 'XMLHttpRequest', 'origin': 'https://share.zight.com' } }); handleSessionUpdate(response, ctx); return response.data; }; /** * Restore an item from trash * @param {string} itemId - The item ID to restore * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Restore response */ export const restoreItem = async (itemId, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const response = await axios.put(`https://share.zight.com/api/v4/items/${itemId}/restore`, null, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, 'x-requested-with': 'XMLHttpRequest', 'origin': 'https://share.zight.com' } }); handleSessionUpdate(response, ctx); return response.data; }; /** * Empty the trash (delete all items permanently) * Deletes items one by one since bulk endpoint doesn't work via API * @param {Function} onProgress - Progress callback (index, total, item) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Results summary */ export const emptyTrash = async (onProgress = null, ctx = null) => { // Get all trash items first const items = await getTrashItems(ctx); if (items.length === 0) { return { deleted: 0, failed: 0, total: 0 }; } let deleted = 0; let failed = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; if (onProgress) { onProgress(i + 1, items.length, item); } try { await deleteItem(item.id, true, ctx); // permanent delete deleted++; // Small delay between deletions if (i < items.length - 1) { await new Promise(r => setTimeout(r, 300)); } } catch (err) { failed++; } } return { deleted, failed, total: items.length }; }; /** * Fetch trash page HTML * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<string>} - HTML content */ export const fetchTrashPage = async (ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const response = await axios.get('https://share.zight.com/dashboard/trash', { headers: { ...htmlHeaders, cookie: `_session_id=${sessionId}` } }); handleSessionUpdate(response, ctx); return response.data; }; /** * Parse trash items from HTML * @param {string} html - HTML content * @returns {Array} - Array of trash items */ export const parseTrashItems = (html) => { const $ = cheerio.load(html); const items = []; $('.zt-trash-listitem').each((index, element) => { const $item = $(element); // Get item name const name = $item.find(`[data-testid="item-name-${index}"]`).text().trim(); // Get created date const createdAt = $item.find(`[data-testid="item-created-at-${index}"]`).attr('title') || ''; // Get deleted date const deletedAt = $item.find(`[data-testid="item-archived-at-${index}"]`).attr('title') || ''; // Get item ID from restore link const restoreHref = $item.find('.zt-test-restore').attr('href') || ''; const idMatch = restoreHref.match(/\/api\/v4\/items\/([^\/]+)\/restore/); const id = idMatch ? idMatch[1] : ''; if (id) { items.push({ id, name, createdAt, deletedAt }); } }); return items; }; /** * Get items from trash * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Array>} - Array of trash items */ export const getTrashItems = async (ctx = null) => { const html = await fetchTrashPage(ctx); return parseTrashItems(html); }; /** * Get notifications * @param {Object} options - Options * @param {string} options.viewed - 'yes', 'no', or 'all' (default: 'no') * @param {number} options.limit - Number of notifications to fetch (default: 20) * @param {string} options.createdBefore - ISO date to fetch older notifications * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Notifications data */ export const getNotifications = async (options = {}, ctx = null) => { const { viewed = 'no', limit = 20, createdBefore } = options; const params = { limit }; if (viewed !== 'all') { params.viewed = viewed; } if (createdBefore) { params.created_before = createdBefore; } return await apiRequest('GET', '/client_notifications', null, params, ctx); }; /** * Mark notification as viewed * @param {string} notificationId - The notification ID * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Response */ export const markNotificationViewed = async (notificationId, ctx = null) => { const sessionId = await getSessionId(ctx); if (!sessionId) { throw new Error('Not logged in'); } const response = await axios.patch( `https://share.zight.com/api/v5/client_notifications/${notificationId}`, { viewed: true, campagin_source: 'cli' }, { headers: { ...defaultHeaders, cookie: `_session_id=${sessionId}`, 'content-type': 'application/json', 'origin': 'https://share.zight.com' } } ); handleSessionUpdate(response, ctx); return response.data; }; /** * Mark all notifications as viewed * Fetches unread notifications and marks each one individually * @param {Function} onProgress - Progress callback (index, total) * @param {SessionContext|null} ctx - Session context for programmatic usage * @returns {Promise<Object>} - Results summary */ export const markAllNotificationsViewed = async (onProgress = null, ctx = null) => { // Get unread notifications const response = await getNotifications({ viewed: 'no', limit: 100 }, ctx); const notifications = response.data?.client_notifications || []; if (notifications.length === 0) { return { marked: 0, failed: 0, total: 0 }; } let marked = 0; let failed = 0; for (let i = 0; i < notifications.length; i++) { const notif = notifications[i]; if (onProgress) { onProgress(i + 1, notifications.length); } try { await markNotificationViewed(notif.id, ctx); marked++; // Small delay between requests if (i < notifications.length - 1) { await new Promise(r => setTimeout(r, 200)); } } catch (err) { failed++; } } return { marked, failed, total: notifications.length }; }; export default { apiRequest, getAccountDetails, getDrops, getDrop, getOrganization, searchDrops, fetchDashboardPage, parseItemsFromDashboard, getItemsFromDashboard, getAllItems, getCollections, getAllCollections, createVideoRequest, getVideoRequests, getVideoRequest, updateVideoRequest, deleteVideoRequest, getItem, uploadFile, createUploadRequest, uploadToS3, confirmUploadComplete, deleteItem, restoreItem, emptyTrash, getTrashItems, getNotifications, markNotificationViewed, markAllNotificationsViewed };