This repository was archived by the owner on Apr 11, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathxpi.ts
More file actions
286 lines (258 loc) · 7.29 KB
/
xpi.ts
File metadata and controls
286 lines (258 loc) · 7.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
import type { AxiosProgressEvent } from 'axios'
import crypto from 'node:crypto'
import path from 'node:path'
import AdmZip from 'adm-zip'
import axios from 'axios'
import fs from 'fs-extra'
interface ManifestInfo {
id?: string
name?: string
version?: string
browser_specific_settings?: {
gecko?: {
id?: string
}
}
applications?: {
zotero?: {
id?: string
}
gecko?: {
id?: string
}
}
}
export interface XpiMetadata {
version: string
url: string
hash?: string
manifestId?: string
downloadedAt?: string
}
/**
* Download an XPI file from URL
* @param url The XPI download URL
* @param targetPath The local path to save the XPI
* @param onProgress Optional progress callback
*/
export async function downloadXpi(
url: string,
targetPath: string,
onProgress?: (progress: AxiosProgressEvent) => void,
): Promise<void> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30_000,
onDownloadProgress: onProgress,
})
await fs.ensureDir(path.dirname(targetPath))
await fs.writeFile(targetPath, response.data)
}
/**
* Extract manifest information from an XPI file (Zotero extension)
* XPI files are ZIP archives with manifest.json at root
* Handles both manifest v2 (id field) and v3 (browser_specific_settings.gecko.id)
* @param xpiPath Path to the XPI file
* @returns Manifest info extracted from the XPI
*/
export async function extractXpiInfo(xpiPath: string): Promise<ManifestInfo> {
if (!(await fs.pathExists(xpiPath))) {
throw new Error(`XPI file not found: ${xpiPath}`)
}
try {
const zip = new AdmZip(xpiPath)
const manifestEntry = zip.getEntry('manifest.json')
if (!manifestEntry) {
throw new Error('No manifest.json found in XPI')
}
const manifestContent = zip.readAsText(manifestEntry)
const manifest = JSON.parse(manifestContent) as ManifestInfo
return manifest
}
catch (error) {
throw new Error(
`Failed to extract XPI info: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Extract the effective plugin ID from manifest
* Supports multiple formats:
* - Zotero manifest: applications.zotero.id
* - Manifest v3: browser_specific_settings.gecko.id
* - Manifest v2: id
* @param manifest Manifest object
* @returns Plugin ID or undefined
*/
function getManifestPluginId(manifest: ManifestInfo): string | undefined {
// Try Zotero-specific format first
return (
manifest.applications?.zotero?.id
|| manifest.browser_specific_settings?.gecko?.id
|| manifest.applications?.gecko?.id
|| manifest.id
)
}
/**
* Verify XPI integrity by checking:
* 1. File exists and is readable
* 2. Can be parsed as ZIP
* 3. Contains valid manifest.json
* 4. Plugin ID matches expected ID (supports both manifest formats)
* @param xpiPath Path to the XPI file
* @param expectedPluginId The expected plugin ID
*/
export async function verifyXpi(
xpiPath: string,
expectedPluginId: string,
): Promise<boolean> {
try {
const manifest = await extractXpiInfo(xpiPath)
const manifestId = getManifestPluginId(manifest)
return manifestId === expectedPluginId
}
catch {
return false
}
}
/**
* Calculate SHA256 hash of a file
* @param filePath Path to the file
* @returns Hex-encoded SHA256 hash
*/
export async function calculateFileHash(filePath: string): Promise<string> {
const fileContent = await fs.readFile(filePath)
return crypto
.createHash('sha256')
.update(fileContent)
.digest('hex')
}
/**
* Get the path where XPI should be stored for a plugin
* @param pluginDir Plugin directory (e.g., plugins/<plugin-id>)
* @param version Plugin version (used in filename)
* @returns Path to store the XPI file
*/
export function getXpiCachePath(pluginDir: string, version: string): string {
return path.join(pluginDir, `${version}.xpi`)
}
/**
* Save downloaded XPI to plugin cache directory
* @param sourcePath Temporary path of downloaded XPI
* @param pluginDir Plugin directory
* @param version Plugin version
* @param expectedPluginId Expected plugin ID for validation
* @returns XpiMetadata containing cache info and hash
*/
export async function saveXpiCache(
sourcePath: string,
pluginDir: string,
version: string,
expectedPluginId: string,
): Promise<XpiMetadata> {
// Verify XPI before saving
const isValid = await verifyXpi(sourcePath, expectedPluginId)
if (!isValid) {
throw new Error(
`XPI verification failed: plugin ID mismatch or invalid XPI structure`,
)
}
// Calculate hash
const hash = await calculateFileHash(sourcePath)
// Save to cache
const cachePath = getXpiCachePath(pluginDir, version)
await fs.ensureDir(pluginDir)
await fs.copy(sourcePath, cachePath)
// Extract manifest for additional metadata
const manifest = await extractXpiInfo(sourcePath)
const manifestId = getManifestPluginId(manifest)
return {
version,
url: cachePath,
hash,
manifestId,
downloadedAt: new Date().toISOString(),
}
}
/**
* Check if cached XPI exists and validate it
* @param pluginDir Plugin directory
* @param version Version to check
* @returns true if valid cache exists, false otherwise
*/
export async function validateCachedXpi(
pluginDir: string,
version: string,
): Promise<boolean> {
const cachePath = getXpiCachePath(pluginDir, version)
if (!(await fs.pathExists(cachePath))) {
return false
}
try {
// Try to read and parse as ZIP to ensure integrity
const zip = new AdmZip(cachePath)
const manifestEntry = zip.getEntry('manifest.json')
return manifestEntry !== null
}
catch {
return false
}
}
/**
* List all cached XPI files for a plugin
* @param pluginDir Plugin directory
* @returns Array of version strings that are cached
*/
export async function listCachedXpis(pluginDir: string): Promise<string[]> {
if (!(await fs.pathExists(pluginDir))) {
return []
}
try {
const files = await fs.readdir(pluginDir)
return files
.filter(f => f.endsWith('.xpi'))
.map(f => f.replace('.xpi', ''))
.sort()
}
catch {
return []
}
}
/**
* Clean up old cached XPI files, keeping only the latest N versions
* @param pluginDir Plugin directory
* @param keepCount Number of recent versions to keep (default: 3)
*/
export async function cleanupOldXpiCaches(
pluginDir: string,
keepCount: number = 3,
): Promise<void> {
const cachedVersions = await listCachedXpis(pluginDir)
if (cachedVersions.length > keepCount) {
// Sort by version (newest first) using semver-like comparison
const sorted = cachedVersions.sort((a, b) => {
// Simple version comparison: split by '.', convert to numbers
const aParts = a.split('.').map(p => Number.parseInt(p, 10))
const bParts = b.split('.').map(p => Number.parseInt(p, 10))
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aN = aParts[i] || 0
const bN = bParts[i] || 0
if (aN !== bN) {
return bN - aN // Descending order
}
}
return 0
})
// Remove old versions
const toRemove = sorted.slice(keepCount)
for (const version of toRemove) {
const filePath = getXpiCachePath(pluginDir, version)
try {
await fs.remove(filePath)
}
catch {
// Ignore errors during cleanup
}
}
}
}