@opensaas/stack-storage 0.1.1
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +426 -0
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/dist/config/index.d.ts +19 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +25 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +113 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/fields/index.d.ts +111 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +237 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/local.d.ts +22 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +64 -0
- package/dist/providers/local.js.map +1 -0
- package/dist/runtime/index.d.ts +75 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +157 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/utils/image.d.ts +18 -0
- package/dist/utils/image.d.ts.map +1 -0
- package/dist/utils/image.js +82 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/upload.d.ts +56 -0
- package/dist/utils/upload.d.ts.map +1 -0
- package/dist/utils/upload.js +74 -0
- package/dist/utils/upload.js.map +1 -0
- package/package.json +50 -0
- package/src/config/index.ts +30 -0
- package/src/config/types.ts +127 -0
- package/src/fields/index.ts +347 -0
- package/src/index.ts +14 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/local.ts +85 -0
- package/src/runtime/index.ts +243 -0
- package/src/utils/image.ts +111 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/upload.ts +122 -0
- package/tests/image-utils.test.ts +498 -0
- package/tests/local-provider.test.ts +349 -0
- package/tests/upload-utils.test.ts +313 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
package/CHANGELOG.md
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# @opensaas/stack-storage
|
|
2
|
+
|
|
3
|
+
File and image upload field types with pluggable storage providers.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Provides file and image upload capabilities for OpenSaas Stack with:
|
|
8
|
+
|
|
9
|
+
- Self-contained field types (`file()` and `image()`)
|
|
10
|
+
- Pluggable storage providers (local, S3, Vercel Blob)
|
|
11
|
+
- Automatic image transformations with sharp
|
|
12
|
+
- JSON-backed metadata storage
|
|
13
|
+
- Developer-controlled upload routes
|
|
14
|
+
|
|
15
|
+
## Package Structure
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
packages/storage/
|
|
19
|
+
├── src/
|
|
20
|
+
│ ├── config/ # Storage config types and builders
|
|
21
|
+
│ ├── fields/ # file() and image() field builders
|
|
22
|
+
│ ├── providers/ # LocalStorageProvider
|
|
23
|
+
│ ├── runtime/ # Upload/delete utilities for developers
|
|
24
|
+
│ └── utils/ # Image processing, validation utilities
|
|
25
|
+
└── package.json
|
|
26
|
+
|
|
27
|
+
packages/storage-s3/ # Separate S3 provider package
|
|
28
|
+
packages/storage-vercel/ # Separate Vercel Blob provider package
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Key Exports
|
|
32
|
+
|
|
33
|
+
### Config (`src/config/`)
|
|
34
|
+
|
|
35
|
+
- `localStorage(config)` - Creates local filesystem storage config
|
|
36
|
+
- Types: `StorageProvider`, `StorageConfig`, `FileMetadata`, `ImageMetadata`
|
|
37
|
+
|
|
38
|
+
### Fields (`src/fields/`)
|
|
39
|
+
|
|
40
|
+
- `file(options)` - File upload field builder
|
|
41
|
+
- `image(options)` - Image upload field builder with transformations
|
|
42
|
+
|
|
43
|
+
### Providers (`src/providers/`)
|
|
44
|
+
|
|
45
|
+
- `LocalStorageProvider` - Built-in filesystem storage
|
|
46
|
+
|
|
47
|
+
### Runtime (`src/runtime/`)
|
|
48
|
+
|
|
49
|
+
- `uploadFile(config, provider, data, options)` - Upload file and return metadata
|
|
50
|
+
- `uploadImage(config, provider, data, options)` - Upload image with transformations
|
|
51
|
+
- `deleteFile(config, provider, filename)` - Delete file
|
|
52
|
+
- `deleteImage(config, metadata)` - Delete image and all transformations
|
|
53
|
+
- `createStorageProvider(config, providerName)` - Create provider instance
|
|
54
|
+
|
|
55
|
+
### Utils (`src/utils/`)
|
|
56
|
+
|
|
57
|
+
- `validateFile(file, options)` - Validate file size, MIME type, extensions
|
|
58
|
+
- `formatFileSize(bytes)` - Human-readable file sizes
|
|
59
|
+
- `getMimeType(filename)` - Get MIME type from filename
|
|
60
|
+
- `parseFileFromFormData(formData, fieldName)` - Extract file from FormData
|
|
61
|
+
- `getImageDimensions(buffer)` - Get image width/height
|
|
62
|
+
- `transformImage(buffer, config)` - Apply single transformation
|
|
63
|
+
- `processImageTransformations(buffer, filename, transformations, provider, contentType)` - Process all transformations
|
|
64
|
+
|
|
65
|
+
## Architecture Patterns
|
|
66
|
+
|
|
67
|
+
### Field Self-Containment
|
|
68
|
+
|
|
69
|
+
File and image fields follow the self-contained field pattern:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
export function file(options): FileFieldConfig {
|
|
73
|
+
return {
|
|
74
|
+
type: 'file',
|
|
75
|
+
...options,
|
|
76
|
+
getZodSchema: () => z.object({ filename: z.string(), url: z.string().url(), ... }).nullable(),
|
|
77
|
+
getPrismaType: () => ({ type: 'Json', modifiers: '?' }),
|
|
78
|
+
getTypeScriptType: () => ({ type: 'import("@opensaas/stack-storage").FileMetadata | null', optional: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
No changes to core generators - fields define their own Prisma/TS types.
|
|
84
|
+
|
|
85
|
+
### Storage Provider Interface
|
|
86
|
+
|
|
87
|
+
All storage backends implement `StorageProvider`:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
interface StorageProvider {
|
|
91
|
+
upload(
|
|
92
|
+
file: Buffer | Uint8Array,
|
|
93
|
+
filename: string,
|
|
94
|
+
options?: UploadOptions,
|
|
95
|
+
): Promise<UploadResult>
|
|
96
|
+
download(filename: string): Promise<Buffer>
|
|
97
|
+
delete(filename: string): Promise<void>
|
|
98
|
+
getUrl(filename: string): string
|
|
99
|
+
getSignedUrl?(filename: string, expiresIn?: number): Promise<string> // Optional
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Config-Level Storage
|
|
104
|
+
|
|
105
|
+
Storage config is added to `OpenSaasConfig` (similar to auth pattern):
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
export type OpenSaasConfig = {
|
|
109
|
+
db: DatabaseConfig
|
|
110
|
+
lists: Record<string, ListConfig>
|
|
111
|
+
storage?: StorageConfig // Maps names to provider configs
|
|
112
|
+
// ...
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Named Storage Providers
|
|
117
|
+
|
|
118
|
+
Multiple storage providers can be configured:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
storage: {
|
|
122
|
+
avatars: s3Storage({ bucket: 'avatars', region: 'us-east-1' }),
|
|
123
|
+
documents: localStorage({ uploadDir: './uploads', serveUrl: '/api/files' }),
|
|
124
|
+
videos: vercelBlobStorage({ token: process.env.BLOB_TOKEN }),
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Fields reference providers by name:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
avatar: image({ storage: 'avatars' }),
|
|
132
|
+
resume: file({ storage: 'documents' }),
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### JSON Metadata Storage
|
|
136
|
+
|
|
137
|
+
Files and images store metadata as JSON (leveraging existing `json` field type):
|
|
138
|
+
|
|
139
|
+
**Prisma schema:**
|
|
140
|
+
|
|
141
|
+
```prisma
|
|
142
|
+
model User {
|
|
143
|
+
avatar Json? // ImageMetadata
|
|
144
|
+
resume Json? // FileMetadata
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Runtime types:**
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
user.avatar // ImageMetadata | null
|
|
152
|
+
user.resume // FileMetadata | null
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Automatic Upload via Field Hooks
|
|
156
|
+
|
|
157
|
+
Files are uploaded automatically during form submission via `resolveInput` hooks. **No custom upload API routes are needed.**
|
|
158
|
+
|
|
159
|
+
**How it works:**
|
|
160
|
+
|
|
161
|
+
1. User selects file in UI component
|
|
162
|
+
2. File object stored in form state
|
|
163
|
+
3. Form submitted with File object
|
|
164
|
+
4. Field's `resolveInput` hook uploads file server-side
|
|
165
|
+
5. Returns FileMetadata for database storage
|
|
166
|
+
|
|
167
|
+
**This provides:**
|
|
168
|
+
|
|
169
|
+
1. **Atomic uploads** - files only saved if form submission succeeds
|
|
170
|
+
2. **No orphaned files** - failed submissions don't leave files in storage
|
|
171
|
+
3. **Automatic security** - uploads happen server-side with access control
|
|
172
|
+
4. **Simpler code** - no custom upload routes needed
|
|
173
|
+
|
|
174
|
+
### Image Transformation Pipeline
|
|
175
|
+
|
|
176
|
+
1. Validate file (size, MIME type)
|
|
177
|
+
2. Upload original image to storage
|
|
178
|
+
3. For each transformation:
|
|
179
|
+
- Apply transformation with sharp
|
|
180
|
+
- Upload transformed image
|
|
181
|
+
- Return transformation metadata
|
|
182
|
+
4. Return ImageMetadata with all URLs
|
|
183
|
+
|
|
184
|
+
**Transformations stored with original:**
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
{
|
|
188
|
+
url: "https://bucket.s3.amazonaws.com/original.jpg",
|
|
189
|
+
transformations: {
|
|
190
|
+
thumbnail: { url: "https://bucket.s3.amazonaws.com/original-thumbnail.jpg", width: 100, height: 100 },
|
|
191
|
+
large: { url: "https://bucket.s3.amazonaws.com/original-large.jpg", width: 1200, height: 1200 }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### UI Component Integration
|
|
197
|
+
|
|
198
|
+
File/image fields work in admin UI via component registry:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// packages/ui/src/components/fields/registry.ts
|
|
202
|
+
export const fieldComponentRegistry = {
|
|
203
|
+
file: FileField,
|
|
204
|
+
image: ImageField,
|
|
205
|
+
// ...
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Components accept `File | FileMetadata | null` as values:
|
|
210
|
+
|
|
211
|
+
- New uploads: File object stored in form state
|
|
212
|
+
- Existing files: FileMetadata from database
|
|
213
|
+
- Deleted files: null
|
|
214
|
+
|
|
215
|
+
## Integration Points
|
|
216
|
+
|
|
217
|
+
### With @opensaas/stack-core
|
|
218
|
+
|
|
219
|
+
- `StorageConfig` added to `OpenSaasConfig` type
|
|
220
|
+
- Field builders use `BaseFieldConfig` interface
|
|
221
|
+
- Generators delegate to field methods (no core changes)
|
|
222
|
+
|
|
223
|
+
### With @opensaas/stack-ui
|
|
224
|
+
|
|
225
|
+
- `FileField` component with drag-and-drop
|
|
226
|
+
- `ImageField` component with preview
|
|
227
|
+
- Registered in field component registry
|
|
228
|
+
- Components require `onUpload` prop (developer implements)
|
|
229
|
+
|
|
230
|
+
### With @opensaas/stack-storage-s3
|
|
231
|
+
|
|
232
|
+
- S3StorageProvider implements `StorageProvider`
|
|
233
|
+
- Supports AWS S3 and S3-compatible services (MinIO, Backblaze, etc.)
|
|
234
|
+
- Optional signed URLs for private files
|
|
235
|
+
|
|
236
|
+
### With @opensaas/stack-storage-vercel
|
|
237
|
+
|
|
238
|
+
- VercelBlobStorageProvider implements `StorageProvider`
|
|
239
|
+
- Uses `@vercel/blob` package
|
|
240
|
+
- Optimized for Vercel deployments
|
|
241
|
+
|
|
242
|
+
## Common Patterns
|
|
243
|
+
|
|
244
|
+
### Basic Config
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { config, list } from '@opensaas/stack-core'
|
|
248
|
+
import { localStorage } from '@opensaas/stack-storage'
|
|
249
|
+
import { file, image } from '@opensaas/stack-storage/fields'
|
|
250
|
+
|
|
251
|
+
export default config({
|
|
252
|
+
storage: {
|
|
253
|
+
files: localStorage({
|
|
254
|
+
uploadDir: './public/uploads',
|
|
255
|
+
serveUrl: '/uploads',
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
lists: {
|
|
259
|
+
Post: list({
|
|
260
|
+
fields: {
|
|
261
|
+
coverImage: image({
|
|
262
|
+
storage: 'files',
|
|
263
|
+
transformations: {
|
|
264
|
+
thumbnail: { width: 300, height: 200, fit: 'cover' },
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Multiple Storage Providers
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
storage: {
|
|
277
|
+
avatars: s3Storage({
|
|
278
|
+
bucket: 'user-avatars',
|
|
279
|
+
region: 'us-east-1',
|
|
280
|
+
acl: 'public-read',
|
|
281
|
+
}),
|
|
282
|
+
documents: localStorage({
|
|
283
|
+
uploadDir: './private/documents',
|
|
284
|
+
serveUrl: '/api/files', // Served through auth-protected route
|
|
285
|
+
}),
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Automatic File Cleanup
|
|
290
|
+
|
|
291
|
+
Enable automatic cleanup of files when records are deleted or files are replaced:
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
User: list({
|
|
295
|
+
fields: {
|
|
296
|
+
avatar: image({
|
|
297
|
+
storage: 'avatars',
|
|
298
|
+
cleanupOnDelete: true, // Delete avatar when user deleted
|
|
299
|
+
cleanupOnReplace: true, // Delete old avatar when new one uploaded
|
|
300
|
+
transformations: {
|
|
301
|
+
thumbnail: { width: 100, height: 100 },
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Serving Private Files
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
// app/api/files/[filename]/route.ts
|
|
312
|
+
import { createStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
313
|
+
import config from '@/opensaas.config'
|
|
314
|
+
|
|
315
|
+
export async function GET(request: NextRequest, { params }: { params: { filename: string } }) {
|
|
316
|
+
const session = await getSession()
|
|
317
|
+
if (!session) {
|
|
318
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Get storage provider
|
|
322
|
+
const provider = createStorageProvider(config, 'documents')
|
|
323
|
+
|
|
324
|
+
// Download file
|
|
325
|
+
const buffer = await provider.download(params.filename)
|
|
326
|
+
|
|
327
|
+
// Return file
|
|
328
|
+
return new NextResponse(buffer, {
|
|
329
|
+
headers: {
|
|
330
|
+
'Content-Type': 'application/octet-stream',
|
|
331
|
+
'Content-Disposition': `attachment; filename="${params.filename}"`,
|
|
332
|
+
},
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Image Transformations
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
avatar: image({
|
|
341
|
+
storage: 'avatars',
|
|
342
|
+
transformations: {
|
|
343
|
+
thumbnail: { width: 100, height: 100, fit: 'cover', format: 'webp', quality: 80 },
|
|
344
|
+
small: { width: 200, height: 200, fit: 'cover', format: 'webp' },
|
|
345
|
+
medium: { width: 400, height: 400, fit: 'cover', format: 'webp' },
|
|
346
|
+
large: { width: 800, height: 800, fit: 'inside', format: 'jpeg', quality: 90 },
|
|
347
|
+
},
|
|
348
|
+
validation: {
|
|
349
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
350
|
+
acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Custom Storage Provider
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// lib/cloudflare-r2-storage.ts
|
|
359
|
+
import type { StorageProvider } from '@opensaas/stack-storage'
|
|
360
|
+
|
|
361
|
+
export class CloudflareR2StorageProvider implements StorageProvider {
|
|
362
|
+
async upload(file: Buffer, filename: string, options?) {
|
|
363
|
+
// Upload to Cloudflare R2
|
|
364
|
+
// ...
|
|
365
|
+
return { filename, url, size, contentType }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async download(filename: string) {
|
|
369
|
+
// Download from R2
|
|
370
|
+
// ...
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async delete(filename: string) {
|
|
374
|
+
// Delete from R2
|
|
375
|
+
// ...
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
getUrl(filename: string) {
|
|
379
|
+
return `https://r2.example.com/${filename}`
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Register in runtime
|
|
384
|
+
import { createStorageProvider } from '@opensaas/stack-storage/runtime'
|
|
385
|
+
|
|
386
|
+
// Extend createStorageProvider to support 'cloudflare-r2' type
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Type Safety
|
|
390
|
+
|
|
391
|
+
All types are strongly typed:
|
|
392
|
+
|
|
393
|
+
- `FileMetadata` and `ImageMetadata` for database storage
|
|
394
|
+
- `StorageProvider` interface for custom providers
|
|
395
|
+
- Field configs fully typed with TypeScript
|
|
396
|
+
- Validation options typed with Zod
|
|
397
|
+
|
|
398
|
+
Avoid `any` - all internal utilities use proper types.
|
|
399
|
+
|
|
400
|
+
## Performance Considerations
|
|
401
|
+
|
|
402
|
+
- **Image transformations** happen during upload (one-time cost)
|
|
403
|
+
- **Sharp** is fast but CPU-intensive (consider background jobs for large images)
|
|
404
|
+
- **Separate provider packages** reduce bundle size (only install what you use)
|
|
405
|
+
- **JSON storage** is efficient for metadata (no additional tables)
|
|
406
|
+
- **CDN integration** via custom domains or CloudFront
|
|
407
|
+
|
|
408
|
+
## Security
|
|
409
|
+
|
|
410
|
+
- **Developer-controlled routes** allow custom auth/validation
|
|
411
|
+
- **MIME type validation** prevents file type spoofing
|
|
412
|
+
- **File size limits** prevent DoS attacks
|
|
413
|
+
- **Access control** enforced in upload routes
|
|
414
|
+
- **Signed URLs** for private S3 files (optional)
|
|
415
|
+
- **No direct file access** unless served through developer routes
|
|
416
|
+
|
|
417
|
+
## Future Enhancements
|
|
418
|
+
|
|
419
|
+
Potential additions:
|
|
420
|
+
|
|
421
|
+
- Background job support for large image processing
|
|
422
|
+
- Video/audio field types
|
|
423
|
+
- CDN invalidation hooks
|
|
424
|
+
- Image optimization (compression, format conversion)
|
|
425
|
+
- Cloud provider integrations (Azure Blob, Google Cloud Storage)
|
|
426
|
+
- File virus scanning integration
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OpenSaas Stack Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|