Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2026-03-25 11:00:40 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh EthanHealy01 Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 47cad0a131
commit 28613caf8a
181 changed files with 25715 additions and 124 deletions
+78 -3
View File
@@ -53,9 +53,18 @@ class FileStorageService {
type: stirlingFile.type,
size: stirlingFile.size,
lastModified: stirlingFile.lastModified,
createdAt: stub.createdAt,
data: arrayBuffer,
thumbnail: stub.thumbnailUrl,
isLeaf: stub.isLeaf ?? true,
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser,
remoteAccessRole: stub.remoteAccessRole,
remoteSharedViaLink: stub.remoteSharedViaLink,
remoteHasShareLinks: stub.remoteHasShareLinks,
remoteShareToken: stub.remoteShareToken,
// History data from stub
versionNumber: stub.versionNumber ?? 1,
@@ -160,11 +169,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber,
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
createdAt: Date.now() // Current session
createdAt: record.createdAt || Date.now()
};
resolve(stub);
@@ -200,11 +217,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
createdAt: record.createdAt || Date.now()
});
}
cursor.continue();
@@ -215,6 +240,16 @@ class FileStorageService {
});
}
/**
* Get all history stubs for a given original file ID.
*/
async getHistoryChainStubs(originalFileId: FileId): Promise<StirlingFileStub[]> {
const stubs = await this.getAllStirlingFileStubs();
return stubs
.filter((stub) => (stub.originalFileId || stub.id) === originalFileId)
.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
}
/**
* Get leaf StirlingFileStubs only - for unprocessed files
*/
@@ -243,11 +278,19 @@ class FileStorageService {
quickKey: record.quickKey,
thumbnailUrl: record.thumbnail,
isLeaf: record.isLeaf,
remoteStorageId: record.remoteStorageId,
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
remoteOwnerUsername: record.remoteOwnerUsername,
remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser,
remoteAccessRole: record.remoteAccessRole,
remoteSharedViaLink: record.remoteSharedViaLink,
remoteHasShareLinks: record.remoteHasShareLinks,
remoteShareToken: record.remoteShareToken,
versionNumber: record.versionNumber || 1,
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
createdAt: Date.now()
createdAt: record.createdAt || Date.now()
});
}
cursor.continue();
@@ -473,6 +516,38 @@ class FileStorageService {
return false;
}
}
/**
* Update metadata fields for a stored file record.
*/
async updateFileMetadata(fileId: FileId, updates: Partial<StoredStirlingFileRecord>): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>((resolve, reject) => {
const request = store.get(fileId);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as StoredStirlingFileRecord | undefined);
});
if (!record) {
return false;
}
const updatedRecord = { ...record, ...updates };
await new Promise<void>((resolve, reject) => {
const request = store.put(updatedRecord);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
return true;
} catch (error) {
console.error('Failed to update file metadata:', error);
return false;
}
}
}
// Export singleton instance
@@ -0,0 +1,169 @@
import JSZip from 'jszip';
import { fileStorage } from '@app/services/fileStorage';
import type { FileId, ToolOperation } from '@app/types/file';
import type { StirlingFileStub } from '@app/types/fileContext';
interface ShareBundleEntry {
logicalId: string;
rootLogicalId: string;
parentLogicalId?: string;
versionNumber: number;
name: string;
type: string;
size: number;
lastModified: number;
toolHistory?: ToolOperation[];
filePath: string;
isLeaf: boolean;
}
export interface ShareBundleManifest {
schemaVersion: 1;
rootLogicalId: string;
rootLogicalIds: string[];
createdAt: number;
entries: ShareBundleEntry[];
}
function sanitizeFilename(name: string): string {
const trimmed = name?.trim();
if (!trimmed) return 'file';
return trimmed.replace(/[\\/:*?"<>|]/g, '_');
}
export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
const roots = Array.isArray(originalFileIds) ? originalFileIds : [originalFileIds];
const uniqueRoots = Array.from(new Set(roots));
const allStubs: Array<{ rootId: FileId; stubs: Awaited<ReturnType<typeof fileStorage.getHistoryChainStubs>> }> = [];
for (const rootId of uniqueRoots) {
const stubs = await fileStorage.getHistoryChainStubs(rootId);
if (stubs.length === 0) {
throw new Error('No history chain found for file.');
}
allStubs.push({ rootId, stubs });
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const chain of allStubs) {
for (const stub of chain.stubs) {
const file = await fileStorage.getStirlingFile(stub.id);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: chain.rootId,
parentLogicalId: stub.parentFileId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: Boolean(stub.isLeaf),
});
}
}
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: uniqueRoots[0],
rootLogicalIds: uniqueRoots,
createdAt: Date.now(),
entries,
};
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const firstStubName = allStubs[0]?.stubs[0]?.name || 'shared';
const rootName = sanitizeFilename(firstStubName);
const bundleFile = new File([zipBlob], `${rootName}-history.zip`, {
type: 'application/zip',
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
export async function buildSharePackage(
stubs: StirlingFileStub[]
): Promise<{
bundleFile: File;
manifest: ShareBundleManifest;
}> {
if (stubs.length === 0) {
throw new Error('No files provided for sharing.');
}
const zip = new JSZip();
const entries: ShareBundleEntry[] = [];
for (const stub of stubs) {
const file = await fileStorage.getStirlingFile(stub.id as FileId);
if (!file) {
throw new Error(`Missing file data for ${stub.name || stub.id}`);
}
const logicalId = stub.id as string;
const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`;
const buffer = await file.arrayBuffer();
zip.file(filePath, buffer);
entries.push({
logicalId,
rootLogicalId: logicalId,
versionNumber: stub.versionNumber || 1,
name: stub.name,
type: stub.type,
size: stub.size,
lastModified: stub.lastModified,
toolHistory: stub.toolHistory,
filePath,
isLeaf: true,
});
}
const rootLogicalIds = entries.map((entry) => entry.logicalId);
const manifest: ShareBundleManifest = {
schemaVersion: 1,
rootLogicalId: rootLogicalIds[0],
rootLogicalIds,
createdAt: Date.now(),
entries,
};
zip.file('stirling-share.json', JSON.stringify(manifest, null, 2));
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
const bundleFile = new File([zipBlob], `shared-files.zip`, {
type: 'application/zip',
lastModified: Date.now(),
});
return { bundleFile, manifest };
}
@@ -0,0 +1,125 @@
import apiClient from '@app/services/apiClient';
import { fileStorage } from '@app/services/fileStorage';
import { buildHistoryBundle, buildSharePackage } from '@app/services/serverStorageBundle';
import type { FileId } from '@app/types/file';
import type { StirlingFileStub } from '@app/types/fileContext';
function resolveUpdatedAt(value: unknown): number {
if (!value) {
return Date.now();
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : Date.now();
}
const parsed = new Date(String(value)).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
export async function uploadHistoryChain(
originalFileId: FileId,
existingRemoteId?: number
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const chain = await fileStorage.getHistoryChainStubs(originalFileId);
if (chain.length === 0) {
throw new Error('No history chain found.');
}
const finalStub =
chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
const finalFile = await fileStorage.getStirlingFile(finalStub.id);
if (!finalFile) {
throw new Error('Missing final file data for sharing.');
}
const { bundleFile, manifest } = await buildHistoryBundle(originalFileId);
const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', {
type: 'application/json',
lastModified: Date.now(),
});
const formData = new FormData();
formData.append('file', finalFile, finalFile.name);
formData.append('historyBundle', bundleFile, bundleFile.name);
formData.append('auditLog', auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain };
}
const response = await apiClient.post('/api/v1/storage/files', formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error('Missing stored file ID in response.');
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain };
}
export async function uploadHistoryChains(
originalFileIds: FileId[],
existingRemoteId?: number
): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> {
const uniqueRoots = Array.from(new Set(originalFileIds));
const chainMap = new Map<FileId, StirlingFileStub[]>();
const combinedChain: StirlingFileStub[] = [];
const seenIds = new Set<FileId>();
const leafStubs: StirlingFileStub[] = [];
for (const rootId of uniqueRoots) {
const chain = await fileStorage.getHistoryChainStubs(rootId);
if (chain.length === 0) {
throw new Error('No history chain found.');
}
chainMap.set(rootId, chain);
const finalStub =
chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1];
if (finalStub) {
leafStubs.push(finalStub);
}
for (const stub of chain) {
if (!seenIds.has(stub.id as FileId)) {
seenIds.add(stub.id as FileId);
combinedChain.push(stub);
}
}
}
let shareFile: File;
if (leafStubs.length === 1) {
const finalFile = await fileStorage.getStirlingFile(leafStubs[0].id);
if (!finalFile) {
throw new Error('Missing final file data for sharing.');
}
shareFile = finalFile;
} else {
const { bundleFile } = await buildSharePackage(leafStubs);
shareFile = bundleFile;
}
const { bundleFile, manifest } = await buildHistoryBundle(uniqueRoots);
const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', {
type: 'application/json',
lastModified: Date.now(),
});
const formData = new FormData();
formData.append('file', shareFile, shareFile.name);
formData.append('historyBundle', bundleFile, bundleFile.name);
formData.append('auditLog', auditLog, auditLog.name);
if (existingRemoteId) {
const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData);
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId: existingRemoteId, updatedAt, chain: combinedChain };
}
const response = await apiClient.post('/api/v1/storage/files', formData);
const remoteId = response.data?.id as number | undefined;
if (!remoteId) {
throw new Error('Missing stored file ID in response.');
}
const updatedAt = resolveUpdatedAt(response.data?.updatedAt);
return { remoteId, updatedAt, chain: combinedChain };
}
@@ -0,0 +1,119 @@
import JSZip from 'jszip';
import type { ShareBundleManifest } from '@app/services/serverStorageBundle';
const MANIFEST_FILENAME = 'stirling-share.json';
export function parseContentDispositionFilename(disposition?: string): string | null {
if (!disposition) return null;
const filenameMatch = /filename="([^"]+)"/i.exec(disposition);
if (filenameMatch?.[1]) return filenameMatch[1];
const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1]);
} catch {
return utf8Match[1];
}
}
return null;
}
export function isZipBundle(contentType: string, filename: string): boolean {
return contentType.includes('zip') || filename.toLowerCase().endsWith('.zip');
}
export function getShareBundleEntryRootId(
manifest: ShareBundleManifest,
entry: ShareBundleManifest['entries'][number]
): string {
return entry.rootLogicalId || manifest.rootLogicalId;
}
export function resolveShareBundleOrder(manifest: ShareBundleManifest): {
rootOrder: string[];
sortedEntries: ShareBundleManifest['entries'];
} {
const entryRootId = (entry: ShareBundleManifest['entries'][number]) =>
getShareBundleEntryRootId(manifest, entry);
const rootOrder =
manifest.rootLogicalIds && manifest.rootLogicalIds.length > 0
? manifest.rootLogicalIds
: Array.from(new Set(manifest.entries.map(entryRootId)));
const sortedEntries: ShareBundleManifest['entries'] = [];
for (const rootId of rootOrder) {
const rootEntries = manifest.entries
.filter((entry) => entryRootId(entry) === rootId)
.sort((a, b) => a.versionNumber - b.versionNumber);
sortedEntries.push(...rootEntries);
}
return { rootOrder, sortedEntries };
}
export async function loadShareBundleEntries(
blob: Blob
): Promise<{
manifest: ShareBundleManifest;
rootOrder: string[];
sortedEntries: ShareBundleManifest['entries'];
files: File[];
} | null> {
const zip = await JSZip.loadAsync(blob);
const manifestEntry = zip.file(MANIFEST_FILENAME);
if (!manifestEntry) {
return null;
}
const manifestText = await manifestEntry.async('text');
const manifest = JSON.parse(manifestText) as ShareBundleManifest;
const { rootOrder, sortedEntries } = resolveShareBundleOrder(manifest);
const files: File[] = [];
for (const entry of sortedEntries) {
const zipEntry = zip.file(entry.filePath);
if (!zipEntry) {
throw new Error(`Missing file entry ${entry.filePath}`);
}
const fileBlob = await zipEntry.async('blob');
files.push(
new File([fileBlob], entry.name, {
type: entry.type,
lastModified: entry.lastModified,
})
);
}
return { manifest, rootOrder, sortedEntries, files };
}
export async function extractLatestFilesFromBundle(
blob: Blob,
filename: string,
contentType: string
): Promise<File[]> {
if (!isZipBundle(contentType, filename)) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const bundle = await loadShareBundleEntries(blob);
if (!bundle) {
return [new File([blob], filename, { type: contentType || blob.type })];
}
const { manifest, rootOrder, sortedEntries, files } = bundle;
const latestByRoot = new Map<string, File>();
for (let i = 0; i < sortedEntries.length; i += 1) {
const entry = sortedEntries[i];
latestByRoot.set(getShareBundleEntryRootId(manifest, entry), files[i]);
}
const latestFiles = rootOrder
.map((rootId) => latestByRoot.get(rootId))
.filter((file): file is File => Boolean(file));
if (latestFiles.length > 0) {
return latestFiles;
}
return [new File([blob], filename, { type: contentType || blob.type })];
}