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
JavaScript
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
};