hazo_files 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +21 -0
- package/README.md +185 -0
- package/SETUP_CHECKLIST.md +96 -0
- package/dist/background-upload/react/index.js +2 -1
- package/dist/background-upload/react/index.mjs +2 -1
- package/dist/index.d.mts +223 -10
- package/dist/index.d.ts +223 -10
- package/dist/index.js +406 -169
- package/dist/index.mjs +406 -169
- package/dist/server/index.d.mts +430 -11
- package/dist/server/index.d.ts +430 -11
- package/dist/server/index.js +843 -171
- package/dist/server/index.mjs +832 -171
- package/migrations/004_changed_by.sql +10 -0
- package/migrations/005_source_url.sql +10 -0
- package/migrations/006_quota_tracking.sql +20 -0
- package/package.json +10 -1
package/CHANGE_LOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## 1.6.0 (2026-05-21)
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **R2 Quota tracking**: Per-scope opt-in quota with threshold callbacks (`setQuotaLimit`, `getQuota`, `recomputeQuota`, `onQuotaThreshold`)
|
|
12
|
+
- **R4 URL import**: `importFromUrl(url, path, opts)` with SSRF protection via `hazo_secure`, 50MB default cap, `source_url` column
|
|
13
|
+
- **R6 Purge**: `createPurgeJobHandlers(fm)` factory + `HAZO_FILES_JOB_TYPES` constants for `hazo_jobs` integration
|
|
14
|
+
- **Actor tracking**: Optional `actor_id` on all `TrackedFileManager` mutations → `changed_by`/`uploaded_by` columns
|
|
15
|
+
- **Migrations 004-006**: `changed_by`, `source_url` columns, and `hazo_file_quotas` table
|
|
16
|
+
- `HAZO_FILES_MIGRATION_V4` export for programmatic V4 migration
|
|
17
|
+
- `HAZO_FILE_QUOTAS_TABLE_SCHEMA` export for quota table DDL
|
|
18
|
+
- `QuotaService`, `QuotaExceededError`, `SSRFError`, `ImportSizeCapError` exported from `hazo_files/server`
|
|
19
|
+
- test-app: Quota, URL Import, and Lifecycle pages with interactive scenarios
|
|
20
|
+
|
|
21
|
+
### Not breaking
|
|
22
|
+
All additions are opt-in or additive. Existing 1.5.x callers work without changes.
|
|
23
|
+
|
|
24
|
+
## [1.5.2] - 2026-05-18
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- `useFileUpload` no longer emits the React dev warning *"The result of getServerSnapshot should be cached to avoid an infinite loop"*. The third argument to `useSyncExternalStore` now points at a module-level frozen `EMPTY_ACTIVE_JOBS` constant instead of allocating a fresh `[]` per call, so React's reference identity check holds across renders.
|
|
28
|
+
|
|
8
29
|
## 1.5.0
|
|
9
30
|
|
|
10
31
|
- **NEW**: `background_upload` module — UploadManager, Job, PipelineExecutor for pipelines that survive React component unmount.
|
package/README.md
CHANGED
|
@@ -21,6 +21,10 @@ A powerful, modular file management package for Node.js and React applications w
|
|
|
21
21
|
- **Content Tagging**: Optional LLM-based content classification at upload time or on-demand via `content_tag` field
|
|
22
22
|
- **Schema Migrations**: Built-in V2/V3 migration utilities for adding reference tracking and content tagging to existing databases
|
|
23
23
|
- **Background Upload Pipelines**: Framework-agnostic `UploadManager` + React `HazoFileUploadProvider` for multi-step upload pipelines that survive component unmount, with optional sonner toast bridge
|
|
24
|
+
- **Quota Tracking**: Per-scope opt-in quota with threshold callbacks and fail-open semantics
|
|
25
|
+
- **URL Import**: `importFromUrl` with SSRF protection, streaming size cap, and `source_url` provenance tracking
|
|
26
|
+
- **Actor Tracking**: Optional `actor_id` on all mutations, written to `uploaded_by` and `changed_by` columns
|
|
27
|
+
- **Purge Scheduler**: `createPurgeJobHandlers` factory for integrating with hazo_jobs cron workers
|
|
24
28
|
- **TypeScript**: Full type safety and IntelliSense support
|
|
25
29
|
- **OAuth Integration**: Built-in Google Drive and Dropbox OAuth authentication
|
|
26
30
|
- **Prompt Cache Invalidation**: Passthrough for hazo_llm_api prompt cache management via server instance
|
|
@@ -1981,6 +1985,187 @@ Contributions are welcome! Please:
|
|
|
1981
1985
|
4. Add tests for new functionality
|
|
1982
1986
|
5. Submit a pull request
|
|
1983
1987
|
|
|
1988
|
+
## Quota Tracking (v1.6.0)
|
|
1989
|
+
|
|
1990
|
+
Per-scope opt-in quota tracking. A scope with no quota row has no limit — uploads succeed regardless (fail-open).
|
|
1991
|
+
|
|
1992
|
+
### Setup
|
|
1993
|
+
|
|
1994
|
+
Run migration 006 to create the `hazo_file_quotas` table (or use the `HAZO_FILE_QUOTAS_TABLE_SCHEMA` DDL export):
|
|
1995
|
+
|
|
1996
|
+
```bash
|
|
1997
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
Create a CRUD service and pass it to the factory:
|
|
2001
|
+
|
|
2002
|
+
```typescript
|
|
2003
|
+
import { createHazoFilesServer, HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
|
|
2004
|
+
|
|
2005
|
+
const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
|
|
2006
|
+
primaryKeys: ['scope_id'],
|
|
2007
|
+
autoId: { enabled: false },
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
const { fileManager } = await createHazoFilesServer({
|
|
2011
|
+
crudService: fileCrud,
|
|
2012
|
+
quotaCrudService: quotaCrud,
|
|
2013
|
+
onQuotaThreshold: (event) => {
|
|
2014
|
+
console.log(`Quota ${(event.percent * 100).toFixed(0)}% for scope ${event.scopeId}`);
|
|
2015
|
+
},
|
|
2016
|
+
quotaBands: [0.80, 0.95], // default
|
|
2017
|
+
config: { provider: 'local', local: { basePath: './files' } },
|
|
2018
|
+
});
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
### Setting Quota Limits
|
|
2022
|
+
|
|
2023
|
+
```typescript
|
|
2024
|
+
// Set a 10 GB limit for a scope
|
|
2025
|
+
const status = await fileManager.setQuotaLimit('scope-uuid', 10 * 1024 * 1024 * 1024);
|
|
2026
|
+
// { scopeId, byteLimit, byteUsed, percentUsed }
|
|
2027
|
+
|
|
2028
|
+
// Check quota without uploading
|
|
2029
|
+
const quota = await fileManager.getQuota('scope-uuid');
|
|
2030
|
+
if (!quota) {
|
|
2031
|
+
console.log('No quota set — unlimited');
|
|
2032
|
+
}
|
|
2033
|
+
```
|
|
2034
|
+
|
|
2035
|
+
### Uploading with Quota Check
|
|
2036
|
+
|
|
2037
|
+
When `scope_id` is passed to `uploadFile`, the quota is checked before the upload:
|
|
2038
|
+
|
|
2039
|
+
```typescript
|
|
2040
|
+
// Throws QuotaExceededError if byteUsed + fileSize > byteLimit
|
|
2041
|
+
await fileManager.uploadFile(buffer, '/docs/report.pdf', { scope_id: 'scope-uuid' });
|
|
2042
|
+
```
|
|
2043
|
+
|
|
2044
|
+
### Threshold Callbacks
|
|
2045
|
+
|
|
2046
|
+
The `onQuotaThreshold` callback fires for each band crossed in a single operation. Both 80% and 95% can fire from one upload:
|
|
2047
|
+
|
|
2048
|
+
```typescript
|
|
2049
|
+
onQuotaThreshold: (event) => {
|
|
2050
|
+
if (event.percent === 0.80) notifyUser('Storage 80% full');
|
|
2051
|
+
if (event.percent === 0.95) blockUploads('Storage 95% full');
|
|
2052
|
+
}
|
|
2053
|
+
```
|
|
2054
|
+
|
|
2055
|
+
---
|
|
2056
|
+
|
|
2057
|
+
## URL Import (v1.6.0)
|
|
2058
|
+
|
|
2059
|
+
Import a file directly from a URL into virtual storage. Requires the optional peer dependency `hazo_secure`.
|
|
2060
|
+
|
|
2061
|
+
### Installation
|
|
2062
|
+
|
|
2063
|
+
```bash
|
|
2064
|
+
npm install hazo_secure # optional peer dep
|
|
2065
|
+
```
|
|
2066
|
+
|
|
2067
|
+
### Usage
|
|
2068
|
+
|
|
2069
|
+
```typescript
|
|
2070
|
+
const result = await fileManager.importFromUrl(
|
|
2071
|
+
'https://cdn.example.com/documents/report.pdf',
|
|
2072
|
+
'/imports/report.pdf',
|
|
2073
|
+
{
|
|
2074
|
+
maxBytes: 50 * 1024 * 1024, // 50MB cap (default)
|
|
2075
|
+
referrer: 'https://myapp.example.com/dashboard',
|
|
2076
|
+
actor_id: 'user-uuid', // written to uploaded_by + changed_by
|
|
2077
|
+
}
|
|
2078
|
+
);
|
|
2079
|
+
// result.data: { virtualPath, size, sourceUrl }
|
|
2080
|
+
```
|
|
2081
|
+
|
|
2082
|
+
The imported file's `source_url` column is set to the fetch URL for provenance tracking.
|
|
2083
|
+
|
|
2084
|
+
### SSRF Protection
|
|
2085
|
+
|
|
2086
|
+
Configure an allowlist in the server factory to restrict importable domains:
|
|
2087
|
+
|
|
2088
|
+
```typescript
|
|
2089
|
+
const { fileManager } = await createHazoFilesServer({
|
|
2090
|
+
ssrf: {
|
|
2091
|
+
allowlist: ['cdn.example.com', 'assets.myapp.com'],
|
|
2092
|
+
},
|
|
2093
|
+
// ...
|
|
2094
|
+
});
|
|
2095
|
+
```
|
|
2096
|
+
|
|
2097
|
+
Without an allowlist, private IPs (RFC-1918) are always blocked. Blocked requests throw `SSRFError`.
|
|
2098
|
+
|
|
2099
|
+
### Size Cap
|
|
2100
|
+
|
|
2101
|
+
If the response body exceeds `maxBytes`, the fetch is aborted and `ImportSizeCapError` is thrown. No partial file is left in storage.
|
|
2102
|
+
|
|
2103
|
+
---
|
|
2104
|
+
|
|
2105
|
+
## Actor Tracking (v1.6.0)
|
|
2106
|
+
|
|
2107
|
+
All `TrackedFileManager` mutation methods accept an optional `actor_id` parameter:
|
|
2108
|
+
|
|
2109
|
+
```typescript
|
|
2110
|
+
await fileManager.uploadFile(buffer, '/path/file.pdf', { actor_id: 'user-uuid' });
|
|
2111
|
+
await fileManager.moveItem('/old', '/new', { actor_id: 'user-uuid' });
|
|
2112
|
+
await fileManager.renameFile('/old.pdf', 'new.pdf', { actor_id: 'user-uuid' });
|
|
2113
|
+
await fileManager.renameFolder('/old-dir', 'new-dir', { actor_id: 'user-uuid' });
|
|
2114
|
+
await fileManager.deleteFile('/path/file.pdf', { actor_id: 'user-uuid' });
|
|
2115
|
+
await fileManager.softDeleteFile(fileId, { actor_id: 'user-uuid' });
|
|
2116
|
+
```
|
|
2117
|
+
|
|
2118
|
+
When `actor_id` is provided:
|
|
2119
|
+
- `uploaded_by` is set on creation (uploadFile only)
|
|
2120
|
+
- `changed_by` is written on every mutation
|
|
2121
|
+
|
|
2122
|
+
Run migrations 004 and 005 to add these columns to existing databases:
|
|
2123
|
+
|
|
2124
|
+
```bash
|
|
2125
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
|
|
2126
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
|
|
2127
|
+
```
|
|
2128
|
+
|
|
2129
|
+
---
|
|
2130
|
+
|
|
2131
|
+
## Purge via hazo_jobs (v1.6.0)
|
|
2132
|
+
|
|
2133
|
+
`createPurgeJobHandlers` creates handlers compatible with hazo_jobs workers for scheduled hard-deletion of soft-deleted files.
|
|
2134
|
+
|
|
2135
|
+
```typescript
|
|
2136
|
+
import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
|
|
2137
|
+
|
|
2138
|
+
const handlers = createPurgeJobHandlers(fm, {
|
|
2139
|
+
submitJob: (type, payload) => jobs.submit({ type, payload }),
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
// Register with your hazo_jobs worker:
|
|
2143
|
+
worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
|
|
2144
|
+
worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
|
|
2145
|
+
|
|
2146
|
+
// Create schedule via hazo_jobs REST or admin UI:
|
|
2147
|
+
await jobs.schedules.create({
|
|
2148
|
+
name: 'hazo_files_purge',
|
|
2149
|
+
cron: '30 3 * * *',
|
|
2150
|
+
type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
|
|
2151
|
+
payload: { retentionDays: 30 },
|
|
2152
|
+
});
|
|
2153
|
+
```
|
|
2154
|
+
|
|
2155
|
+
**purge_plan**: Finds all `soft_deleted` records older than `retentionDays` and submits individual `purge_one` jobs. Supports `dryRun: true` to return `wouldPurge` list without deleting.
|
|
2156
|
+
|
|
2157
|
+
**purge_one**: Hard-deletes a single file's physical storage and DB record. Idempotent — no-op if record not found.
|
|
2158
|
+
|
|
2159
|
+
**Note**: Quota is decremented at soft-delete time, not at purge time.
|
|
2160
|
+
|
|
2161
|
+
---
|
|
2162
|
+
|
|
2163
|
+
## Image Processing
|
|
2164
|
+
|
|
2165
|
+
Image resizing, thumbnail generation, and EXIF extraction are provided by the companion package `hazo_images`, which depends on `hazo_files` for storage.
|
|
2166
|
+
|
|
2167
|
+
---
|
|
2168
|
+
|
|
1984
2169
|
## Support
|
|
1985
2170
|
|
|
1986
2171
|
- GitHub Issues: [https://github.com/pub12/hazo_files/issues](https://github.com/pub12/hazo_files/issues)
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -1297,6 +1297,102 @@ Run through this final checklist to ensure everything is working:
|
|
|
1297
1297
|
- Verify hazo_files/ui is imported correctly
|
|
1298
1298
|
- Check browser console for errors
|
|
1299
1299
|
|
|
1300
|
+
## Part 9: V4 Setup (1.6.0 Features)
|
|
1301
|
+
|
|
1302
|
+
### 9.1 Run Migrations 004-006
|
|
1303
|
+
|
|
1304
|
+
- [ ] Add actor tracking columns:
|
|
1305
|
+
```bash
|
|
1306
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
|
|
1307
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
|
|
1308
|
+
```
|
|
1309
|
+
*(For SQLite: run the commented-out `ALTER TABLE` statements manually)*
|
|
1310
|
+
|
|
1311
|
+
- [ ] Create quota tracking table:
|
|
1312
|
+
```bash
|
|
1313
|
+
psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
### 9.2 Quota Tracking (optional)
|
|
1317
|
+
|
|
1318
|
+
- [ ] Create CRUD service for `hazo_file_quotas`:
|
|
1319
|
+
```typescript
|
|
1320
|
+
import { HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
|
|
1321
|
+
|
|
1322
|
+
const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
|
|
1323
|
+
primaryKeys: ['scope_id'],
|
|
1324
|
+
autoId: { enabled: false },
|
|
1325
|
+
});
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
- [ ] Pass to server factory:
|
|
1329
|
+
```typescript
|
|
1330
|
+
const { fileManager } = await createHazoFilesServer({
|
|
1331
|
+
quotaCrudService: quotaCrud,
|
|
1332
|
+
onQuotaThreshold: (event) => {
|
|
1333
|
+
console.log(`Scope ${event.scopeId} at ${(event.percent * 100).toFixed(0)}%`);
|
|
1334
|
+
},
|
|
1335
|
+
// ... other options
|
|
1336
|
+
});
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
- [ ] Set quota limits for scopes:
|
|
1340
|
+
```typescript
|
|
1341
|
+
await fileManager.setQuotaLimit(scopeId, 10 * 1024 * 1024 * 1024); // 10 GB
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
### 9.3 URL Import / SSRF Guard (optional)
|
|
1345
|
+
|
|
1346
|
+
- [ ] Install optional peer dependency:
|
|
1347
|
+
```bash
|
|
1348
|
+
npm install hazo_secure
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
- [ ] Configure SSRF allowlist in factory:
|
|
1352
|
+
```typescript
|
|
1353
|
+
const { fileManager } = await createHazoFilesServer({
|
|
1354
|
+
ssrf: { allowlist: ['cdn.example.com', 'assets.myapp.com'] },
|
|
1355
|
+
// ...
|
|
1356
|
+
});
|
|
1357
|
+
```
|
|
1358
|
+
|
|
1359
|
+
- [ ] Use `importFromUrl` in your API route:
|
|
1360
|
+
```typescript
|
|
1361
|
+
const result = await fileManager.importFromUrl(url, '/imports/file.pdf', {
|
|
1362
|
+
actor_id: userId,
|
|
1363
|
+
maxBytes: 50 * 1024 * 1024,
|
|
1364
|
+
});
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
### 9.4 Purge Scheduler via hazo_jobs (optional)
|
|
1368
|
+
|
|
1369
|
+
- [ ] Install hazo_jobs (separate package):
|
|
1370
|
+
```bash
|
|
1371
|
+
npm install hazo_jobs
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
- [ ] Register purge handlers with your worker:
|
|
1375
|
+
```typescript
|
|
1376
|
+
import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
|
|
1377
|
+
|
|
1378
|
+
const handlers = createPurgeJobHandlers(fm, {
|
|
1379
|
+
submitJob: (type, payload) => jobs.submit({ type, payload }),
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
|
|
1383
|
+
worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
|
|
1384
|
+
```
|
|
1385
|
+
|
|
1386
|
+
- [ ] Create recurring schedule:
|
|
1387
|
+
```typescript
|
|
1388
|
+
await jobs.schedules.create({
|
|
1389
|
+
name: 'hazo_files_purge',
|
|
1390
|
+
cron: '30 3 * * *', // 3:30 AM daily
|
|
1391
|
+
type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
|
|
1392
|
+
payload: { retentionDays: 30 },
|
|
1393
|
+
});
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1300
1396
|
## Next Steps
|
|
1301
1397
|
|
|
1302
1398
|
- [ ] Review [README.md](README.md) for advanced usage examples
|
|
@@ -391,6 +391,7 @@ function HazoFileUploadProvider({
|
|
|
391
391
|
|
|
392
392
|
// src/background_upload/react/use-file-upload.ts
|
|
393
393
|
var import_react4 = require("react");
|
|
394
|
+
var EMPTY_ACTIVE_JOBS = Object.freeze([]);
|
|
394
395
|
function useFileUpload() {
|
|
395
396
|
const manager = (0, import_react4.useContext)(FileUploadContext);
|
|
396
397
|
if (!manager) {
|
|
@@ -417,7 +418,7 @@ function useFileUpload() {
|
|
|
417
418
|
snapshot_ref.current = next;
|
|
418
419
|
return next;
|
|
419
420
|
},
|
|
420
|
-
() =>
|
|
421
|
+
() => EMPTY_ACTIVE_JOBS
|
|
421
422
|
);
|
|
422
423
|
const submit_batch = (0, import_react4.useCallback)(
|
|
423
424
|
(options) => manager.submit_batch(options),
|
|
@@ -351,6 +351,7 @@ function HazoFileUploadProvider({
|
|
|
351
351
|
|
|
352
352
|
// src/background_upload/react/use-file-upload.ts
|
|
353
353
|
import { useContext, useCallback, useRef as useRef2, useSyncExternalStore } from "react";
|
|
354
|
+
var EMPTY_ACTIVE_JOBS = Object.freeze([]);
|
|
354
355
|
function useFileUpload() {
|
|
355
356
|
const manager = useContext(FileUploadContext);
|
|
356
357
|
if (!manager) {
|
|
@@ -377,7 +378,7 @@ function useFileUpload() {
|
|
|
377
378
|
snapshot_ref.current = next;
|
|
378
379
|
return next;
|
|
379
380
|
},
|
|
380
|
-
() =>
|
|
381
|
+
() => EMPTY_ACTIVE_JOBS
|
|
381
382
|
);
|
|
382
383
|
const submit_batch = useCallback(
|
|
383
384
|
(options) => manager.submit_batch(options),
|