Skip to content

Commit 2a2749f

Browse files
committed
fix: improve zip upload handling
1 parent 56230aa commit 2a2749f

File tree

5 files changed

+217
-38
lines changed

5 files changed

+217
-38
lines changed

src/__tests__/upload.route.test.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2+
import { strToU8, zipSync } from 'fflate'
23
import { vi } from 'vitest'
34

45
import { Upload } from '../routes/upload'
@@ -16,7 +17,6 @@ vi.mock('convex/react', () => ({
1617
useConvexAuth: () => ({ isAuthenticated: true }),
1718
useMutation: () => generateUploadUrl,
1819
useAction: () => publishVersion,
19-
useQuery: () => null,
2020
}))
2121

2222
describe('Upload route', () => {
@@ -83,6 +83,64 @@ describe('Upload route', () => {
8383
expect(await screen.findByText(/Add at least one file/i)).toBeTruthy()
8484
})
8585

86+
it('extracts zip uploads and unwraps top-level folders', async () => {
87+
render(<Upload />)
88+
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
89+
target: { value: 'cool-skill' },
90+
})
91+
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
92+
target: { value: 'Cool Skill' },
93+
})
94+
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
95+
target: { value: '1.2.3' },
96+
})
97+
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
98+
target: { value: 'latest' },
99+
})
100+
101+
const zip = zipSync({
102+
'hetzner-cloud-skill/SKILL.md': new Uint8Array(strToU8('hello')),
103+
'hetzner-cloud-skill/notes.txt': new Uint8Array(strToU8('notes')),
104+
})
105+
const zipBytes = zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength)
106+
const zipFile = new File([zipBytes], 'bundle.zip', { type: 'application/zip' })
107+
108+
const input = screen.getByTestId('upload-input') as HTMLInputElement
109+
fireEvent.change(input, { target: { files: [zipFile] } })
110+
111+
expect(await screen.findByText('notes.txt', {}, { timeout: 3000 })).toBeTruthy()
112+
expect(screen.getByText('SKILL.md')).toBeTruthy()
113+
expect(await screen.findByText(/Ready to publish/i, {}, { timeout: 3000 })).toBeTruthy()
114+
})
115+
116+
it('blocks non-text folder uploads (png)', async () => {
117+
render(<Upload />)
118+
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
119+
target: { value: 'cool-skill' },
120+
})
121+
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
122+
target: { value: 'Cool Skill' },
123+
})
124+
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
125+
target: { value: '1.2.3' },
126+
})
127+
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
128+
target: { value: 'latest' },
129+
})
130+
131+
const skill = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
132+
const png = new File([new Uint8Array([137, 80, 78, 71]).buffer], 'screenshot.png', {
133+
type: 'image/png',
134+
})
135+
const input = screen.getByTestId('upload-input') as HTMLInputElement
136+
fireEvent.change(input, { target: { files: [skill, png] } })
137+
138+
expect(await screen.findByText('screenshot.png')).toBeTruthy()
139+
fireEvent.click(screen.getByRole('button', { name: /publish/i }))
140+
expect(await screen.findByText(/Remove non-text files: screenshot\.png/i)).toBeTruthy()
141+
expect(screen.getByText('screenshot.png')).toBeTruthy()
142+
})
143+
86144
it('surfaces publish errors and stays on page', async () => {
87145
publishVersion.mockRejectedValueOnce(new Error('Changelog is required'))
88146
generateUploadUrl.mockResolvedValue('https://upload.local')

src/lib/uploadFiles.jsdom.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { strToU8, unzipSync, zipSync } from 'fflate'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { expandFiles } from './uploadFiles'
5+
6+
function readWithFileReader(blob: Blob) {
7+
return new Promise<ArrayBuffer>((resolve, reject) => {
8+
const reader = new FileReader()
9+
reader.onerror = () => reject(reader.error ?? new Error('Could not read blob.'))
10+
reader.onload = () => resolve(reader.result as ArrayBuffer)
11+
reader.readAsArrayBuffer(blob)
12+
})
13+
}
14+
15+
describe('expandFiles (jsdom)', () => {
16+
it('expands zip archives using FileReader fallback', async () => {
17+
const zip = zipSync({
18+
'hetzner-cloud-skill/SKILL.md': new Uint8Array(strToU8('hello')),
19+
'hetzner-cloud-skill/notes.txt': new Uint8Array(strToU8('notes')),
20+
})
21+
const zipBytes = zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength)
22+
const zipFile = new File([zipBytes], 'bundle.zip', { type: 'application/zip' })
23+
24+
const readerBuffer = await readWithFileReader(zipFile)
25+
const entries = unzipSync(new Uint8Array(readerBuffer))
26+
expect(Object.keys(entries)).toEqual(
27+
expect.arrayContaining(['hetzner-cloud-skill/SKILL.md', 'hetzner-cloud-skill/notes.txt']),
28+
)
29+
30+
const expanded = await expandFiles([zipFile])
31+
expect(expanded.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
32+
})
33+
})

src/lib/uploadFiles.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ describe('expandFiles', () => {
7171
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
7272
})
7373

74+
it('unwraps top-level folders in zip archives', async () => {
75+
const zip = zipSync({
76+
'hetzner-cloud-skill/SKILL.md': strToU8('hello'),
77+
'hetzner-cloud-skill/docs/readme.txt': strToU8('doc'),
78+
'__MACOSX/._SKILL.md': strToU8('junk'),
79+
'hetzner-cloud-skill/.DS_Store': strToU8('junk2'),
80+
'hetzner-cloud-skill/screenshot.png': strToU8('not-really-a-png'),
81+
})
82+
const zipFile = new File([zip.buffer], 'pack.zip', { type: 'application/zip' })
83+
const result = await expandFiles([zipFile])
84+
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
85+
const png = result.find((file) => file.name.endsWith('.png'))
86+
expect(png).toBeUndefined()
87+
})
88+
7489
it('expands gzipped tar archives into files', async () => {
7590
const tar = buildTar([
7691
{ name: 'SKILL.md', content: 'hi' },
@@ -82,6 +97,17 @@ describe('expandFiles', () => {
8297
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
8398
})
8499

100+
it('unwraps top-level folders in tar.gz archives', async () => {
101+
const tar = buildTar([
102+
{ name: 'skill-folder/SKILL.md', content: 'hi' },
103+
{ name: 'skill-folder/notes.txt', content: 'yo' },
104+
])
105+
const tgz = gzipSync(tar)
106+
const tgzFile = new File([tgz.buffer], 'bundle.tgz', { type: 'application/gzip' })
107+
const result = await expandFiles([tgzFile])
108+
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
109+
})
110+
85111
it('expands .gz single files', async () => {
86112
const gz = gzipSync(strToU8('content'))
87113
const gzFile = new File([gz.buffer], 'skill.md.gz', { type: 'application/gzip' })

src/lib/uploadFiles.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TEXT_FILE_EXTENSION_SET } from 'clawdhub-schema'
12
import { gunzipSync, unzipSync } from 'fflate'
23

34
const TEXT_TYPES = new Map([
@@ -23,53 +24,77 @@ export async function expandFiles(selected: File[]) {
2324
const lower = file.name.toLowerCase()
2425
if (lower.endsWith('.zip')) {
2526
const entries = unzipSync(new Uint8Array(await readArrayBuffer(file)))
26-
for (const [path, data] of Object.entries(entries)) {
27-
if (!path || path.endsWith('/')) continue
28-
expanded.push(
29-
new File([data.buffer], normalizePath(path), {
30-
type: guessContentType(path),
31-
}),
32-
)
33-
}
27+
pushArchiveEntries(
28+
expanded,
29+
Object.entries(entries).map(([path, data]) => ({ path, data })),
30+
)
3431
continue
3532
}
3633
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
3734
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
38-
for (const entry of untar(unpacked)) {
39-
expanded.push(
40-
new File([entry.data.buffer], normalizePath(entry.path), {
41-
type: guessContentType(entry.path),
42-
}),
43-
)
44-
}
35+
pushArchiveEntries(expanded, untar(unpacked))
4536
continue
4637
}
4738
if (lower.endsWith('.gz')) {
4839
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
4940
const name = file.name.replace(/\.gz$/i, '')
50-
expanded.push(new File([unpacked.buffer], name, { type: guessContentType(name) }))
41+
expanded.push(new File([unpacked], name, { type: guessContentType(name) }))
5142
continue
5243
}
5344
expanded.push(file)
5445
}
5546
return expanded
5647
}
5748

49+
function pushArchiveEntries(target: File[], entries: Array<{ path: string; data: Uint8Array }>) {
50+
const normalized = entries
51+
.map((entry) => ({ ...entry, path: normalizePath(entry.path) }))
52+
.filter((entry) => entry.path && !entry.path.endsWith('/'))
53+
.filter((entry) => !isJunkPath(entry.path))
54+
.filter((entry) => isTextPath(entry.path))
55+
56+
const unwrapped = unwrapSingleTopLevelFolder(normalized)
57+
58+
for (const entry of unwrapped) {
59+
target.push(
60+
new File([entry.data], entry.path, {
61+
type: guessContentType(entry.path),
62+
}),
63+
)
64+
}
65+
}
66+
5867
async function readArrayBuffer(file: Blob) {
5968
if (typeof file.arrayBuffer === 'function') {
6069
return file.arrayBuffer()
6170
}
62-
return new Response(file).arrayBuffer()
71+
if (typeof FileReader !== 'undefined') {
72+
return new Promise<ArrayBuffer>((resolve, reject) => {
73+
const reader = new FileReader()
74+
reader.onerror = () => reject(reader.error ?? new Error('Could not read file.'))
75+
reader.onload = () => resolve(reader.result as ArrayBuffer)
76+
reader.readAsArrayBuffer(file)
77+
})
78+
}
79+
return new Response(file as BodyInit).arrayBuffer()
6380
}
6481

6582
function guessContentType(path: string) {
6683
const ext = path.split('.').pop()?.toLowerCase()
67-
if (!ext) return 'text/plain'
68-
return TEXT_TYPES.get(ext) ?? 'text/plain'
84+
if (!ext) return 'application/octet-stream'
85+
const known = TEXT_TYPES.get(ext)
86+
if (known) return known
87+
if (TEXT_FILE_EXTENSION_SET.has(ext)) return 'text/plain'
88+
return 'application/octet-stream'
6989
}
7090

7191
function normalizePath(path: string) {
72-
return path.replace(/^\.\/+/, '').replace(/^\/+/, '')
92+
return path
93+
.replaceAll('\u0000', '')
94+
.replaceAll('\\', '/')
95+
.trim()
96+
.replace(/^\.\/+/, '')
97+
.replace(/^\/+/, '')
7398
}
7499

75100
function untar(bytes: Uint8Array) {
@@ -100,3 +125,35 @@ function readOctal(bytes: Uint8Array) {
100125
const raw = readString(bytes)
101126
return raw ? Number.parseInt(raw, 8) : 0
102127
}
128+
129+
function unwrapSingleTopLevelFolder<T extends { path: string }>(entries: T[]) {
130+
if (entries.length === 0) return entries
131+
132+
const segments = entries.map((entry) => entry.path.split('/').filter(Boolean))
133+
if (segments.some((parts) => parts.length < 2)) return entries
134+
135+
const first = segments[0]?.[0]
136+
if (!first) return entries
137+
if (!segments.every((parts) => parts[0] === first)) return entries
138+
139+
return entries.map((entry) => ({
140+
...entry,
141+
path: entry.path.split('/').slice(1).join('/'),
142+
}))
143+
}
144+
145+
function isJunkPath(path: string) {
146+
const normalized = path.toLowerCase()
147+
if (normalized.startsWith('__macosx/')) return true
148+
if (normalized.endsWith('/.ds_store')) return true
149+
if (normalized === '.ds_store') return true
150+
return false
151+
}
152+
153+
function isTextPath(path: string) {
154+
const normalized = path.trim().toLowerCase()
155+
const parts = normalized.split('.')
156+
const extension = parts.length > 1 ? (parts.at(-1) ?? '') : ''
157+
if (!extension) return false
158+
return TEXT_FILE_EXTENSION_SET.has(extension)
159+
}

src/routes/upload.tsx

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createFileRoute, useNavigate } from '@tanstack/react-router'
2-
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
2+
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'clawdhub-schema'
3+
import { useAction, useConvexAuth, useMutation } from 'convex/react'
34
import { useEffect, useMemo, useRef, useState } from 'react'
45
import semver from 'semver'
56
import { api } from '../../convex/_generated/api'
@@ -41,8 +42,6 @@ export function Upload() {
4142
const trimmedSlug = slug.trim()
4243
const trimmedName = displayName.trim()
4344
const trimmedChangelog = changelog.trim()
44-
const lookupSlug = trimmedSlug && SLUG_PATTERN.test(trimmedSlug) ? trimmedSlug : ''
45-
const existingSkill = useQuery(api.skills.getBySlug, lookupSlug ? { slug: lookupSlug } : 'skip')
4645
const parsedTags = useMemo(
4746
() =>
4847
tags
@@ -67,33 +66,29 @@ export function Upload() {
6766
if (parsedTags.length === 0) {
6867
issues.push('At least one tag is required.')
6968
}
70-
if (existingSkill && !trimmedChangelog) {
71-
issues.push('Changelog is required for updates.')
72-
}
7369
if (files.length === 0) {
7470
issues.push('Add at least one file.')
7571
}
7672
if (!hasSkillFile) {
7773
issues.push('SKILL.md is required.')
7874
}
75+
const invalidFiles = files.filter((file) => !isTextFile(file))
76+
if (invalidFiles.length > 0) {
77+
issues.push(
78+
`Remove non-text files: ${invalidFiles
79+
.slice(0, 3)
80+
.map((file) => file.name)
81+
.join(', ')}`,
82+
)
83+
}
7984
if (totalBytes > maxBytes) {
8085
issues.push('Total file size exceeds 50MB.')
8186
}
8287
return {
8388
issues,
8489
ready: issues.length === 0,
8590
}
86-
}, [
87-
trimmedSlug,
88-
trimmedName,
89-
version,
90-
parsedTags.length,
91-
trimmedChangelog,
92-
existingSkill,
93-
files.length,
94-
hasSkillFile,
95-
totalBytes,
96-
])
91+
}, [trimmedSlug, trimmedName, version, parsedTags.length, files, hasSkillFile, totalBytes])
9792

9893
useEffect(() => {
9994
if (!fileInputRef.current) return
@@ -444,3 +439,13 @@ function formatPublishError(error: unknown) {
444439
}
445440
return 'Publish failed. Please try again.'
446441
}
442+
443+
function isTextFile(file: File) {
444+
const path = (file.webkitRelativePath || file.name).trim().toLowerCase()
445+
if (!path) return false
446+
const parts = path.split('.')
447+
const extension = parts.length > 1 ? (parts.at(-1) ?? '') : ''
448+
if (file.type && isTextContentType(file.type)) return true
449+
if (extension && TEXT_FILE_EXTENSION_SET.has(extension)) return true
450+
return false
451+
}

0 commit comments

Comments
 (0)