├── .vscode └── settings.json ├── .npmrc ├── test-studio ├── .gitignore ├── sanity.cli.js ├── .env.example ├── CHANGELOG.md ├── package.json ├── schemas │ └── index.js └── sanity.config.js ├── pnpm-workspace.yaml ├── screenshots.png ├── .prettierignore ├── packages ├── cloudflare-r2 │ ├── CHANGELOG.md │ ├── worker │ │ ├── README.md │ │ ├── wrangler.toml │ │ ├── package.json │ │ └── index.ts │ ├── .npmignore │ ├── clearLib.js │ ├── tsconfig.json │ ├── src │ │ ├── deleteFile.ts │ │ ├── schema.config.ts │ │ ├── uploadFile.ts │ │ └── index.tsx │ ├── package.json │ └── README.md ├── aws │ ├── .npmignore │ ├── clearLib.js │ ├── s3Cors.example.json │ ├── tsconfig.json │ ├── src │ │ ├── deleteFile.ts │ │ ├── schema.config.ts │ │ ├── index.tsx │ │ └── uploadFile.ts │ ├── CHANGELOG.md │ ├── package.json │ ├── lambda.example.mjs │ └── README.md ├── firebase │ ├── .npmignore │ ├── clearLib.js │ ├── tsconfig.json │ ├── src │ │ ├── getFirebaseClient.ts │ │ ├── deleteFile.ts │ │ ├── schema.config.ts │ │ ├── uploadFile.ts │ │ └── index.tsx │ ├── CHANGELOG.md │ ├── README.md │ └── package.json ├── digital-ocean │ ├── .npmignore │ ├── clearLib.js │ ├── tsconfig.json │ ├── src │ │ ├── deleteFile.ts │ │ ├── schema.config.ts │ │ ├── uploadFile.ts │ │ └── index.tsx │ ├── CHANGELOG.md │ ├── package.json │ ├── deleteObject.example.js │ ├── getSignedUrl.example.js │ └── README.md └── core │ ├── .npmignore │ ├── src │ ├── scripts │ │ ├── sanityClient.ts │ │ ├── formatBytes.ts │ │ ├── getBasicMetadata.ts │ │ ├── formatSeconds.ts │ │ ├── getFileRef.ts │ │ └── getWaveformData.ts │ ├── components │ │ ├── documentPreview │ │ │ ├── paneItemTypes.ts │ │ │ ├── TimeAgo.tsx │ │ │ ├── MissingSchemaType.tsx │ │ │ ├── DraftStatus.tsx │ │ │ ├── PublishedStatus.tsx │ │ │ ├── PaneItemPreview.tsx │ │ │ └── DocumentPreview.tsx │ │ ├── SpinnerBox.tsx │ │ ├── StudioTool.tsx │ │ ├── VideoIcon.tsx │ │ ├── AudioIcon.tsx │ │ ├── IconInfo.tsx │ │ ├── ToolIcon.tsx │ │ ├── WaveformDisplay.tsx │ │ ├── FormField.tsx │ │ ├── Uploader │ │ │ ├── UploaderWithConfig.tsx │ │ │ ├── Uploader.tsx │ │ │ ├── useUpload.tsx │ │ │ └── UploadBox.tsx │ │ ├── CopyButton.tsx │ │ ├── Browser │ │ │ ├── FileReferences.tsx │ │ │ ├── FilePreview.tsx │ │ │ ├── browserMachine.ts │ │ │ ├── fileDetailsMachine.ts │ │ │ └── Browser.tsx │ │ ├── FileMetadata.tsx │ │ ├── CreateInput.tsx │ │ ├── Credentials │ │ │ ├── CredentialsProvider.tsx │ │ │ └── ConfigureCredentials.tsx │ │ └── MediaPreview.tsx │ ├── schemas │ │ ├── getDimensionsSchema.ts │ │ ├── getCustomDataSchema.ts │ │ └── getStoredFileSchema.ts │ ├── index.ts │ └── types.ts │ ├── clearLib.js │ ├── tsconfig.json │ ├── CHANGELOG.md │ └── package.json ├── .prettierrc ├── .gitignore ├── turbo.json ├── .changeset ├── config.json └── README.md ├── test-server ├── package.json ├── index.js ├── do.deleteObject.js └── do.getSignedUrl.js ├── package.json └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | link-workspace-packages=true -------------------------------------------------------------------------------- /test-studio/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .sanity 4 | .env -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'test-studio' 4 | -------------------------------------------------------------------------------- /screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hdoro/sanity-plugin-external-files/HEAD/screenshots.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | **/lib 4 | .yarn 5 | .git 6 | .pnp.js 7 | .yarnrc.yml -------------------------------------------------------------------------------- /packages/cloudflare-r2/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-r2-files 2 | 3 | ## 1.0.0 4 | 5 | Initial release 6 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/worker/README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Worker 2 | 3 | ```bash 4 | wrangler deploy 5 | ``` 6 | -------------------------------------------------------------------------------- /packages/aws/.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .yarn 3 | src/* 4 | clearLib.js 5 | .prettierignore 6 | .prettierrc 7 | tsconfig.json 8 | node_modules -------------------------------------------------------------------------------- /packages/cloudflare-r2/.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .yarn 3 | src/* 4 | clear.js 5 | .prettierignore 6 | .prettierrc 7 | tsconfig.json 8 | node_modules -------------------------------------------------------------------------------- /packages/firebase/.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .yarn 3 | src/* 4 | clearLib.js 5 | .prettierignore 6 | .prettierrc 7 | tsconfig.json 8 | node_modules -------------------------------------------------------------------------------- /packages/digital-ocean/.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .yarn 3 | src/* 4 | clearLib.js 5 | .prettierignore 6 | .prettierrc 7 | tsconfig.json 8 | node_modules -------------------------------------------------------------------------------- /packages/core/.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .yarn 3 | src/* 4 | !src/types.ts 5 | clearLib.js 6 | .prettierignore 7 | .prettierrc 8 | tsconfig.json 9 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "jsxBracketSameLine": false, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/scripts/sanityClient.ts: -------------------------------------------------------------------------------- 1 | import { useClient } from 'sanity' 2 | 3 | export const useSanityClient = () => useClient({ apiVersion: '2023-07-05' }) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | lib 3 | node_modules 4 | .yarn/cache 5 | .yarn/unplugged 6 | .yarn/build-state.yml 7 | .yarn/install-state.gz 8 | .pnp.js 9 | **/*.lerna_backup 10 | lerna-debug.log 11 | .DS_STORE 12 | .turbo 13 | .env 14 | .wrangler -------------------------------------------------------------------------------- /test-studio/sanity.cli.js: -------------------------------------------------------------------------------- 1 | import {defineCliConfig} from 'sanity/cli' 2 | 3 | export default defineCliConfig({ 4 | api: { 5 | projectId: process.env.SANITY_STUDIO_PROJECT_ID, 6 | dataset: process.env.SANITY_STUDIO_DATASET, 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["lib/**"] 7 | }, 8 | "dev": { 9 | "cache": false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "sanity-r2-worker" 2 | main = "index.ts" 3 | compatibility_date = "2024-08-24" 4 | 5 | [[r2_buckets]] 6 | binding = "R2_BUCKET" 7 | bucket_name = "sanity-media" 8 | 9 | [vars] 10 | ALLOWED_ORIGINS = ["http://localhost:3333", "https://sanity-studio.com"] -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/paneItemTypes.ts: -------------------------------------------------------------------------------- 1 | import type { PreviewValue, SanityDocument } from '@sanity/types' 2 | 3 | export interface PaneItemPreviewState { 4 | isLoading?: boolean 5 | draft?: PreviewValue | Partial | null 6 | published?: PreviewValue | Partial | null 7 | } 8 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/aws/clearLib.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // directory path 4 | const dir = 'build' 5 | 6 | // delete directory recursively 7 | try { 8 | fs.rmSync(dir, { recursive: true }) 9 | 10 | console.log(`${dir} is deleted, ready for build.`) 11 | } catch (err) { 12 | console.error(`Error while deleting ${dir}.`) 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/clearLib.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // directory path 4 | const dir = 'build' 5 | 6 | // delete directory recursively 7 | try { 8 | fs.rmSync(dir, { recursive: true }) 9 | 10 | console.log(`${dir} is deleted, ready for build.`) 11 | } catch (err) { 12 | console.error(`Error while deleting ${dir}.`) 13 | } 14 | -------------------------------------------------------------------------------- /packages/firebase/clearLib.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // directory path 4 | const dir = 'build' 5 | 6 | // delete directory recursively 7 | try { 8 | fs.rmSync(dir, { recursive: true }) 9 | 10 | console.log(`${dir} is deleted, ready for build.`) 11 | } catch (err) { 12 | console.error(`Error while deleting ${dir}.`) 13 | } 14 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/clearLib.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // directory path 4 | const dir = 'build' 5 | 6 | // delete directory recursively 7 | try { 8 | fs.rmSync(dir, { recursive: true }) 9 | 10 | console.log(`${dir} is deleted, ready for build.`) 11 | } catch (err) { 12 | console.error(`Error while deleting ${dir}.`) 13 | } 14 | -------------------------------------------------------------------------------- /packages/digital-ocean/clearLib.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | // directory path 4 | const dir = 'build' 5 | 6 | // delete directory recursively 7 | try { 8 | fs.rmSync(dir, { recursive: true }) 9 | 10 | console.log(`${dir} is deleted, ready for build.`) 11 | } catch (err) { 12 | console.error(`Error while deleting ${dir}.`) 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/components/SpinnerBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Spinner } from '@sanity/ui' 2 | import React from 'react' 3 | 4 | const SpinnerBox: React.FC = () => ( 5 | 13 | 14 | 15 | ) 16 | 17 | export default SpinnerBox 18 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/TimeAgo.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/TimeAgo.tsx 2 | import React from 'react' 3 | import { useTimeAgo } from 'sanity' 4 | 5 | export interface TimeAgoProps { 6 | time: string | Date 7 | } 8 | 9 | export function TimeAgo({ time }: TimeAgoProps) { 10 | const timeAgo = useTimeAgo(time) 11 | 12 | return {timeAgo} ago 13 | } 14 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-r2-worker", 3 | "version": "0.0.1", 4 | "main": "dist/index.js", 5 | "devDependencies": { 6 | "@cloudflare/workers-types": "^4.20240314.0", 7 | "esbuild": "^0.20.2", 8 | "fast-xml-parser": "^4.3.6", 9 | "typescript": "^5.4.2", 10 | "wrangler": "3.22.1" 11 | }, 12 | "private": true, 13 | "scripts": { 14 | "dev": "wrangler dev", 15 | "deploy": "wrangler deploy" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@aws-sdk/client-s3": "^3.367.0", 14 | "@aws-sdk/s3-presigned-post": "^3.367.0", 15 | "aws-sdk": "^2.1412.0", 16 | "dotenv": "^16.3.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/components/StudioTool.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { VendorConfiguration } from '../types' 3 | import Browser from './Browser/Browser' 4 | import CredentialsProvider from './Credentials/CredentialsProvider' 5 | 6 | const StudioTool: React.FC = (props) => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default StudioTool 15 | -------------------------------------------------------------------------------- /packages/aws/s3Cors.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "AllowedHeaders": ["*"], 4 | "AllowedMethods": ["POST", "PUT"], 5 | "AllowedOrigins": ["http://localhost:3333", "https://your-live-studio"], 6 | "ExposeHeaders": [] 7 | }, 8 | { 9 | "AllowedHeaders": [], 10 | "AllowedMethods": ["GET"], 11 | "AllowedOrigins": [ 12 | "http://localhost:3333", 13 | "https://your-live-studio", 14 | "https://your-site.com" 15 | ], 16 | "ExposeHeaders": [] 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /packages/core/src/scripts/formatBytes.ts: -------------------------------------------------------------------------------- 1 | // From: https://stackoverflow.com/a/18650828/10433647 2 | export default function formatBytes(bytes: number, decimals = 0): string { 3 | if (bytes === 0) return '0 Bytes' 4 | 5 | const k = 1024 6 | const dm = decimals < 0 ? 0 : decimals 7 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 8 | 9 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 10 | 11 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] 12 | } 13 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/core/src/schemas/getDimensionsSchema.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from '../types' 2 | 3 | const getDimensionsSchema = (vendorConfig: VendorConfiguration) => ({ 4 | name: `${vendorConfig.schemaPrefix}.dimensions`, 5 | title: `${vendorConfig.toolTitle || vendorConfig.schemaPrefix} dimensions`, 6 | type: 'object', 7 | fields: [ 8 | { 9 | name: 'width', 10 | type: 'number', 11 | }, 12 | { 13 | name: 'height', 14 | type: 'number', 15 | }, 16 | ], 17 | }) 18 | 19 | export default getDimensionsSchema 20 | -------------------------------------------------------------------------------- /packages/core/src/components/VideoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const VideoIcon: React.FC<{ style?: React.CSSProperties }> = (props) => ( 4 | 14 | 15 | 16 | 17 | ) 18 | 19 | export default VideoIcon 20 | -------------------------------------------------------------------------------- /packages/core/src/components/AudioIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const AudioIcon: React.FC<{ style?: React.CSSProperties }> = (props) => ( 4 | 14 | 15 | 16 | 17 | ) 18 | 19 | export default AudioIcon 20 | -------------------------------------------------------------------------------- /packages/core/src/scripts/getBasicMetadata.ts: -------------------------------------------------------------------------------- 1 | import mime from 'mime' 2 | import type { SanityUpload } from '../types' 3 | import getFileRef, { type GetFileRefProps } from './getFileRef' 4 | 5 | export function parseExtension(extension: string) { 6 | return mime.getType(extension) || extension 7 | } 8 | 9 | export default function getBasicFileMetadata( 10 | props: GetFileRefProps, 11 | ): Pick { 12 | return { 13 | fileSize: props.file.size, 14 | contentType: parseExtension(props.file.type), 15 | fileName: getFileRef(props), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/components/IconInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, Inline, Flex } from '@sanity/ui' 3 | 4 | const IconInfo: React.FC<{ 5 | text: string 6 | icon: React.FC 7 | size?: number 8 | muted?: boolean 9 | }> = (props) => { 10 | const Icon = props.icon 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | {props.text} 18 | 19 | 20 | ) 21 | } 22 | 23 | export default IconInfo 24 | -------------------------------------------------------------------------------- /packages/core/src/components/ToolIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Icon of a monitor with a play button. 5 | * Credits: material design icons & react-icons 6 | */ 7 | const ToolIcon = () => ( 8 | 17 | 18 | 19 | ) 20 | 21 | export default ToolIcon 22 | -------------------------------------------------------------------------------- /packages/aws/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "esnext"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES6", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "build", 18 | "declaration": true, 19 | "noImplicitAny": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "esnext"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES6", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "build", 18 | "declaration": true, 19 | "noImplicitAny": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/firebase/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "esnext"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES6", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "build", 18 | "declaration": true, 19 | "noImplicitAny": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test-studio/.env.example: -------------------------------------------------------------------------------- 1 | # Sanity Studio 2 | SANITY_STUDIO_PROJECT_ID="" 3 | SANITY_STUDIO_DATASET="" 4 | 5 | # S3 Bucket 6 | SANITY_STUDIO_S3_BUCKET_KEY="" 7 | SANITY_STUDIO_S3_BUCKET_REGION="" 8 | SANITY_STUDIO_S3_SIGNED_URL_ENDPOINT="" 9 | SANITY_STUDIO_S3_DELETE_OBJECT_ENDPOINT="" 10 | SANITY_STUDIO_S3_UPLOAD_FOLDER="" 11 | 12 | # DigitalOcean Spaces 13 | SANITY_STUDIO_DO_BUCKET_KEY="" 14 | SANITY_STUDIO_DO_BUCKET_REGION="" 15 | SANITY_STUDIO_DO_SIGNED_URL_ENDPOINT="" 16 | SANITY_STUDIO_DO_DELETE_OBJECT_ENDPOINT="" 17 | SANITY_STUDIO_DO_UPLOAD_FOLDER="" 18 | 19 | # Cloudflare R2 Bucket 20 | SANITY_STUDIO_CF_URL="" 21 | SANITY_STUDIO_CF_WORKER_URL="" -------------------------------------------------------------------------------- /packages/cloudflare-r2/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "esnext"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES6", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "build", 18 | "declaration": true, 19 | "noImplicitAny": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/digital-ocean/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.tsx"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "esnext"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "module": "CommonJS", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES6", 14 | "strict": true, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "build", 18 | "declaration": true, 19 | "noImplicitAny": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-external-files 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Fix: account for new Sanity version that broke useMemoObservable, used in document previews 8 | 9 | ## 1.0.1 10 | 11 | ### Patch Changes 12 | 13 | - Chore: update dependencies 14 | 15 | ## 1.0.0 16 | 17 | ### Major Changes 18 | 19 | - 5c598ac: Release Sanity v3 compatibility 20 | 21 | ### Patch Changes 22 | 23 | - 68e2782: fix: proper version resolution 24 | 25 | ## 1.0.0-next.1 26 | 27 | ### Patch Changes 28 | 29 | - fix: proper version resolution 30 | 31 | ## 1.0.0-next.0 32 | 33 | ### Major Changes 34 | 35 | - Release Sanity v3 compatibility 36 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import createInput from './components/CreateInput' 2 | import StudioTool from './components/StudioTool' 3 | import ToolIcon from './components/ToolIcon' 4 | import getStoredFileSchema from './schemas/getStoredFileSchema' 5 | import getDimensionsSchema from './schemas/getDimensionsSchema' 6 | import getCustomDataSchema from './schemas/getCustomDataSchema' 7 | import type { VendorConfiguration, SchemaConfigOptions } from './types' 8 | 9 | export { 10 | createInput, 11 | StudioTool, 12 | ToolIcon, 13 | getStoredFileSchema, 14 | getDimensionsSchema, 15 | getCustomDataSchema, 16 | VendorConfiguration, 17 | SchemaConfigOptions, 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/scripts/formatSeconds.ts: -------------------------------------------------------------------------------- 1 | // From: https://stackoverflow.com/a/11486026/10433647 2 | export default function formatSeconds(seconds: number): string { 3 | if (typeof seconds !== 'number' || Number.isNaN(seconds)) { 4 | return '' 5 | } 6 | // Hours, minutes and seconds 7 | const hrs = ~~(seconds / 3600) 8 | const mins = ~~((seconds % 3600) / 60) 9 | const secs = ~~seconds % 60 10 | 11 | // Output like "1:01" or "4:03:59" or "123:03:59" 12 | let ret = '' 13 | 14 | if (hrs > 0) { 15 | ret += '' + hrs + ':' + (mins < 10 ? '0' : '') 16 | } 17 | 18 | ret += '' + mins + ':' + (secs < 10 ? '0' : '') 19 | ret += '' + secs 20 | return ret 21 | } 22 | -------------------------------------------------------------------------------- /packages/firebase/src/getFirebaseClient.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | import 'firebase/storage' 3 | 4 | export interface FirebaseCredentials { 5 | apiKey: string 6 | storageBucket: string 7 | } 8 | 9 | function getFirebaseClient({ apiKey, storageBucket }: FirebaseCredentials) { 10 | const appName = `${apiKey}-${storageBucket}` 11 | try { 12 | firebase.initializeApp( 13 | { 14 | apiKey, 15 | storageBucket, 16 | }, 17 | appName, 18 | ) 19 | } catch (error) { 20 | // console.info('Skipped Firebase initialization error - already initialized') 21 | } 22 | 23 | return firebase.app(appName) 24 | } 25 | 26 | export default getFirebaseClient 27 | -------------------------------------------------------------------------------- /test-studio/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # test-studio 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Fix: account for new Sanity version that broke useMemoObservable, used in document previews 8 | - Updated dependencies 9 | - sanity-plugin-r2-files@0.1.0 10 | - sanity-plugin-digital-ocean-files@1.0.2 11 | - sanity-plugin-external-files@1.0.2 12 | - sanity-plugin-s3-files@1.0.2 13 | 14 | ## 1.0.2 15 | 16 | ### Patch Changes 17 | 18 | - Updated dependencies 19 | - sanity-plugin-digital-ocean-files@1.0.1 20 | - sanity-plugin-external-files@1.0.1 21 | - sanity-plugin-s3-files@1.0.1 22 | 23 | ## 1.0.1 24 | 25 | ### Patch Changes 26 | 27 | - Updated dependencies [5c598ac] 28 | - Updated dependencies [68e2782] 29 | - sanity-plugin-digital-ocean-files@1.0.0 30 | -------------------------------------------------------------------------------- /packages/firebase/src/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import getFirebaseClient, { FirebaseCredentials } from './getFirebaseClient' 3 | 4 | const deleteFile: VendorConfiguration['deleteFile'] = async ({ 5 | storedFile, 6 | credentials, 7 | }) => { 8 | try { 9 | const firebaseClient = getFirebaseClient(credentials as FirebaseCredentials) 10 | 11 | await firebaseClient.storage().ref(storedFile.firebase?.fullPath).delete() 12 | 13 | return true 14 | } catch (error: any) { 15 | if (error?.code === 'storage/object-not-found') { 16 | // If file not found in Firebase, we're good! 17 | return true 18 | } 19 | 20 | return 'Error' 21 | } 22 | } 23 | 24 | export default deleteFile 25 | -------------------------------------------------------------------------------- /packages/firebase/src/schema.config.ts: -------------------------------------------------------------------------------- 1 | import { LockIcon, LinkIcon } from '@sanity/icons' 2 | import { VendorConfiguration } from 'sanity-plugin-external-files' 3 | 4 | export const schemaConfig = { 5 | title: 'Media file hosted in Firebase', 6 | customFields: [ 7 | 'bucket', 8 | 'contentDisposition', 9 | 'contentEncoding', 10 | 'fullPath', 11 | 'md5Hash', 12 | 'generation', 13 | 'metageneration', 14 | 'type', 15 | ], 16 | } 17 | 18 | export const credentialsFields: VendorConfiguration['credentialsFields'] = [ 19 | { 20 | name: 'apiKey', 21 | title: 'API Key', 22 | icon: LockIcon, 23 | type: 'string', 24 | validation: (Rule) => Rule.required(), 25 | }, 26 | { 27 | name: 'storageBucket', 28 | title: 'Storage Bucket', 29 | icon: LinkIcon, 30 | type: 'string', 31 | validation: (Rule) => Rule.required(), 32 | }, 33 | ] 34 | -------------------------------------------------------------------------------- /packages/core/src/scripts/getFileRef.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import type { UploaderProps } from '../components/Uploader/Uploader' 3 | 4 | export type GetFileRefProps = Pick & { 5 | file: File 6 | } 7 | 8 | /** 9 | * Creates unique file names for uploads if storeOriginalFilename is set to false 10 | */ 11 | export default function getFileRef({ 12 | storeOriginalFilename, 13 | file, 14 | }: GetFileRefProps) { 15 | if (storeOriginalFilename) { 16 | // Even when using the original file name, we need to provide a unique identifier for it. 17 | // Else most vendors' storage offering will re-utilize the same file for 2 different uploads with the same file name, replacing the previous upload. 18 | return `${new Date().toISOString().replace(/\:/g, '-')}-${file.name}` 19 | } 20 | 21 | return `${new Date().toISOString().replace(/\:/g, '-')}-${nanoid(6)}.${ 22 | file.name.split('.').slice(-1)[0] 23 | }` 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/components/WaveformDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { hues, ColorHueKey } from '@sanity/color' 3 | 4 | const WaveformDisplay: React.FC<{ 5 | waveformData?: number[] 6 | colorHue?: ColorHueKey 7 | style?: React.CSSProperties 8 | }> = ({ waveformData, colorHue = 'magenta', style = {} }) => { 9 | if (!Array.isArray(waveformData)) { 10 | return null 11 | } 12 | return ( 13 |
23 | {waveformData.map((bar, i) => ( 24 |
32 | ))} 33 |
34 | ) 35 | } 36 | 37 | export default WaveformDisplay 38 | -------------------------------------------------------------------------------- /packages/core/src/schemas/getCustomDataSchema.ts: -------------------------------------------------------------------------------- 1 | import { SchemaConfigOptions, VendorConfiguration } from '../types' 2 | 3 | export const getCustomDataFieldKey = (vendorConfig: VendorConfiguration) => 4 | vendorConfig.customDataFieldName || 5 | vendorConfig.schemaPrefix.replace(/-/g, '_') 6 | 7 | export const getCustomDataTypeKey = (vendorConfig: VendorConfiguration) => 8 | `${vendorConfig.schemaPrefix}.custom-data` 9 | 10 | const getCustomDataSchema = ( 11 | vendorConfig: VendorConfiguration, 12 | schemaConfig: SchemaConfigOptions = {}, 13 | ) => ({ 14 | name: getCustomDataTypeKey(vendorConfig), 15 | title: `${vendorConfig.schemaPrefix}-exclusive fields`, 16 | options: { collapsible: true, collapsed: false }, 17 | type: 'object', 18 | fields: (schemaConfig?.customFields || []).map((field) => { 19 | if (typeof field === 'string') { 20 | return { 21 | name: field, 22 | type: 'string', 23 | } 24 | } 25 | return field 26 | }), 27 | }) 28 | 29 | export default getCustomDataSchema 30 | -------------------------------------------------------------------------------- /packages/core/src/components/FormField.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Stack, Text } from '@sanity/ui' 2 | import { PropsWithChildren } from 'react' 3 | import { FormFieldValidationStatus, ValidationMarker } from 'sanity' 4 | 5 | export default function FormField( 6 | props: PropsWithChildren<{ 7 | label: string 8 | description?: string 9 | markers?: ValidationMarker[] 10 | }>, 11 | ) { 12 | return ( 13 | 14 | 15 | 16 | {props.label} 17 | ({ 19 | level: m.level, 20 | message: m.message || m.item?.message || '', 21 | path: m.path, 22 | }))} 23 | /> 24 | 25 | 26 | {props.description && ( 27 | 28 | {props.description} 29 | 30 | )} 31 | {props.children} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /packages/digital-ocean/src/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import { DigitalOceanCredentials } from '.' 3 | 4 | const deleteFile: VendorConfiguration['deleteFile'] = 5 | async ({ storedFile, credentials }) => { 6 | if (!credentials || typeof credentials.deleteObjectEndpoint !== 'string') { 7 | return 'missing-credentials' 8 | } 9 | 10 | const endpoint = credentials.deleteObjectEndpoint as string 11 | try { 12 | const res = await fetch(endpoint, { 13 | method: 'POST', 14 | body: JSON.stringify({ 15 | fileKey: storedFile.digitalOcean?.key, 16 | secret: credentials.secretForValidating, 17 | }), 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | }, 21 | }) 22 | if (res.ok) { 23 | return true 24 | } else { 25 | return 'error' 26 | } 27 | } catch (error: any) { 28 | return error?.message || 'error' 29 | } 30 | } 31 | 32 | export default deleteFile 33 | -------------------------------------------------------------------------------- /test-studio/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-studio", 3 | "private": true, 4 | "version": "1.0.2", 5 | "main": "package.json", 6 | "license": "UNLICENSED", 7 | "scripts": { 8 | "dev": "sanity dev", 9 | "start": "sanity start", 10 | "build": "sanity build", 11 | "deploy": "sanity deploy", 12 | "deploy-graphql": "sanity graphql deploy" 13 | }, 14 | "keywords": [ 15 | "sanity" 16 | ], 17 | "dependencies": { 18 | "@sanity/vision": "^3.58.0", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "sanity": "^3.58.0", 22 | "sanity-plugin-r2-files": "workspace:*", 23 | "sanity-plugin-digital-ocean-files": "workspace:*", 24 | "sanity-plugin-external-files": "workspace:*", 25 | "sanity-plugin-s3-files": "workspace:*", 26 | "styled-components": "^6.1.13" 27 | }, 28 | "devDependencies": { 29 | "@types/react": "latest", 30 | "typescript": "latest" 31 | }, 32 | "prettier": { 33 | "semi": false, 34 | "printWidth": 100, 35 | "bracketSpacing": false, 36 | "singleQuote": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/src/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import { CloudflareR2Credentials } from '.' 3 | 4 | const deleteFile: VendorConfiguration['deleteFile'] = 5 | async ({ storedFile, credentials }) => { 6 | if (!credentials || typeof credentials.workerUrl !== 'string') { 7 | return 'missing-credentials' 8 | } 9 | 10 | const endpoint = credentials.workerUrl as string 11 | const url = `${endpoint}/${storedFile.cloudflareR2?.fileKey}` 12 | const authToken = credentials.secret 13 | 14 | // Delete file from Cloudflare R2 15 | // By sending a DELETE request to the Cloudflare Worker 16 | const response = await fetch(url, { 17 | method: 'DELETE', 18 | headers: { 19 | Authorization: `Bearer ${authToken}`, 20 | }, 21 | mode: 'cors', 22 | }).then((response) => { 23 | if (response.ok) { 24 | return true as true 25 | } else { 26 | return 'error' 27 | } 28 | }) 29 | 30 | return response 31 | } 32 | 33 | export default deleteFile 34 | -------------------------------------------------------------------------------- /packages/firebase/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-firebase-files 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Fix: account for new Sanity version that broke useMemoObservable, used in document previews 8 | - Updated dependencies 9 | - sanity-plugin-external-files@1.0.2 10 | 11 | ## 1.0.1 12 | 13 | ### Patch Changes 14 | 15 | - Chore: update dependencies 16 | - Updated dependencies 17 | - sanity-plugin-external-files@1.0.1 18 | 19 | ## 1.0.0 20 | 21 | ### Major Changes 22 | 23 | - 5c598ac: Release Sanity v3 compatibility 24 | 25 | ### Patch Changes 26 | 27 | - 68e2782: fix: proper version resolution 28 | - Updated dependencies [5c598ac] 29 | - Updated dependencies [68e2782] 30 | - sanity-plugin-external-files@1.0.0 31 | 32 | ## 1.0.0-next.1 33 | 34 | ### Patch Changes 35 | 36 | - fix: proper version resolution 37 | - Updated dependencies 38 | - sanity-plugin-external-files@1.0.0-next.1 39 | 40 | ## 1.0.0-next.0 41 | 42 | ### Major Changes 43 | 44 | - Release Sanity v3 compatibility 45 | 46 | ### Patch Changes 47 | 48 | - Updated dependencies 49 | - sanity-plugin-external-files@1.0.0-next.0 50 | -------------------------------------------------------------------------------- /packages/digital-ocean/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-digital-ocean-files 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Fix: account for new Sanity version that broke useMemoObservable, used in document previews 8 | - Updated dependencies 9 | - sanity-plugin-external-files@1.0.2 10 | 11 | ## 1.0.1 12 | 13 | ### Patch Changes 14 | 15 | - Chore: update dependencies 16 | - Updated dependencies 17 | - sanity-plugin-external-files@1.0.1 18 | 19 | ## 1.0.0 20 | 21 | ### Major Changes 22 | 23 | - 5c598ac: Release Sanity v3 compatibility 24 | 25 | ### Patch Changes 26 | 27 | - 68e2782: fix: proper version resolution 28 | - Updated dependencies [5c598ac] 29 | - Updated dependencies [68e2782] 30 | - sanity-plugin-external-files@1.0.0 31 | 32 | ## 1.0.0-next.1 33 | 34 | ### Patch Changes 35 | 36 | - fix: proper version resolution 37 | - Updated dependencies 38 | - sanity-plugin-external-files@1.0.0-next.1 39 | 40 | ## 1.0.0-next.0 41 | 42 | ### Major Changes 43 | 44 | - Release Sanity v3 compatibility 45 | 46 | ### Patch Changes 47 | 48 | - Updated dependencies 49 | - sanity-plugin-external-files@1.0.0-next.0 50 | -------------------------------------------------------------------------------- /packages/aws/src/deleteFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import { S3Credentials } from '.' 3 | 4 | const deleteFile: VendorConfiguration['deleteFile'] = async ({ 5 | storedFile, 6 | credentials, 7 | }) => { 8 | if (!credentials || typeof credentials.deleteObjectEndpoint !== 'string') { 9 | return 'missing-credentials' 10 | } 11 | 12 | const endpoint = credentials.deleteObjectEndpoint as string 13 | try { 14 | const res = await fetch(endpoint, { 15 | method: 'POST', 16 | body: JSON.stringify({ 17 | fileKey: storedFile.s3?.key, 18 | secret: credentials.secretForValidating, 19 | bucketKey: credentials.bucketKey, 20 | bucketRegion: credentials.bucketRegion, 21 | }), 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | }, 25 | }) 26 | console.log({ res }) 27 | if (res.ok) { 28 | return true 29 | } else { 30 | return 'error' 31 | } 32 | } catch (error: any) { 33 | return error?.message || 'error' 34 | } 35 | } 36 | 37 | export default deleteFile 38 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/MissingSchemaType.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/MissingSchemaType.tsx 2 | import { WarningOutlineIcon } from '@sanity/icons' 3 | import type { SanityDocument } from '@sanity/types' 4 | import type { GeneralPreviewLayoutKey } from 'sanity' 5 | import { SanityDefaultPreview } from 'sanity' 6 | 7 | export interface MissingSchemaTypeProps { 8 | layout?: GeneralPreviewLayoutKey 9 | value: SanityDocument 10 | } 11 | 12 | const getUnknownTypeFallback = (id: string, typeName: string) => ({ 13 | title: ( 14 | 15 | No schema found for type {typeName} 16 | 17 | ), 18 | subtitle: ( 19 | 20 | Document: {id} 21 | 22 | ), 23 | media: () => , 24 | }) 25 | 26 | export function MissingSchemaType(props: MissingSchemaTypeProps) { 27 | const { layout, value } = props 28 | 29 | return ( 30 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-external-files-root", 3 | "private": true, 4 | "scripts": { 5 | "dev": "turbo run dev --parallel", 6 | "build": "turbo run build --force --filter=!test-studio", 7 | "format": "prettier --write .", 8 | "changeset": "changeset", 9 | "update-package-versions": "changeset version", 10 | "publish-packages": "changeset version && yarn run build && changeset publish", 11 | "check-versions": "manypkg check", 12 | "fix-versions": "manypkg fix" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+ssh://git@github.com/hdoro/sanity-plugin-external-files.git" 17 | }, 18 | "author": "Henrique Doro ", 19 | "license": "Apache-2.0", 20 | "dependencies": { 21 | "@changesets/cli": "^2.27.8", 22 | "@manypkg/cli": "^0.21.4", 23 | "prettier": "^3.3.3", 24 | "turbo": "^2.1.2", 25 | "typescript": "^5.6.2" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/hdoro/sanity-plugin-external-files/issues" 29 | }, 30 | "homepage": "https://github.com/hdoro/sanity-plugin-external-files#readme", 31 | "packageManager": "pnpm@9.11.0" 32 | } 33 | -------------------------------------------------------------------------------- /packages/aws/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-s3-files 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Fix: account for new Sanity version that broke useMemoObservable, used in document previews 8 | - Updated dependencies 9 | - sanity-plugin-external-files@1.0.2 10 | 11 | ## 1.0.1 12 | 13 | ### Patch Changes 14 | 15 | - Fix: handle updated AWS S3 SDK0, and simplify lambda function 16 | - Chore: update dependencies 17 | - Updated dependencies 18 | - sanity-plugin-external-files@1.0.1 19 | 20 | ## 1.0.0 21 | 22 | ### Major Changes 23 | 24 | - 5c598ac: Release Sanity v3 compatibility 25 | 26 | ### Patch Changes 27 | 28 | - 68e2782: fix: proper version resolution 29 | - Updated dependencies [5c598ac] 30 | - Updated dependencies [68e2782] 31 | - sanity-plugin-external-files@1.0.0 32 | 33 | ## 1.0.0-next.1 34 | 35 | ### Patch Changes 36 | 37 | - fix: proper version resolution 38 | - Updated dependencies 39 | - sanity-plugin-external-files@1.0.0-next.1 40 | 41 | ## 1.0.0-next.0 42 | 43 | ### Major Changes 44 | 45 | - Release Sanity v3 compatibility 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies 50 | - sanity-plugin-external-files@1.0.0-next.0 51 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/DraftStatus.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/DraftStatus.tsx 2 | import { EditIcon } from '@sanity/icons' 3 | import type { PreviewValue, SanityDocument } from '@sanity/types' 4 | import { Box, Text, Tooltip } from '@sanity/ui' 5 | import { TextWithTone } from 'sanity' 6 | import { TimeAgo } from './TimeAgo' 7 | 8 | export function DraftStatus(props: { 9 | document?: PreviewValue | Partial | null 10 | }) { 11 | const { document } = props 12 | const updatedAt = document && '_updatedAt' in document && document._updatedAt 13 | 14 | return ( 15 | 19 | 20 | {document ? ( 21 | <>Edited {updatedAt && } 22 | ) : ( 23 | <>No unpublished edits 24 | )} 25 | 26 | 27 | } 28 | > 29 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/PublishedStatus.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/PublishedStatus.tsx 2 | 3 | import { PublishIcon } from '@sanity/icons' 4 | import type { PreviewValue, SanityDocument } from '@sanity/types' 5 | import { Box, Text, Tooltip } from '@sanity/ui' 6 | import { TextWithTone } from 'sanity' 7 | import { TimeAgo } from './TimeAgo' 8 | 9 | export function PublishedStatus(props: { 10 | document?: PreviewValue | Partial | null 11 | }) { 12 | const { document } = props 13 | const updatedAt = document && '_updatedAt' in document && document._updatedAt 14 | 15 | return ( 16 | 20 | 21 | {document ? ( 22 | <>Published {updatedAt && } 23 | ) : ( 24 | <>Not published 25 | )} 26 | 27 | 28 | } 29 | > 30 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/src/components/Uploader/UploaderWithConfig.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Spinner, Box } from '@sanity/ui' 2 | import React from 'react' 3 | import ConfigureCredentials from '../Credentials/ConfigureCredentials' 4 | import { CredentialsContext } from '../Credentials/CredentialsProvider' 5 | import Uploader, { UploaderProps } from './Uploader' 6 | 7 | export interface UploaderWithConfigProps 8 | extends Omit {} 9 | 10 | const UploaderWithConfig: React.FC = (props) => { 11 | const { status } = React.useContext(CredentialsContext) 12 | 13 | if (status === 'missingCredentials') { 14 | return 15 | } 16 | 17 | if (status === 'loading') { 18 | return ( 19 | 26 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | return 41 | } 42 | 43 | export default UploaderWithConfig 44 | -------------------------------------------------------------------------------- /packages/core/src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardIcon, CopyIcon } from '@sanity/icons' 2 | import { Box, Button, Popover, Text, Tooltip } from '@sanity/ui' 3 | import ClipboardJS from 'clipboard' 4 | import { useEffect, useRef, useState } from 'react' 5 | 6 | export default function CopyButton(props: { 7 | textToCopy: string 8 | label: string 9 | }) { 10 | const [hasCopied, setHasCopied] = useState(false) 11 | const btnRef = useRef(null) 12 | useEffect(() => { 13 | if (!btnRef.current) return 14 | 15 | new ClipboardJS(btnRef.current as HTMLButtonElement) 16 | }, []) 17 | 18 | useEffect(() => { 19 | if (!hasCopied) return 20 | 21 | const timeout = setTimeout(() => { 22 | setHasCopied(false) 23 | }, 2000) 24 | 25 | return () => { 26 | clearTimeout(timeout) 27 | } 28 | }, [hasCopied]) 29 | 30 | return ( 31 | 34 | Copied to clipboard 35 | 36 | } 37 | open={hasCopied} 38 | > 39 |
69 | 70 | 71 | ) 72 | } 73 | 74 | export default FilePreview 75 | -------------------------------------------------------------------------------- /packages/core/src/components/FileMetadata.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, Stack, Inline } from '@sanity/ui' 3 | import { DownloadIcon, CalendarIcon, ClockIcon } from '@sanity/icons' 4 | 5 | import { SanityUpload } from '../types' 6 | import formatSeconds from '../scripts/formatSeconds' 7 | import formatBytes from '../scripts/formatBytes' 8 | import IconInfo from './IconInfo' 9 | 10 | interface FileMetadataProps { 11 | file: SanityUpload 12 | } 13 | 14 | const FileMetadata: React.FC = ({ file }) => { 15 | if (!file) { 16 | return null 17 | } 18 | return ( 19 | 20 | 21 | 28 | {file.title || file.fileName} 29 | 30 | {file.description && ( 31 |

46 | {file.description} 47 |

48 | )} 49 |
50 | 51 | {file.duration && ( 52 | 57 | )} 58 | {file.fileSize && ( 59 | 64 | )} 65 | 70 | 71 |
72 | ) 73 | } 74 | 75 | export default FileMetadata 76 | -------------------------------------------------------------------------------- /test-studio/sanity.config.js: -------------------------------------------------------------------------------- 1 | import {visionTool} from '@sanity/vision' 2 | import {defineConfig} from 'sanity' 3 | import {cloudflareR2Files} from 'sanity-plugin-r2-files' 4 | import {digitalOceanFiles} from 'sanity-plugin-digital-ocean-files' 5 | import {s3Files} from 'sanity-plugin-s3-files' 6 | import {structureTool} from 'sanity/structure' 7 | import {schemaTypes} from './schemas' 8 | 9 | export default defineConfig({ 10 | name: 'default', 11 | title: 'Test studio', 12 | 13 | projectId: process.env.SANITY_STUDIO_PROJECT_ID, 14 | dataset: process.env.SANITY_STUDIO_DATASET, 15 | 16 | plugins: [ 17 | structureTool(), 18 | visionTool(), 19 | cloudflareR2Files({ 20 | toolTitle: 'Cloudflare R2', 21 | credentials: { 22 | url: process.env.SANITY_STUDIO_CF_URL, 23 | workerUrl: process.env.SANITY_STUDIO_CF_WORKER_URL, 24 | }, 25 | }), 26 | digitalOceanFiles({ 27 | toolTitle: 'Digital Ocean', 28 | // If you want to restrict file types library-wide: 29 | // defaultAccept: { 30 | // 'application/pdf': ['pdf'], 31 | // 'video/*': ['mp4', 'mov', 'webm'], 32 | // }, 33 | credentials: { 34 | bucketKey: process.env.SANITY_STUDIO_DO_BUCKET_KEY, 35 | bucketRegion: process.env.SANITY_STUDIO_DO_BUCKET_REGION, 36 | getSignedUrlEndpoint: process.env.SANITY_STUDIO_DO_SIGNED_URL_ENDPOINT, 37 | deleteObjectEndpoint: process.env.SANITY_STUDIO_DO_DELETE_OBJECT_ENDPOINT, 38 | folder: process.env.SANITY_STUDIO_DO_UPLOAD_FOLDER, 39 | subdomain: null, 40 | secretForValidating: null, 41 | }, 42 | }), 43 | s3Files({ 44 | toolTitle: 'S3', 45 | // If you want to restrict file types library-wide: 46 | // defaultAccept: { 47 | // '*': ['*'], 48 | // }, 49 | credentials: { 50 | bucketKey: process.env.SANITY_STUDIO_S3_BUCKET_KEY, 51 | bucketRegion: process.env.SANITY_STUDIO_S3_BUCKET_REGION, 52 | getSignedUrlEndpoint: process.env.SANITY_STUDIO_S3_SIGNED_URL_ENDPOINT, 53 | deleteObjectEndpoint: process.env.SANITY_STUDIO_S3_DELETE_OBJECT_ENDPOINT, 54 | }, 55 | }), 56 | ], 57 | 58 | schema: { 59 | types: schemaTypes, 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /test-server/do.getSignedUrl.js: -------------------------------------------------------------------------------- 1 | const { createPresignedPost } = require('@aws-sdk/s3-presigned-post') 2 | const { S3Client } = require('@aws-sdk/client-s3') 3 | const http = require('http') 4 | 5 | require('dotenv').config() 6 | 7 | // === CONFIG === 8 | // 🚨 Don't forget to configure CORS in Digital Ocean Spaces 9 | const BUCKET = process.env.DO_SPACES_BUCKET 10 | const REGION = process.env.DO_SPACES_REGION 11 | const SECRET = process.env.UPLOADER_SECRET || undefined 12 | 13 | const ENDPOINT = `https://${REGION}.digitaloceanspaces.com` 14 | 15 | const client = new S3Client({ 16 | endpoint: ENDPOINT, 17 | credentials: { 18 | accessKeyId: process.env.DO_SPACES_ACCESS_KEY_ID, 19 | secretAccessKey: process.env.DO_SPACES_SECRET_ACCESS_KEY, 20 | }, 21 | forcePathStyle: false, // Configures to use subdomain/virtual calling format. 22 | region: REGION, 23 | }) 24 | 25 | function getRandomKey() { 26 | return Math.random().toFixed(10).replace('0.', '') 27 | } 28 | 29 | /** 30 | * 31 | * @param {{ contentType: string; fileName: string; secret?: string }} body 32 | * @param {http.ServerResponse & { req: http.IncomingMessage;}} response 33 | */ 34 | async function DigitalOceanGetSignedUrl(body, response) { 35 | const { contentType, fileName, secret } = body || {} 36 | 37 | response.setHeader('Content-Type', 'application/json') 38 | if (typeof SECRET !== 'undefined' && secret !== SECRET) { 39 | response.statusCode = 401 40 | return response.end( 41 | JSON.stringify({ 42 | message: 'Unauthorized', 43 | }), 44 | ) 45 | } 46 | const objectKey = 47 | fileName || 48 | `${getRandomKey()}-${getRandomKey()}-${contentType || 'unknown-type'}` 49 | try { 50 | const signed = await createPresignedPost(client, { 51 | Bucket: BUCKET, 52 | Key: objectKey, 53 | Conditions: contentType ? [['eq', '$Content-Type', contentType]] : [], 54 | Fields: { 55 | key: objectKey, 56 | acl: 'public-read', 57 | }, 58 | Expires: 30, 59 | ContentType: contentType, 60 | }) 61 | response.statusCode = 200 62 | return response.end(JSON.stringify(signed)) 63 | } catch (error) { 64 | response.statusCode = 500 65 | return response.end(JSON.stringify(error)) 66 | } 67 | } 68 | 69 | module.exports = DigitalOceanGetSignedUrl 70 | -------------------------------------------------------------------------------- /packages/firebase/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { definePlugin } from 'sanity' 2 | import { 3 | StudioTool, 4 | ToolIcon, 5 | VendorConfiguration, 6 | createInput, 7 | getCustomDataSchema, 8 | getDimensionsSchema, 9 | getStoredFileSchema, 10 | } from 'sanity-plugin-external-files' 11 | import deleteFile from './deleteFile' 12 | import { credentialsFields, schemaConfig } from './schema.config' 13 | import uploadFile from './uploadFile' 14 | 15 | const VENDOR_ID = 'firebase-files' 16 | 17 | export const firebaseFiles = definePlugin((userConfig?: UserConfig) => { 18 | const config = buildConfig(userConfig) 19 | return { 20 | name: config.schemaPrefix, 21 | schema: { 22 | types: [ 23 | // firebase-files.custom-data 24 | getCustomDataSchema(config, schemaConfig), 25 | // firebase-files.dimensions 26 | getDimensionsSchema(config), 27 | // firebase-files.storedFile 28 | getStoredFileSchema(config, schemaConfig), 29 | { 30 | name: `${config.schemaPrefix}.media`, 31 | title: 'Firebase media', 32 | type: 'object', 33 | components: { 34 | input: createInput(config), 35 | }, 36 | fields: [ 37 | { 38 | name: 'asset', 39 | title: 'Asset', 40 | type: 'reference', 41 | to: [{ type: `${config.schemaPrefix}.storedFile` }], 42 | validation: (Rule) => Rule.required(), 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | tools: [ 49 | { 50 | name: config.schemaPrefix, 51 | title: config.toolTitle, 52 | component: () => , 53 | icon: ToolIcon, 54 | }, 55 | ], 56 | } 57 | }) 58 | 59 | function buildConfig(userConfig: UserConfig = {}): VendorConfiguration { 60 | return { 61 | id: VENDOR_ID, 62 | customDataFieldName: 'firebase', 63 | defaultAccept: userConfig.defaultAccept, 64 | schemaPrefix: userConfig.schemaPrefix ?? VENDOR_ID, 65 | toolTitle: userConfig.toolTitle ?? 'Media Library (Firebase)', 66 | credentialsFields, 67 | deleteFile: deleteFile, 68 | uploadFile: uploadFile, 69 | } 70 | } 71 | 72 | interface UserConfig 73 | extends Pick, 'defaultAccept' | 'schemaPrefix'> { 74 | toolTitle?: string 75 | } 76 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-external-files", 3 | "version": "1.0.2", 4 | "description": "Core library for external object storage providers in Sanity.io studio.", 5 | "scripts": { 6 | "clear-lib": "node clearLib.js", 7 | "build": "npm run clear-lib && tsc && tsc --module esnext --outDir build/esm", 8 | "dev": "concurrently \"tsc -w\" \"tsc --module esnext --outDir build/esm -w\"" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/hdoro/sanity-plugin-external-files.git" 13 | }, 14 | "keywords": [ 15 | "sanity.io", 16 | "sanity", 17 | "media", 18 | "cloud storage", 19 | "asset management", 20 | "sanity-plugin" 21 | ], 22 | "author": "Henrique Doro ", 23 | "license": "Apache-2.0", 24 | "dependencies": { 25 | "@xstate/react": "^3.2.2", 26 | "clipboard": "^2.0.11", 27 | "mime": "^4.0.4", 28 | "nanoid": "^5.0.7", 29 | "react-dropzone": "^14.2.3", 30 | "react-rx": "^4.0.0", 31 | "rxjs": "^7.8.1", 32 | "xstate": "^4.38.3" 33 | }, 34 | "devDependencies": { 35 | "@sanity/client": "^6.22.0", 36 | "@sanity/color": "^3.0.6", 37 | "@sanity/icons": "^3.4.0", 38 | "@sanity/image-url": "^1.0.2", 39 | "@sanity/schema": "^3.58.0", 40 | "@sanity/types": "^3.58.0", 41 | "@sanity/ui": "^2.8.9", 42 | "@types/react": "^18.3.9", 43 | "@types/react-dom": "^18.3.0", 44 | "@types/styled-components": "^5.1.34", 45 | "concurrently": "^9.0.1", 46 | "lodash": "^4.17.21", 47 | "react": "^18.3.1", 48 | "react-dom": "^18.3.1", 49 | "sanity": "^3.58.0", 50 | "styled-components": "^6.1.13", 51 | "typescript": "^5.6.2" 52 | }, 53 | "peerDependencies": { 54 | "sanity": "^3.50.0", 55 | "styled-components": "^5 || ^6" 56 | }, 57 | "bugs": { 58 | "url": "https://github.com/hdoro/sanity-plugin-external-files/issues" 59 | }, 60 | "homepage": "https://github.com/hdoro/sanity-plugin-external-files#readme", 61 | "exports": { 62 | ".": { 63 | "types": "./build/index.d.ts", 64 | "import": "./build/esm/index.js", 65 | "require": "./build/index.js", 66 | "default": "./build/esm/index.js" 67 | }, 68 | "./package.json": "./package.json" 69 | }, 70 | "main": "./build/index.js", 71 | "module": "./build/esm/index.js", 72 | "types": "./build/index.d.ts" 73 | } 74 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/worker/index.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | R2_BUCKET: R2Bucket 3 | SECRET: string 4 | ALLOWED_ORIGINS: string[] 5 | } 6 | 7 | /** 8 | * The main handler function for the Cloudflare Worker 9 | */ 10 | export default { 11 | async fetch(request: Request, env: Env): Promise { 12 | const method = request.method.toUpperCase() 13 | 14 | // Return a 200 for OPTIONS request to ensure browsers can process CORS in the Sanity studio 15 | if (method === 'OPTIONS') { 16 | return response(200, { message: 'Success' }, request, env.ALLOWED_ORIGINS) 17 | } 18 | 19 | if (['PUT', 'DELETE'].includes(method)) { 20 | const auth = request.headers.get('Authorization') 21 | const expectedAuth = `Bearer ${env.SECRET}` 22 | 23 | if (!auth || auth !== expectedAuth) { 24 | return response( 25 | 401, 26 | { message: 'Unauthorized' }, 27 | request, 28 | env.ALLOWED_ORIGINS, 29 | ) 30 | } 31 | } 32 | 33 | if (method === 'PUT') { 34 | const url = new URL(request.url) 35 | const key = url.pathname.slice(1) 36 | await env.R2_BUCKET.put(key, request.body) 37 | return response( 38 | 200, 39 | { message: `Object ${key} uploaded successfully!` }, 40 | request, 41 | env.ALLOWED_ORIGINS, 42 | ) 43 | } 44 | 45 | if (method === 'DELETE') { 46 | const url = new URL(request.url) 47 | const key = url.pathname.slice(1) 48 | await env.R2_BUCKET.delete(key) 49 | return response( 50 | 200, 51 | { message: `Object ${key} deleted successfully!` }, 52 | request, 53 | env.ALLOWED_ORIGINS, 54 | ) 55 | } 56 | 57 | return response( 58 | 400, 59 | { message: 'Invalid request' }, 60 | request, 61 | env.ALLOWED_ORIGINS, 62 | ) 63 | }, 64 | } 65 | 66 | /** 67 | * ## `response` 68 | * Helper function to return a JSON response 69 | * @param {number} statusCode 70 | * @param {Object} body 71 | */ 72 | function response( 73 | statusCode: number, 74 | body: Object, 75 | request: Request, 76 | ALLOWED_ORIGINS: string[], 77 | ): Response { 78 | const origin = request.headers.get('Origin') || '' 79 | const headers = { 80 | 'Content-Type': 'application/json', 81 | 'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) 82 | ? origin 83 | : '', 84 | 'Access-Control-Allow-Methods': 'OPTIONS, PUT, DELETE', 85 | 'Access-Control-Allow-Headers': '*', 86 | } 87 | 88 | return new Response(JSON.stringify(body), { 89 | status: statusCode, 90 | headers: headers, 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /packages/digital-ocean/deleteObject.example.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | // === CONFIG === 4 | // 🚨 Don't forget to configure CORS in Digital Ocean Spaces 5 | const BUCKET = 'YOUR_BUCKET' 6 | const REGION = 'YOUR_REGION' 7 | const SECRET = undefined // (Optional) set a secret. Needs to be added to the Sanity plugin as well! 8 | 9 | const ENDPOINT = `${REGION}.digitaloceanspaces.com` 10 | const spacesEndpoint = new AWS.Endpoint(ENDPOINT) 11 | const credentials = new AWS.Credentials({ 12 | accessKeyId: process.env.SPACES_KEY, 13 | secretAccessKey: process.env.SPACES_SECRET, 14 | }) 15 | 16 | const s3 = new AWS.S3({ 17 | endpoint: spacesEndpoint, 18 | credentials, 19 | }) 20 | 21 | const SHARED_HEADERS = { 22 | 'Content-Type': 'application/json', 23 | } 24 | 25 | exports.handler = async (event, _context, callback) => { 26 | const method = 27 | event.httpMethod || 28 | event?.requestContext?.httpMethod || 29 | event?.requestContext?.http?.method 30 | if (method?.toUpperCase() === 'OPTIONS') { 31 | return callback(null, { 32 | statusCode: 200, 33 | body: JSON.stringify(event), 34 | headers: SHARED_HEADERS, 35 | }) 36 | } 37 | 38 | let body = {} 39 | try { 40 | body = JSON.parse(event.body || '{}') 41 | } catch (error) { 42 | return callback(null, { 43 | statusCode: 400, 44 | body: JSON.stringify({ 45 | message: 'Missing body', 46 | ...event, 47 | }), 48 | headers: SHARED_HEADERS, 49 | }) 50 | } 51 | 52 | // Event is an object with the JSON data sent to the function 53 | const { fileKey, secret } = body || {} 54 | 55 | if (typeof SECRET !== 'undefined' && secret !== SECRET) { 56 | return callback(null, { 57 | statusCode: 401, 58 | body: JSON.stringify({ 59 | message: 'Unauthorized', 60 | ...event, 61 | }), 62 | headers: SHARED_HEADERS, 63 | }) 64 | } 65 | 66 | if (!fileKey || typeof fileKey !== 'string') { 67 | return { 68 | statusCode: 400, 69 | body: JSON.stringify({ 70 | message: 'Missing file key', 71 | }), 72 | headers: SHARED_HEADERS, 73 | } 74 | } 75 | 76 | try { 77 | await s3 78 | .deleteObject({ 79 | Bucket: BUCKET, 80 | Key: fileKey, 81 | }) 82 | .promise() 83 | return callback(null, { 84 | statusCode: 200, 85 | body: JSON.stringify({ 86 | message: 'success!', 87 | }), 88 | headers: SHARED_HEADERS, 89 | }) 90 | } catch (error) { 91 | return callback(error, { 92 | statusCode: 500, 93 | body: JSON.stringify(error), 94 | headers: SHARED_HEADERS, 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/digital-ocean/getSignedUrl.example.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | 3 | // === CONFIG === 4 | // 🚨 Don't forget to configure CORS in Digital Ocean Spaces 5 | const BUCKET = 'YOUR_BUCKET' 6 | const REGION = 'YOUR_REGION' 7 | const SECRET = undefined // (Optional) set a secret. Needs to be added to the Sanity plugin as well! 8 | 9 | const ENDPOINT = `${REGION}.digitaloceanspaces.com` 10 | const spacesEndpoint = new AWS.Endpoint(ENDPOINT) 11 | const credentials = new AWS.Credentials({ 12 | accessKeyId: process.env.SPACES_KEY, 13 | secretAccessKey: process.env.SPACES_SECRET, 14 | }) 15 | 16 | const s3 = new AWS.S3({ 17 | endpoint: spacesEndpoint, 18 | credentials, 19 | }) 20 | 21 | function getRandomKey() { 22 | return Math.random().toFixed(10).replace('0.', '') 23 | } 24 | 25 | const SHARED_HEADERS = { 26 | 'Content-Type': 'application/json', 27 | } 28 | 29 | exports.handler = async (event, _context, callback) => { 30 | const method = 31 | event.httpMethod || 32 | event?.requestContext?.httpMethod || 33 | event?.requestContext?.http?.method 34 | if (method?.toUpperCase() === 'OPTIONS') { 35 | return callback(null, { 36 | statusCode: 200, 37 | body: JSON.stringify(event), 38 | headers: SHARED_HEADERS, 39 | }) 40 | } 41 | 42 | let body = {} 43 | try { 44 | body = JSON.parse(event.body || '{}') 45 | } catch (error) { 46 | return callback(null, { 47 | statusCode: 400, 48 | body: JSON.stringify({ 49 | message: 'Missing body', 50 | ...event, 51 | }), 52 | headers: SHARED_HEADERS, 53 | }) 54 | } 55 | 56 | const { contentType, fileName, secret } = body || {} 57 | 58 | if (typeof SECRET !== 'undefined' && secret !== SECRET) { 59 | return callback(null, { 60 | statusCode: 401, 61 | body: JSON.stringify({ 62 | message: 'Unauthorized', 63 | ...event, 64 | }), 65 | headers: SHARED_HEADERS, 66 | }) 67 | } 68 | s3.createPresignedPost( 69 | { 70 | Fields: { 71 | key: 72 | fileName || 73 | `${getRandomKey()}-${getRandomKey()}-${ 74 | contentType || 'unknown-type' 75 | }`, 76 | acl: 'public-read', 77 | }, 78 | Conditions: contentType ? [['eq', '$Content-Type', contentType]] : [], 79 | Expires: 30, 80 | Bucket: BUCKET, 81 | ContentType: contentType, 82 | }, 83 | (error, signed) => { 84 | if (!!error) { 85 | return callback(error, { 86 | statusCode: 500, 87 | body: JSON.stringify(error), 88 | headers: SHARED_HEADERS, 89 | }) 90 | } 91 | return callback(null, { 92 | statusCode: 200, 93 | body: JSON.stringify(signed), 94 | headers: SHARED_HEADERS, 95 | }) 96 | }, 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/PaneItemPreview.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from: 2 | // https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/paneItem/PaneItemPreview.tsx 3 | import { PreviewValue } from '@sanity/types' 4 | import { Inline } from '@sanity/ui' 5 | import { isNumber, isString } from 'lodash' 6 | import React, { isValidElement, useMemo } from 'react' 7 | import { useObservable } from 'react-rx' 8 | import type { Observable } from 'rxjs' 9 | import type { SanityDocument, SchemaType } from 'sanity' 10 | import { 11 | DocumentPresence, 12 | DocumentPreviewPresence, 13 | DocumentPreviewStore, 14 | GeneralPreviewLayoutKey, 15 | SanityDefaultPreview, 16 | getPreviewStateObservable, 17 | getPreviewValueWithFallback, 18 | isRecord, 19 | } from 'sanity' 20 | import { DraftStatus } from './DraftStatus' 21 | import { PublishedStatus } from './PublishedStatus' 22 | 23 | export interface PaneItemPreviewState { 24 | isLoading?: boolean 25 | draft?: PreviewValue | Partial | null 26 | published?: PreviewValue | Partial | null 27 | } 28 | 29 | export interface PaneItemPreviewProps { 30 | documentPreviewStore: DocumentPreviewStore 31 | icon: React.ComponentType | false 32 | layout: GeneralPreviewLayoutKey 33 | presence?: DocumentPresence[] 34 | schemaType: SchemaType 35 | value: SanityDocument 36 | } 37 | 38 | export function PaneItemPreview(props: PaneItemPreviewProps) { 39 | const { icon, layout, presence, schemaType, value } = props 40 | const title = 41 | (isRecord(value.title) && isValidElement(value.title)) || 42 | isString(value.title) || 43 | isNumber(value.title) 44 | ? (value.title as string | number | React.ReactElement) 45 | : null 46 | 47 | // NOTE: this emits sync so can never be null 48 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 49 | const previewObservable = useMemo>( 50 | () => 51 | getPreviewStateObservable( 52 | props.documentPreviewStore, 53 | schemaType, 54 | value._id, 55 | title, 56 | ), 57 | [props.documentPreviewStore, schemaType, value._id, title], 58 | )! 59 | const { draft, published, isLoading } = useObservable(previewObservable) || {} 60 | 61 | const status = isLoading ? null : ( 62 | 63 | {presence && presence.length > 0 && ( 64 | 65 | )} 66 | 67 | 68 | 69 | ) 70 | 71 | return ( 72 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /packages/core/src/components/CreateInput.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, ThemeProvider, studioTheme } from '@sanity/ui' 2 | import { useCallback, useState } from 'react' 3 | import { createPortal } from 'react-dom' 4 | import { ObjectInputProps, set, unset } from 'sanity' 5 | import { 6 | ExternalFileFieldOptions, 7 | SanityUpload, 8 | VendorConfiguration, 9 | } from '../types' 10 | import Browser from './Browser/Browser' 11 | import CredentialsProvider from './Credentials/CredentialsProvider' 12 | import UploaderWithConfig from './Uploader/UploaderWithConfig' 13 | 14 | /** 15 | * Lets editors choose assets from external DAM inside the document editor. 16 | */ 17 | export default function createInput(vendorConfig: VendorConfiguration) { 18 | return function ExternalDamInput(props: ObjectInputProps) { 19 | const { onChange, value, schemaType } = props 20 | const { 21 | accept = vendorConfig?.defaultAccept, 22 | storeOriginalFilename = true, 23 | } = (schemaType?.options || {}) as ExternalFileFieldOptions 24 | 25 | const [browserOpen, setBrowserOpen] = useState(false) 26 | const [uploadedFile, setUploadedFile] = useState() 27 | 28 | const updateValue = useCallback( 29 | (document: SanityUpload) => { 30 | const patchValue = { 31 | _type: schemaType.name, 32 | asset: { 33 | _type: 'reference', 34 | _ref: document._id, 35 | }, 36 | } 37 | onChange(set(patchValue)) 38 | setBrowserOpen(false) 39 | setUploadedFile(document) 40 | }, 41 | [onChange, setBrowserOpen, setUploadedFile, schemaType], 42 | ) 43 | 44 | const removeFile = useCallback(() => { 45 | onChange(unset()) 46 | setUploadedFile(undefined) 47 | }, [onChange, setUploadedFile]) 48 | 49 | const toggleBrowser = useCallback(() => { 50 | setBrowserOpen((prev) => !prev) 51 | }, [setBrowserOpen]) 52 | 53 | return ( 54 | 55 | 56 | 65 | {browserOpen && 66 | createPortal( 67 | 74 | 79 | , 80 | document.getElementsByTagName('body')[0], 81 | )} 82 | 83 | 84 | ) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/core/src/components/Uploader/Uploader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Container, Stack } from '@sanity/ui' 3 | import { SearchIcon, UploadIcon, TrashIcon } from '@sanity/icons' 4 | 5 | import useUpload from './useUpload' 6 | import { MediaFile, SanityUpload, VendorConfiguration } from '../../types' 7 | import UploadBox from './UploadBox' 8 | import MediaPreview from '../MediaPreview' 9 | import { Accept } from 'react-dropzone' 10 | 11 | export interface UploaderProps { 12 | vendorConfig: VendorConfiguration 13 | onSuccess: (document: SanityUpload) => void 14 | 15 | // CONFIGURATION 16 | /** 17 | * MIME file type 18 | */ 19 | accept?: Accept 20 | /** 21 | * Whether or not we should use the file's name when uploading 22 | */ 23 | storeOriginalFilename?: boolean 24 | 25 | // FIELD INPUT CONTEXT 26 | /** 27 | * Opens the media browser / library 28 | */ 29 | openBrowser?: () => void 30 | /** 31 | * File already uploaded in this instance 32 | */ 33 | chosenFile?: MediaFile 34 | /** 35 | * Used to clear the field via the remove button 36 | */ 37 | removeFile?: () => void 38 | } 39 | 40 | const Uploader: React.FC = (props) => { 41 | const uploadProps = useUpload(props) 42 | const { 43 | dropzone: { inputRef, getInputProps }, 44 | } = uploadProps 45 | 46 | const onUploadClick = React.useCallback(() => { 47 | if (inputRef?.current) { 48 | inputRef.current.click() 49 | } 50 | }, [inputRef]) 51 | 52 | return ( 53 | 54 | 55 | 56 | {props.chosenFile ? ( 57 | 58 | ) : ( 59 | 64 | )} 65 |
74 |
107 |
108 |
109 | ) 110 | } 111 | 112 | export default Uploader 113 | -------------------------------------------------------------------------------- /packages/digital-ocean/src/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import { DigitalOceanCredentials } from '.' 3 | 4 | const uploadFile: VendorConfiguration['uploadFile'] = 5 | ({ credentials, onError, onSuccess, file, fileName }) => { 6 | if ( 7 | !credentials || 8 | typeof credentials.getSignedUrlEndpoint !== 'string' || 9 | typeof credentials.bucketKey !== 'string' 10 | ) { 11 | onError({ 12 | name: 'missing-credentials', 13 | message: 'Missing correct credentials', 14 | }) 15 | } 16 | 17 | const filePath = [credentials.folder, fileName] 18 | .filter(Boolean) 19 | .join('/') 20 | .replace(/\s/g, '-') 21 | 22 | // On cancelling fetch: https://davidwalsh.name/cancel-fetch 23 | let signal: AbortSignal | undefined 24 | let controller: AbortController | undefined 25 | try { 26 | controller = new AbortController() 27 | signal = controller.signal 28 | } catch (error) {} 29 | 30 | const endpoint = credentials.getSignedUrlEndpoint as string 31 | fetch(endpoint, { 32 | method: 'POST', 33 | body: JSON.stringify({ 34 | fileName: filePath, 35 | contentType: file.type, 36 | secret: credentials.secretForValidating, 37 | }), 38 | headers: { 39 | 'Content-Type': 'application/json', 40 | }, 41 | mode: 'cors', 42 | signal, 43 | }) 44 | .then((response) => response.json()) 45 | .then(({ url, fields }) => { 46 | const fileKey = fields?.key || filePath 47 | const data = { 48 | ...fields, 49 | 'Content-Type': file.type, 50 | file, 51 | } 52 | 53 | const formData = new FormData() 54 | for (const name in data) { 55 | formData.append(name, data[name]) 56 | } 57 | 58 | fetch(url, { 59 | method: 'POST', 60 | body: formData, 61 | mode: 'cors', 62 | signal, 63 | }) 64 | .then((res) => { 65 | if (res.ok) { 66 | const subdomain = `${credentials.bucketKey}.${credentials.bucketRegion}` 67 | onSuccess({ 68 | // CDN - accepts a custom subdomain 69 | fileURL: credentials.subdomain 70 | ? `${credentials.subdomain}/${fileKey}` 71 | : `https://${subdomain}.cdn.digitaloceanspaces.com/${fileKey}`, 72 | digitalOcean: { 73 | key: fileKey, 74 | bucket: credentials.bucketKey, 75 | region: credentials.bucketRegion, 76 | // Non-CDN 77 | originURL: `https://${subdomain}.digitaloceanspaces.com/${fileKey}`, 78 | }, 79 | }) 80 | } else { 81 | onError({ 82 | message: 83 | 'Ask your developer to check DigitalOcean permissions.', 84 | name: 'failed-presigned', 85 | }) 86 | } 87 | }) 88 | .catch((error) => { 89 | onError(error) 90 | }) 91 | }) 92 | .catch((error) => { 93 | onError(error) 94 | }) 95 | return () => { 96 | try { 97 | if (controller?.abort) { 98 | controller.abort() 99 | } 100 | } catch (error) {} 101 | } 102 | } 103 | 104 | export default uploadFile 105 | -------------------------------------------------------------------------------- /packages/core/src/schemas/getStoredFileSchema.ts: -------------------------------------------------------------------------------- 1 | import { SchemaType } from 'sanity' 2 | import { VendorConfiguration } from '../types' 3 | import { 4 | getCustomDataFieldKey, 5 | getCustomDataTypeKey, 6 | } from './getCustomDataSchema' 7 | import getDimensionsSchema from './getDimensionsSchema' 8 | 9 | type CustomField = string | SchemaType 10 | 11 | interface SchemaConfigOptions { 12 | title?: string 13 | customFields?: CustomField[] 14 | } 15 | 16 | const getStoredFileSchema = ( 17 | vendorConfig: VendorConfiguration, 18 | schemaConfig: SchemaConfigOptions = {}, 19 | ) => ({ 20 | name: `${vendorConfig.schemaPrefix}.storedFile`, 21 | title: schemaConfig.title || 'Media file hosted in external vendor', 22 | type: 'document', 23 | fieldsets: [ 24 | { 25 | name: 'audio', 26 | title: 'Audio-exclusive fields', 27 | options: { collapsible: true, collapsed: true }, 28 | }, 29 | { 30 | name: 'video', 31 | title: 'Video-exclusive fields', 32 | options: { collapsible: true, collapsed: true }, 33 | }, 34 | ], 35 | fields: [ 36 | { 37 | name: 'title', 38 | title: 'Title', 39 | description: 'Mainly for internal reference', 40 | type: 'string', 41 | }, 42 | { 43 | name: 'description', 44 | title: 'Description', 45 | description: 'Mainly for internal reference', 46 | type: 'text', 47 | }, 48 | { 49 | name: 'fileName', 50 | title: 'File name', 51 | type: 'string', 52 | }, 53 | { 54 | name: 'fileURL', 55 | title: 'File URL / download URL', 56 | type: 'string', 57 | }, 58 | { 59 | name: 'contentType', 60 | title: 'Content Type', 61 | description: 'MIME type', 62 | type: 'string', 63 | }, 64 | { 65 | name: 'duration', 66 | title: 'Duration (in seconds)', 67 | type: 'number', 68 | }, 69 | { 70 | name: 'fileSize', 71 | title: 'Size in bytes', 72 | type: 'number', 73 | }, 74 | { 75 | name: 'screenshot', 76 | title: 'Video screenshot', 77 | type: 'image', 78 | fieldset: 'video', 79 | }, 80 | { 81 | name: 'dimensions', 82 | title: 'Dimensions', 83 | type: getDimensionsSchema(vendorConfig).name, 84 | fieldset: 'video', 85 | }, 86 | { 87 | name: 'waveformData', 88 | title: 'Waveform peak data', 89 | description: 'Exclusive to audio files', 90 | fieldset: 'audio', 91 | type: 'array', 92 | of: [{ type: 'number' }], 93 | }, 94 | ...(schemaConfig?.customFields 95 | ? [ 96 | { 97 | name: getCustomDataFieldKey(vendorConfig), 98 | title: `${vendorConfig.schemaPrefix}-exclusive fields`, 99 | options: { collapsible: true, collapsed: false }, 100 | type: getCustomDataTypeKey(vendorConfig), 101 | }, 102 | ] 103 | : []), 104 | ], 105 | preview: { 106 | select: { 107 | title: 'title', 108 | fileName: 'fileName', 109 | description: 'description', 110 | assetURL: 'assetURL', 111 | media: 'screenshot', 112 | }, 113 | prepare: ({ media, assetURL, fileName, title, description }: any) => { 114 | return { 115 | title: title || fileName || 'Untitled file', 116 | subtitle: description || assetURL, 117 | media, 118 | } 119 | }, 120 | }, 121 | }) 122 | 123 | export default getStoredFileSchema 124 | -------------------------------------------------------------------------------- /packages/aws/lambda.example.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | PutObjectCommand, 4 | S3Client, 5 | } from '@aws-sdk/client-s3' 6 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner' 7 | 8 | // === 🚨 CONFIG (bucket key and region is passed from the Sanity studio) === 9 | const SECRET = undefined // (Optional) set a secret. Needs to be added to the Sanity plugin as well! 10 | 11 | const SHARED_HEADERS = { 12 | 'Content-Type': 'application/json', 13 | } 14 | 15 | /** 16 | * @type {import('aws-lambda').Handler} 17 | */ 18 | export const handler = async (event, _context, callback) => { 19 | const method = 20 | event.httpMethod || 21 | event?.requestContext?.httpMethod || 22 | event?.requestContext?.http?.method 23 | 24 | // Return a 200 for OPTIONS request to ensure browsers can process CORS in the Sanity studio 25 | if (method?.toUpperCase() === 'OPTIONS') { 26 | return response(200, {}) 27 | } 28 | 29 | /** 30 | * @type {{ secret?: string; bucketKey?: string; bucketRegion?: string } & ({ fileName: string; contentType?: string } | { secret?: string; fileKey: string })} 31 | */ 32 | let body = {} 33 | try { 34 | body = JSON.parse(event.body || '{}') 35 | } catch (error) { 36 | return response(400, { 37 | message: 'Missing body', 38 | }) 39 | } 40 | 41 | const { bucketKey, bucketRegion, secret } = body 42 | 43 | if (typeof SECRET !== 'undefined' && secret !== SECRET) { 44 | return response(401, { 45 | message: 'Unauthorized', 46 | }) 47 | } 48 | 49 | if (!bucketKey || !bucketRegion) { 50 | return response(400, { 51 | message: 'Missing `bucketKey` or `bucketRegion`', 52 | }) 53 | } 54 | 55 | const s3Client = new S3Client({ 56 | region: bucketRegion, 57 | }) 58 | 59 | /** SIGNED URL CREATION */ 60 | if ('fileName' in body) { 61 | const { contentType, fileName } = body 62 | 63 | const createSignedUrl = new PutObjectCommand({ 64 | Bucket: bucketKey, 65 | Key: 66 | fileName || 67 | `${getRandomKey()}-${getRandomKey()}-${contentType || 'unknown-type'}`, 68 | ContentType: contentType, 69 | 70 | // Can be publicly read 71 | ACL: 'public-read', 72 | }) 73 | 74 | try { 75 | const url = await getSignedUrl(s3Client, createSignedUrl) 76 | 77 | return response(200, { url }) 78 | } catch (error) { 79 | return response(500, { 80 | message: 'Failed creating signed URL', 81 | error, 82 | }) 83 | } 84 | } 85 | 86 | /** OBJECT DELETION */ 87 | if ('fileKey' in body) { 88 | const { fileKey } = body 89 | 90 | const deleteObject = new DeleteObjectCommand({ 91 | Bucket: bucketKey, 92 | Key: fileKey, 93 | }) 94 | 95 | try { 96 | await s3Client.send(deleteObject) 97 | 98 | return response(200, { message: 'success' }) 99 | } catch (error) { 100 | return response(500, { 101 | message: 'Failed deleting file', 102 | fileKey, 103 | }) 104 | } 105 | } 106 | 107 | return response(400, { 108 | message: 'Invalid request', 109 | }) 110 | 111 | /** 112 | * @param {number} statusCode 113 | * @param {Object} body 114 | */ 115 | function response(statusCode, body) { 116 | return callback(null, { 117 | statusCode, 118 | body: JSON.stringify(body), 119 | headers: SHARED_HEADERS, 120 | }) 121 | } 122 | } 123 | 124 | function getRandomKey() { 125 | return Math.random().toFixed(10).replace('0.', '') 126 | } 127 | -------------------------------------------------------------------------------- /packages/core/src/components/Credentials/CredentialsProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@sanity/ui' 2 | import React, { PropsWithChildren } from 'react' 3 | import { useSanityClient } from '../../scripts/sanityClient' 4 | import { VendorConfiguration, VendorCredentials } from '../../types' 5 | 6 | export type CredentialsStatus = 'loading' | 'missingCredentials' | 'success' 7 | 8 | interface ContextValue { 9 | credentials?: VendorCredentials 10 | saveCredentials: (credentials: VendorCredentials) => Promise 11 | status: CredentialsStatus 12 | } 13 | 14 | export const CredentialsContext = React.createContext({ 15 | saveCredentials: async () => false, 16 | status: 'loading', 17 | }) 18 | 19 | const CredentialsProvider = ( 20 | props: PropsWithChildren<{ 21 | vendorConfig: VendorConfiguration 22 | }>, 23 | ) => { 24 | const { vendorConfig } = props 25 | const cacheKey = `_${ 26 | vendorConfig?.schemaPrefix || 'external' 27 | }FilesSavedCredentials` 28 | const documentId = `${vendorConfig?.schemaPrefix}.credentials` 29 | 30 | const sanityClient = useSanityClient() 31 | const toast = useToast() 32 | const [credentials, setCredentials] = React.useState< 33 | VendorCredentials | undefined 34 | >() 35 | const [status, setStatus] = React.useState('loading') 36 | 37 | async function saveCredentials(newCredentials: VendorCredentials) { 38 | ;(window as any)[cacheKey] = undefined 39 | 40 | try { 41 | await sanityClient.createOrReplace({ 42 | _id: documentId, 43 | _type: documentId, 44 | ...newCredentials, 45 | }) 46 | toast.push({ 47 | title: 'Credentials successfully saved!', 48 | status: 'success', 49 | }) 50 | setCredentials(newCredentials) 51 | setStatus('success') 52 | return true 53 | } catch (error) { 54 | toast.push({ 55 | title: "Couldn't create credentials", 56 | status: 'error', 57 | }) 58 | return false 59 | } 60 | } 61 | 62 | React.useEffect(() => { 63 | if (credentials?.apiKey && credentials?.storageBucket) { 64 | ;(window as any)[cacheKey] = credentials 65 | setStatus('success') 66 | } 67 | }, [credentials]) 68 | 69 | React.useEffect(() => { 70 | // Credentials stored in the window object to spare extra API calls 71 | const savedCredentials: VendorCredentials | undefined = (window as any)[ 72 | cacheKey 73 | ] 74 | 75 | // If credentials are passed through the plugin's config, no need to store them in Sanity 76 | if ( 77 | (savedCredentials && 78 | vendorConfig.credentialsFields.every( 79 | (field) => field.name in savedCredentials, 80 | )) || 81 | vendorConfig.credentialsFields.length === 0 82 | ) { 83 | setCredentials(savedCredentials || {}) 84 | setStatus('success') 85 | return 86 | } 87 | 88 | sanityClient 89 | .fetch(`*[_id == "${documentId}"][0]`) 90 | .then((doc) => { 91 | if (!doc) { 92 | setStatus('missingCredentials') 93 | return 94 | } 95 | setCredentials(doc) 96 | setStatus('success') 97 | }) 98 | .catch(() => setStatus('missingCredentials')) 99 | }, [vendorConfig]) 100 | 101 | return ( 102 | 105 | {props.children} 106 | 107 | ) 108 | } 109 | 110 | export default CredentialsProvider 111 | -------------------------------------------------------------------------------- /packages/core/src/components/documentPreview/DocumentPreview.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sanity-io/sanity/blob/next/packages/sanity/src/desk/components/paneItem/PaneItem.tsx 2 | 3 | import { DocumentIcon } from '@sanity/icons' 4 | import type { SanityDocument } from '@sanity/types' 5 | import type { PropsWithChildren } from 'react' 6 | import React, { useMemo } from 'react' 7 | import type { CollatedHit, FIXME, SchemaType } from 'sanity' 8 | import { 9 | PreviewCard, 10 | useDocumentPresence, 11 | useDocumentPreviewStore, 12 | useSchema, 13 | } from 'sanity' 14 | import { usePaneRouter } from 'sanity/desk' 15 | import { IntentLink } from 'sanity/router' 16 | import { MissingSchemaType } from './MissingSchemaType' 17 | import { PaneItemPreview } from './PaneItemPreview' 18 | 19 | interface DocumentPreviewProps { 20 | schemaType?: SchemaType 21 | documentPair: CollatedHit 22 | placement?: 'input' | 'tool' 23 | } 24 | 25 | /** 26 | * Return `false` if we explicitly disable the icon. 27 | * Otherwise return the passed icon or the schema type icon as a backup. 28 | */ 29 | export function getIconWithFallback( 30 | icon: React.ComponentType | false | undefined, 31 | schemaType: SchemaType | undefined, 32 | defaultIcon: React.ComponentType, 33 | ): React.ComponentType | false { 34 | if (icon === false) { 35 | return false 36 | } 37 | 38 | return ( 39 | icon || ((schemaType && schemaType.icon) as any) || defaultIcon || false 40 | ) 41 | } 42 | 43 | /** When inside the field input, we can open the reference on a child pane */ 44 | function DocumentPreviewInInput( 45 | props: PropsWithChildren, 46 | ) { 47 | const { ChildLink } = usePaneRouter() 48 | 49 | return (linkProps: PropsWithChildren) => ( 50 | 56 | ) 57 | } 58 | 59 | /** When inside the tool, we must use a regular intent link to take users to the desk tool */ 60 | function DocumentPreviewInRool(props: DocumentPreviewProps) { 61 | return (linkProps: PropsWithChildren) => ( 62 | 67 | ) 68 | } 69 | 70 | export function DocumentPreview(props: DocumentPreviewProps) { 71 | const { schemaType, documentPair } = props 72 | const doc = documentPair?.draft || documentPair?.published 73 | const id = documentPair.id || '' 74 | const documentPreviewStore = useDocumentPreviewStore() 75 | const schema = useSchema() 76 | const documentPresence = useDocumentPresence(id) 77 | const hasSchemaType = Boolean( 78 | schemaType && schemaType.name && schema.get(schemaType.name), 79 | ) 80 | 81 | const PreviewComponent = useMemo(() => { 82 | if (!doc) return null 83 | 84 | if (!schemaType || !hasSchemaType) { 85 | return 86 | } 87 | 88 | return ( 89 | 97 | ) 98 | }, [hasSchemaType, schemaType, documentPresence, doc, documentPreviewStore]) 99 | 100 | return ( 101 | 114 | {PreviewComponent} 115 | 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /packages/aws/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { definePlugin } from 'sanity' 2 | import { 3 | StudioTool, 4 | ToolIcon, 5 | VendorConfiguration, 6 | createInput, 7 | getCustomDataSchema, 8 | getDimensionsSchema, 9 | getStoredFileSchema, 10 | } from 'sanity-plugin-external-files' 11 | import deleteFile from './deleteFile' 12 | import { credentialsFields, schemaConfig } from './schema.config' 13 | import uploadFile from './uploadFile' 14 | 15 | const VENDOR_ID = 's3-files' 16 | 17 | export const s3Files = definePlugin((userConfig?: UserConfig) => { 18 | const config = buildConfig(userConfig) 19 | 20 | return { 21 | name: config.schemaPrefix, 22 | schema: { 23 | types: [ 24 | // s3-files.custom-data 25 | getCustomDataSchema(config, schemaConfig), 26 | // s3-files.dimensions 27 | getDimensionsSchema(config), 28 | // s3-files.storedFile 29 | getStoredFileSchema(config, schemaConfig), 30 | { 31 | name: `${config.schemaPrefix}.media`, 32 | title: 'S3 media', 33 | type: 'object', 34 | components: { 35 | input: createInput(config), 36 | }, 37 | fields: [ 38 | { 39 | name: 'asset', 40 | title: 'Asset', 41 | type: 'reference', 42 | to: [{ type: `${config.schemaPrefix}.storedFile` }], 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | tools: [ 49 | { 50 | name: config.schemaPrefix, 51 | title: config.toolTitle, 52 | component: () => , 53 | icon: ToolIcon, 54 | }, 55 | ], 56 | } 57 | }) 58 | 59 | function buildConfig(userConfig: UserConfig = {}): VendorConfiguration { 60 | const userCredentials = userConfig?.credentials || {} 61 | 62 | return { 63 | id: VENDOR_ID, 64 | customDataFieldName: 's3', 65 | defaultAccept: userConfig.defaultAccept, 66 | schemaPrefix: userConfig.schemaPrefix || VENDOR_ID, 67 | toolTitle: userConfig.toolTitle ?? 'Media Library (S3)', 68 | credentialsFields: credentialsFields.filter( 69 | // Credentials already provided by the 70 | (field) => 71 | !userCredentials[field.name] && !(field.name in userCredentials), 72 | ), 73 | deleteFile: (props) => 74 | deleteFile({ 75 | ...props, 76 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 77 | }), 78 | uploadFile: (props) => 79 | uploadFile({ 80 | ...props, 81 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 82 | }), 83 | } 84 | } 85 | 86 | interface UserConfig 87 | extends Pick, 'defaultAccept' | 'schemaPrefix'> { 88 | toolTitle?: string 89 | /** 90 | * @optional 91 | * Credentials for accessing the S3 bucket. 92 | * 93 | * Leave this empty if you don't want to store credentials in the JS bundle of the Sanity studio, and instead prefer storing them in the dataset as a private document. 94 | * If empty, the user will be prompted to enter credentials when they first open the media library. 95 | * 96 | * This configuration can be partial: credentials not provided here will be prompted to be stored inside of Sanity. 97 | * For example, you may want to store the public-facing `bucketKey` and `bucketRegion` in the JS bundle, but keep `secretForValidating` in the Sanity dataset. 98 | */ 99 | credentials?: Partial 100 | } 101 | 102 | export interface S3Credentials { 103 | /** S3 bucket key */ 104 | bucketKey?: string 105 | 106 | /** S3 bucket region */ 107 | bucketRegion?: string 108 | 109 | /** HTTPS endpoint that returns S3's signed URLs for uploading objects from the browser */ 110 | getSignedUrlEndpoint?: string 111 | 112 | /** HTTPS endpoint for deleting an object in S3 */ 113 | deleteObjectEndpoint?: string 114 | 115 | /** 116 | * @optional 117 | * Folder to store files inside the bucket. If none provided, will upload files to the bucket's root. 118 | */ 119 | folder?: string 120 | 121 | /** 122 | * @optional 123 | * Secret for validating the signed URL request (optional) 124 | * 125 | * 🚨 Give preference to storing this value in Sanity by leaving this configuration empty. 126 | * When you populate it here, it'll show up in the JS bundle of the Sanity studio. 127 | */ 128 | secretForValidating?: string 129 | } 130 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Accept } from 'react-dropzone' 2 | import type { SanityDocument, BaseSchemaType, SchemaType } from 'sanity' 3 | 4 | type StrictSanityDocument = Pick< 5 | SanityDocument, 6 | '_id' | '_rev' | '_type' | '_createdAt' | '_updatedAt' 7 | > 8 | 9 | export interface VendorCredentials extends Partial { 10 | [credentialKey: string]: any 11 | } 12 | 13 | /** 14 | * Use this return to cancel your upload, clean observables, etc. 15 | */ 16 | type CleanUpUpload = () => void 17 | 18 | export interface AcceptedCredentialField extends Omit { 19 | type: 'string' | 'number' | 'url' 20 | } 21 | 22 | /** 23 | * Necessary to initialize the input & tool on different vendors 24 | */ 25 | export interface VendorConfiguration { 26 | id: string 27 | customDataFieldName?: string 28 | toolTitle?: string 29 | supportsProgress?: boolean 30 | 31 | /** 32 | * Which files to accept in all instances of this media library, by default. 33 | * 34 | * Can be overwritten on a per-field basis. 35 | */ 36 | defaultAccept?: Accept 37 | 38 | /** 39 | * What prefix to use for the plugin's schema. 40 | * 41 | * Schema types added by the plugin: 42 | * - `${PREFIX}.storedFile` - the file stored. Analogous to Sanity's `sanity.imageAsset`. 43 | * - `${PREFIX}.media` - the field for the user to select a file. Analogous to Sanity's `image` field. 44 | * - `${PREFIX}.dimensions` and `${PREFIX}.custom-data` - internal schemas used by `${PREFIX}.storedFile` to ensure full GraphQL compatibility. 45 | * 46 | * @default `${plugin.id}` // e.g. `firebase-files`, `s3-files`, `digital-ocean-files` 47 | */ 48 | schemaPrefix: string 49 | 50 | /** 51 | * This plugin currently treats all fields as required 52 | */ 53 | credentialsFields: AcceptedCredentialField[] 54 | 55 | /** 56 | * Should return true if file successfully deleted or string with error code / name if failed to delete. 57 | */ 58 | deleteFile: (props: { 59 | storedFile: SanityUpload 60 | credentials: Credentials 61 | }) => Promise 62 | /** 63 | * Function to upload file to 64 | */ 65 | uploadFile: (props: { 66 | file: File 67 | /** 68 | * File name you should use for storage. 69 | * Use this instead of file.name as it respects users' storeOriginalFilename options. 70 | */ 71 | fileName: string 72 | /** 73 | * Credentials as configured by your plugin. 74 | */ 75 | credentials: Credentials 76 | /** 77 | * Inform users about the progress of the upload 78 | */ 79 | updateProgress: (progress: number) => void 80 | /** 81 | * Call this function if upload fails to update the UI 82 | */ 83 | onError: (error?: Error) => void 84 | /** 85 | * Should return a VendorUpload object with data we can populate the SanityUpload document with 86 | */ 87 | onSuccess: (uploadedFile: VendorUpload) => void 88 | }) => CleanUpUpload | undefined 89 | } 90 | 91 | export interface VendorUpload { 92 | fileURL?: string 93 | [vendorId: string]: any 94 | } 95 | 96 | export interface SanityUpload 97 | extends StrictSanityDocument, 98 | Partial, 99 | Partial, 100 | VendorUpload { 101 | _type: string 102 | fileName?: string 103 | contentType?: string 104 | fileSize?: number 105 | /** 106 | * Exclusive to videos 107 | */ 108 | screenshot?: { 109 | _type: 'image' 110 | asset: { 111 | _type: 'reference' 112 | _ref: string 113 | } 114 | } 115 | title?: string 116 | description?: string 117 | } 118 | 119 | export interface AssetReference { 120 | asset: { 121 | _type: 'reference' 122 | _ref: string 123 | } 124 | } 125 | 126 | export type MediaFile = SanityUpload | AssetReference 127 | 128 | export interface AudioMetadata { 129 | /** 130 | * Duration in seconds 131 | */ 132 | duration: number 133 | waveformData?: number[] 134 | } 135 | 136 | interface VideoMetadata { 137 | /** 138 | * Duration in seconds 139 | */ 140 | duration: number 141 | /** 142 | * Dimensions in pixels 143 | */ 144 | dimensions: { 145 | width: number 146 | height: number 147 | } 148 | } 149 | 150 | export type FileMetadata = AudioMetadata | VideoMetadata 151 | 152 | export interface ExternalFileFieldOptions { 153 | accept?: Accept 154 | storeOriginalFilename?: boolean 155 | } 156 | 157 | type CustomField = string | SchemaType 158 | 159 | export interface SchemaConfigOptions { 160 | title?: string 161 | customFields?: CustomField[] 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-external-files 2 | 3 | Series of plugins for working with media files hosted elsewhere inside of Sanity. 4 | 5 | ![Screenshot of the plugin](https://raw.githubusercontent.com/hdoro/sanity-plugin-external-files/main/screenshots.png) 6 | 7 | ## Existing implementations 8 | 9 | List of vendors currently supported: 10 | 11 | - AWS S3 -> [sanity-plugin-s3-files](https://github.com/hdoro/sanity-plugin-external-files/tree/main/packages/aws) 12 | - Google Firebase -> [sanity-plugin-firebase-files](https://github.com/hdoro/sanity-plugin-external-files/tree/main/packages/firebase) 13 | - DigitalOcean Spaces -> [sanity-plugin-digital-ocean-files](https://github.com/hdoro/sanity-plugin-external-files/tree/main/packages/digital-ocean) 14 | - Cloudflare R2 -> [sanity-plugin-r2-files](https://github.com/hdoro/sanity-plugin-external-files/tree/main/packages/cloudflare-r2) 15 | 16 | Use one of the existing implementations or write your own! 17 | 18 | ## Creating your own implementation 19 | 20 | I'm yet to properly document how to create your own implementation, so please reach out if you're looking into doing it! You can get a hold of me at [opensource@hdoro.dev](mailto:opensource@hdoro.dev). 21 | 22 | While that documentation gets sorted out, be sure to take a look at the configuration for the [Firebase plugin](https://github.com/hdoro/sanity-plugin-external-files/blob/main/packages/firebase/src/config.ts) and for the [S3 plugin](https://github.com/hdoro/sanity-plugin-external-files/blob/main/packages/aws/src/config.ts). The core plugin does the heavy lifting: the full implementation of the DigitalOcean plugin is 330 lines of code, including types and documentation ✨ 23 | 24 | ## Roadmap 25 | 26 | From my own standpoint and use cases, this _plugin is feature complete_. 27 | 28 | That said, I'm willing to develop it further given the interest and resources. Here's a list of features and improvements we could pursue: 29 | 30 | - **Synchronizing files** uploaded to vendors outside of Sanity 31 | - ✨ Solves: this would make it possible to have multiple entries to your storage buckets and using Sanity as the single source of truth. A significantly better experience than opening AWS S3's console and managing files there, for example. 32 | - This actually doesn't involve much code on the plugin side. It'd be more about providing a blessed path for implementing webhooks in a simpler way by developers. 33 | - If you already have this demand, just take a look at your used plugin's schema and try to build a handler for new files in your vendor that creates documents in Sanity following that schema. 34 | - Previews for PDFs and other file types 35 | - **New vendors**, such as Supabase and Storj.io 36 | 37 | ## Contributing 38 | 39 | To get the project working locally: 40 | 41 | 1. Install dependencies with `pnpm i` from the root directory 42 | - pnpm@9.11 or higher is required - refer to [their installation guide](https://pnpm.io/installation) 43 | - This project uses [Turborepo](https://turbo.build/repo) as a monorepo manager. From a single dev command you'll be working across all packages and the test studio. 44 | 2. Populate the test studio's `.env` file with your keys, following the `.env.example` file. 45 | - You'll need to create or select a Sanity project 46 | - For each vendor, you'll need to create a bucket and get the keys to connect to it 47 | 3. Run `pnpm dev` from the root directory to start the studio and the development servers for all packages. 48 | 4. Open the test studio at `http://localhost:3333` 49 | - You may need to adjust `test-studio/sanity.config.js` to include a new vendor or remove others while testing a specific one. 50 | 51 | Any changes in the plugins or core package will be picked up by the test studio, with some level of hot module reloading. 52 | 53 | On rules of conduct, I'm a newbie with collaborating on open-source, so no strict rules here other than **being respectful and considerate**. 54 | 55 | ### Cutting new releases 56 | 57 | 1. Run `pnpm run build` to build the packages to ensure builds are working and there are no Typescript errors 58 | 2. Bump the packages' versions via [changesets](https://github.com/changesets/changesets) by running `pnpm run changeset` 59 | 3. Run `pnpm run update-package-versions` to ensure all packages' versions are correctly updated 60 | 4. Run `pnpm run publish-packages` to publish the packages 61 | 62 | ## Acknowledgments 63 | 64 | Immense gratitude to Akash Reddy and the folks at Playy.co for sponsoring the initial work for this plugin and helping shape it. You gave me the first opportunity to do paid open-source work and this won't be forgotten 💚 65 | 66 | Also shout-out to Daniel, José and the great folks at [Bürocratik](https://burocratik.com/) for sponsoring the Sanity V3 upgrade of this plugin. 67 | -------------------------------------------------------------------------------- /packages/digital-ocean/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { definePlugin } from 'sanity' 2 | import { 3 | StudioTool, 4 | ToolIcon, 5 | VendorConfiguration, 6 | createInput, 7 | getCustomDataSchema, 8 | getDimensionsSchema, 9 | getStoredFileSchema, 10 | } from 'sanity-plugin-external-files' 11 | import deleteFile from './deleteFile' 12 | import { credentialsFields, schemaConfig } from './schema.config' 13 | import uploadFile from './uploadFile' 14 | 15 | const VENDOR_ID = 'digital-ocean-files' 16 | 17 | export const digitalOceanFiles = definePlugin((userConfig?: UserConfig) => { 18 | const config = buildConfig(userConfig) 19 | return { 20 | name: config.schemaPrefix, 21 | schema: { 22 | types: [ 23 | // digital-ocean-files.custom-data 24 | getCustomDataSchema(config, schemaConfig), 25 | // digital-ocean-files.dimensions 26 | getDimensionsSchema(config), 27 | // digital-ocean-files.storedFile 28 | getStoredFileSchema(config, schemaConfig), 29 | { 30 | name: `${config.schemaPrefix}.media`, 31 | title: 'Digital Ocean media', 32 | type: 'object', 33 | components: { 34 | input: createInput(config), 35 | }, 36 | fields: [ 37 | { 38 | name: 'asset', 39 | title: 'Asset', 40 | type: 'reference', 41 | to: [{ type: `${config.schemaPrefix}.storedFile` }], 42 | validation: (Rule) => Rule.required(), 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | tools: [ 49 | { 50 | name: config.schemaPrefix, 51 | title: config.toolTitle, 52 | component: () => , 53 | icon: ToolIcon, 54 | }, 55 | ], 56 | } 57 | }) 58 | 59 | function buildConfig(userConfig: UserConfig = {}): VendorConfiguration { 60 | const userCredentials = userConfig?.credentials || {} 61 | return { 62 | id: VENDOR_ID, 63 | customDataFieldName: 'digitalOcean', 64 | defaultAccept: userConfig.defaultAccept, 65 | schemaPrefix: userConfig.schemaPrefix || VENDOR_ID, 66 | toolTitle: userConfig.toolTitle ?? 'Media Library (DigitalOcean)', 67 | credentialsFields: credentialsFields.filter( 68 | // Credentials already provided by the 69 | (field) => 70 | !userCredentials[field.name] && !(field.name in userCredentials), 71 | ), 72 | deleteFile: (props) => 73 | deleteFile({ 74 | ...props, 75 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 76 | }), 77 | uploadFile: (props) => 78 | uploadFile({ 79 | ...props, 80 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 81 | }), 82 | } 83 | } 84 | 85 | export interface DigitalOceanCredentials { 86 | /** ID of the Space in DigitalOcean */ 87 | bucketKey?: string 88 | 89 | /** Space (bucket) region */ 90 | bucketRegion?: string 91 | 92 | /** HTTPS endpoint that returns signed URLs for uploading objects from the browser */ 93 | getSignedUrlEndpoint?: string 94 | 95 | /** HTTPS endpoint for deleting an object in Space */ 96 | deleteObjectEndpoint?: string 97 | 98 | /** Folder to store files inside the space. If none provided, will upload files to the Space's root. */ 99 | folder?: string 100 | 101 | /** 102 | * @optional 103 | * If none provided, will fallback to DigitalOcean's default 104 | */ 105 | subdomain?: string 106 | 107 | /** 108 | * @optional 109 | * Secret for validating the signed URL request (optional) 110 | * 111 | * 🚨 Give preference to storing this value in Sanity by leaving this configuration empty. 112 | * When you populate it here, it'll show up in the JS bundle of the Sanity studio. 113 | */ 114 | secretForValidating?: string 115 | } 116 | 117 | interface UserConfig 118 | extends Pick, 'defaultAccept' | 'schemaPrefix'> { 119 | toolTitle?: string 120 | 121 | /** 122 | * @optional 123 | * Credentials for accessing the DigitalOcean Space. 124 | * 125 | * Leave this empty if you don't want to store credentials in the JS bundle of the Sanity studio, and instead prefer storing them in the dataset as a private document. 126 | * If empty, the user will be prompted to enter credentials when they first open the media library. 127 | * 128 | * This configuration can be partial: credentials not provided here will be prompted to be stored inside of Sanity. 129 | * For example, you may want to store the public-facing `bucketKey` and `bucketRegion` in the JS bundle, but keep `secretForValidating` in the Sanity dataset. 130 | */ 131 | credentials?: Partial 132 | } 133 | -------------------------------------------------------------------------------- /packages/aws/src/uploadFile.ts: -------------------------------------------------------------------------------- 1 | import { VendorConfiguration } from 'sanity-plugin-external-files' 2 | import { S3Credentials } from '.' 3 | 4 | const uploadFile: VendorConfiguration['uploadFile'] = ({ 5 | credentials, 6 | onError, 7 | onSuccess, 8 | file, 9 | fileName, 10 | }) => { 11 | if ( 12 | !credentials || 13 | typeof credentials.getSignedUrlEndpoint !== 'string' || 14 | typeof credentials.bucketKey !== 'string' || 15 | !URL.canParse(credentials.getSignedUrlEndpoint) || 16 | !credentials.bucketKey 17 | ) { 18 | onError({ 19 | name: 'missing-credentials', 20 | message: 'Missing correct credentials', 21 | }) 22 | } 23 | 24 | const filePath = [credentials.folder, fileName] 25 | .filter(Boolean) 26 | .join('/') 27 | .replace(/\s/g, '-') 28 | 29 | // On cancelling fetch: https://davidwalsh.name/cancel-fetch 30 | let signal: AbortSignal | undefined 31 | let controller: AbortController | undefined 32 | try { 33 | controller = new AbortController() 34 | signal = controller.signal 35 | } catch (error) {} 36 | 37 | const endpoint = credentials.getSignedUrlEndpoint as string 38 | fetch(endpoint, { 39 | method: 'POST', 40 | body: JSON.stringify({ 41 | fileName: filePath, 42 | contentType: file.type, 43 | secret: credentials.secretForValidating, 44 | bucketKey: credentials.bucketKey, 45 | bucketRegion: credentials.bucketRegion, 46 | }), 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | }, 50 | mode: 'cors', 51 | signal, 52 | }) 53 | .then((response) => response.json()) 54 | .then(({ url, fields }) => { 55 | if (!url || !URL.canParse(url)) { 56 | onError({ 57 | message: 58 | "Ask your developer to rectify the AWS Lambda function that returns the asset's pre-signed url.", 59 | name: 'incorrect-presigned', 60 | }) 61 | } 62 | 63 | const fileKey = fields?.key || filePath 64 | let presignedPromise: Promise 65 | 66 | /** =================================== 67 | * OLDER VERSION OF THE LAMBDA FUNCTION 68 | * 69 | * the presigned URLs generated with the old `aws-sdk` returned a `fields` object 70 | * and expected a POST request with those fields in the `formData` 71 | */ 72 | if (fields) { 73 | const data = { 74 | bucket: credentials.bucketKey, 75 | ...fields, 76 | 'Content-Type': file.type, 77 | file, 78 | } 79 | 80 | const formData = new FormData() 81 | for (const name in data) { 82 | formData.append(name, data[name]) 83 | } 84 | presignedPromise = fetch(url, { 85 | method: 'POST', 86 | body: formData, 87 | mode: 'cors', 88 | signal, 89 | }) 90 | } else { 91 | /** ==================================== 92 | * NEWER VERSIONS OF THE LAMBDA FUNCTION 93 | * 94 | * `@aws-sdk/s3-request-presigner` returns a single URL string and expects PUT requests with 95 | * the file to be uploaded as the binary body. It also requires an explicit `Content-Type` header. 96 | */ 97 | presignedPromise = fetch(url, { 98 | method: 'PUT', 99 | body: file, 100 | mode: 'cors', 101 | headers: { 102 | 'Content-Type': file.type, 103 | }, 104 | signal, 105 | }) 106 | } 107 | 108 | presignedPromise 109 | .then((res) => { 110 | if (res.ok) { 111 | onSuccess({ 112 | fileURL: `https://s3.${credentials.bucketRegion}.amazonaws.com/${credentials.bucketKey}/${fileKey}`, 113 | s3: { 114 | key: fileKey, 115 | bucket: credentials.bucketKey, 116 | region: credentials.bucketRegion, 117 | }, 118 | }) 119 | } else { 120 | console.log({ 121 | objectPostFaultyResponse: res, 122 | }) 123 | onError({ 124 | message: 'Ask your developer to check AWS permissions.', 125 | name: 'failed-presigned', 126 | }) 127 | } 128 | }) 129 | .catch((error) => { 130 | console.log({ objectPostError: error }) 131 | onError(error) 132 | }) 133 | }) 134 | .catch((error) => { 135 | onError({ 136 | message: 'Ask your developer to check the AWS Lambda function.', 137 | name: 'failed-presigned', 138 | cause: error?.cause, 139 | stack: error?.stack, 140 | }) 141 | }) 142 | return () => { 143 | try { 144 | if (controller?.abort) { 145 | controller.abort() 146 | } 147 | } catch (error) {} 148 | } 149 | } 150 | 151 | export default uploadFile 152 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { definePlugin } from 'sanity' 2 | import { 3 | StudioTool, 4 | ToolIcon, 5 | VendorConfiguration, 6 | createInput, 7 | getCustomDataSchema, 8 | getDimensionsSchema, 9 | getStoredFileSchema, 10 | } from 'sanity-plugin-external-files' 11 | import deleteFile from './deleteFile' 12 | import { credentialsFields, schemaConfig } from './schema.config' 13 | import uploadFile from './uploadFile' 14 | 15 | const VENDOR_ID = 'r2-files' 16 | 17 | export const cloudflareR2Files = definePlugin((userConfig?: UserConfig) => { 18 | const config = buildConfig(userConfig) 19 | return { 20 | name: config.schemaPrefix, 21 | schema: { 22 | types: [ 23 | // r2-files.custom-data 24 | getCustomDataSchema(config, schemaConfig), 25 | // r2-files.dimensions 26 | getDimensionsSchema(config), 27 | // r2-files.storedFile 28 | getStoredFileSchema(config, schemaConfig), 29 | { 30 | name: `${config.schemaPrefix}.media`, 31 | title: 'Cloudflare R2 Media', 32 | type: 'object', 33 | components: { 34 | input: createInput(config), 35 | }, 36 | fields: [ 37 | { 38 | name: 'asset', 39 | title: 'Asset', 40 | type: 'reference', 41 | to: [{ type: `${config.schemaPrefix}.storedFile` }], 42 | validation: (Rule) => Rule.required(), 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | tools: [ 49 | { 50 | name: config.schemaPrefix, 51 | title: config.toolTitle, 52 | component: () => , 53 | icon: ToolIcon, 54 | }, 55 | ], 56 | } 57 | }) 58 | 59 | function buildConfig(userConfig: UserConfig = {}): VendorConfiguration { 60 | const userCredentials = userConfig?.credentials || {} 61 | return { 62 | id: VENDOR_ID, 63 | customDataFieldName: 'cloudflareR2', 64 | defaultAccept: userConfig.defaultAccept, 65 | schemaPrefix: userConfig.schemaPrefix || VENDOR_ID, 66 | toolTitle: userConfig.toolTitle ?? 'Media Library (Cloudflare R2)', 67 | credentialsFields: credentialsFields.filter( 68 | // Credentials already provided by the 69 | (field) => 70 | !userCredentials[field.name] && !(field.name in userCredentials), 71 | ), 72 | deleteFile: (props) => 73 | deleteFile({ 74 | ...props, 75 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 76 | }), 77 | uploadFile: (props) => 78 | uploadFile({ 79 | ...props, 80 | credentials: { ...userCredentials, ...(props.credentials || {}) }, 81 | }), 82 | } 83 | } 84 | 85 | export interface CloudflareR2Credentials { 86 | /** 87 | * ## `workerUrl` 88 | * - URL of the Cloudflare Worker that handles the signed URL requests for uploading files to the R2 Bucket. 89 | * - Should accept PUT, DELETE and OPTIONS requests. 90 | * - Example: `https://..workers.dev` 91 | */ 92 | workerUrl?: string 93 | 94 | /** 95 | * ## `url` 96 | * - Public url of the bucket. Either enable R2.dev Subdomain or configure Cloudflare Custom Public Domain. 97 | * - Example: `https://pub-.r2.dev` 98 | */ 99 | url?: string 100 | 101 | /** 102 | * ## `folder` 103 | * @optional 104 | * - Folder to store files inside the R2 Bucket. If none provided, will upload files to the R2 Bucket's root. 105 | * - Example: `images`, `videos`, `documents`, etc. 106 | */ 107 | folder?: string 108 | 109 | /** 110 | * ## `secret` 111 | * @optional 112 | * Secret for validating the signed URL request (optional) 113 | * Must be kept private and not shared with anyone. 114 | * Must be passed to the server for validating the signed URL request, e. g. the Cloudflare Worker. 115 | * 116 | * 🚨 Give preference to storing this value in Sanity by leaving this configuration empty. 117 | * When you populate it here, it'll show up in the JS bundle of the Sanity studio. 118 | */ 119 | secret?: string 120 | } 121 | 122 | interface UserConfig 123 | extends Pick, 'defaultAccept' | 'schemaPrefix'> { 124 | toolTitle?: string 125 | 126 | /** 127 | * @optional 128 | * Credentials for accessing the Cloudflare R2 Bucekt. 129 | * 130 | * Leave this empty if you don't want to store credentials in the JS bundle of the Sanity studio, and instead prefer storing them in the dataset as a private document. 131 | * If empty, the user will be prompted to enter credentials when they first open the media library. 132 | * 133 | * This configuration can be partial: credentials not provided here will be prompted to be stored inside of Sanity. 134 | * For example, you may want to store the public-facing `url` in the JS bundle, but keep `secret` in the Sanity dataset. 135 | */ 136 | credentials?: Partial 137 | } 138 | -------------------------------------------------------------------------------- /packages/core/src/components/Browser/browserMachine.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, assign } from 'xstate' 2 | import { SanityUpload } from '../../types' 3 | 4 | interface Context { 5 | allFiles?: SanityUpload[] 6 | filteredFiles?: SanityUpload[] 7 | fileToEdit?: SanityUpload 8 | searchTerm?: string 9 | } 10 | 11 | type BrowserEvent = 12 | // BROWSING 13 | | { type: 'OPEN_SETTINGS' } 14 | | { type: 'OPEN_UPLOAD' } 15 | | { type: 'EDIT_FILE'; file: SanityUpload } 16 | | { type: 'SEARCH_TERM'; term: string } 17 | // UPLOADING 18 | | { type: 'CLOSE_UPLOAD' } 19 | | { type: 'UPLOADED'; file: SanityUpload } 20 | // EDITING FILE (details dialog) 21 | | { type: 'CLEAR_FILE' } 22 | | { type: 'PERSIST_FILE_SAVE'; file: SanityUpload } 23 | | { type: 'PERSIST_FILE_DELETION'; file: SanityUpload } 24 | // EDITING SETTINGS DIALOG 25 | | { type: 'CLOSE_SETTINGS' } 26 | 27 | const browserMachine = createMachine( 28 | { 29 | id: 'browser-machine', 30 | initial: 'loading', 31 | states: { 32 | loading: { 33 | invoke: { 34 | id: 'FetchFiles', 35 | src: 'fetchFiles', 36 | onDone: { 37 | target: 'browsing', 38 | actions: [ 39 | // console.log, 40 | assign({ 41 | allFiles: (_context, event) => event.data, 42 | filteredFiles: (_context, event) => event.data, 43 | }), 44 | ], 45 | }, 46 | }, 47 | }, 48 | browsing: { 49 | on: { 50 | SEARCH_TERM: { 51 | actions: [ 52 | assign({ 53 | searchTerm: (_context, event) => event.term, 54 | }), 55 | 'filterFiles', 56 | ], 57 | }, 58 | EDIT_FILE: { 59 | target: 'editingFile', 60 | actions: assign({ 61 | fileToEdit: (_context, event) => event.file, 62 | }), 63 | }, 64 | OPEN_UPLOAD: 'uploading', 65 | OPEN_SETTINGS: 'editingSettings', 66 | }, 67 | }, 68 | uploading: { 69 | on: { 70 | CLOSE_UPLOAD: 'browsing', 71 | UPLOADED: { 72 | target: 'editingFile', 73 | actions: [ 74 | assign((context, event) => { 75 | const newFiles = [event.file, ...(context.allFiles || [])] 76 | return { 77 | // After upload is done: 78 | // 1. open the file selection dialog for this entry 79 | fileToEdit: event.file, 80 | // 2. Reset search 81 | searchTerm: '', 82 | // 3. Default to allFiles & filteredFiles 83 | allFiles: newFiles, 84 | filteredFiles: newFiles, 85 | } 86 | }), 87 | ], 88 | }, 89 | }, 90 | }, 91 | editingFile: { 92 | on: { 93 | CLEAR_FILE: { 94 | target: 'browsing', 95 | actions: assign({ 96 | fileToEdit: (_context) => undefined, 97 | }), 98 | }, 99 | }, 100 | }, 101 | editingSettings: { 102 | on: { 103 | CLOSE_SETTINGS: { 104 | target: 'browsing', 105 | }, 106 | }, 107 | }, 108 | }, 109 | on: { 110 | PERSIST_FILE_SAVE: { 111 | actions: assign({ 112 | allFiles: (context, event) => { 113 | return context.allFiles?.map((file) => { 114 | if (file._id === event.file?._id) { 115 | return event.file 116 | } 117 | return file 118 | }) 119 | }, 120 | filteredFiles: (context, event) => { 121 | return context.filteredFiles?.map((file) => { 122 | if (file._id === event.file?._id) { 123 | return event.file 124 | } 125 | return file 126 | }) 127 | }, 128 | }), 129 | }, 130 | PERSIST_FILE_DELETION: { 131 | actions: assign({ 132 | allFiles: (context, event) => 133 | context.allFiles?.filter((file) => { 134 | if (file._id === event.file?._id) { 135 | return false 136 | } 137 | return true 138 | }), 139 | filteredFiles: (context, event) => 140 | context.filteredFiles?.filter((file) => { 141 | if (file._id === event.file?._id) { 142 | return false 143 | } 144 | return true 145 | }), 146 | }), 147 | }, 148 | }, 149 | }, 150 | { 151 | actions: { 152 | filterFiles: assign({ 153 | filteredFiles: (context, event) => { 154 | if (event.type !== 'SEARCH_TERM' || typeof event.term !== 'string') { 155 | return context.filteredFiles 156 | } 157 | 158 | const filtered = (context.allFiles || []).filter( 159 | (file) => 160 | file.fileName?.toLowerCase().includes(event.term) || 161 | file.description?.toLowerCase().includes(event.term) || 162 | file.title?.toLowerCase().includes(event.term), 163 | ) 164 | return filtered 165 | }, 166 | }), 167 | }, 168 | }, 169 | ) 170 | 171 | export default browserMachine 172 | -------------------------------------------------------------------------------- /packages/core/src/components/Uploader/useUpload.tsx: -------------------------------------------------------------------------------- 1 | import { SanityImageAssetDocument } from '@sanity/client' 2 | import { useToast } from '@sanity/ui' 3 | import { useMachine } from '@xstate/react' 4 | import React from 'react' 5 | import { DropzoneState, useDropzone } from 'react-dropzone' 6 | import { StateFrom } from 'xstate' 7 | import getBasicFileMetadata from '../../scripts/getBasicMetadata' 8 | import getFileRef from '../../scripts/getFileRef' 9 | import { useSanityClient } from '../../scripts/sanityClient' 10 | import { SanityUpload } from '../../types' 11 | import { CredentialsContext } from '../Credentials/CredentialsProvider' 12 | import { UploaderProps } from './Uploader' 13 | import uploadMachine from './uploadMachine' 14 | 15 | export interface useUploadReturn { 16 | dropzone: DropzoneState 17 | state: StateFrom 18 | cancelUpload: () => void 19 | retry: () => void 20 | } 21 | 22 | const useUpload = ({ 23 | accept, 24 | vendorConfig, 25 | storeOriginalFilename = true, 26 | onSuccess, 27 | removeFile, 28 | }: UploaderProps): useUploadReturn => { 29 | const toast = useToast() 30 | const { credentials } = React.useContext(CredentialsContext) 31 | const sanityClient = useSanityClient() 32 | const [state, send] = useMachine(uploadMachine, { 33 | actions: { 34 | invalidFileToast: () => 35 | toast.push({ 36 | title: `Invalid file type uploaded`, 37 | status: 'error', 38 | }), 39 | }, 40 | services: { 41 | uploadToVendor: (context) => (callback) => { 42 | if (!context.file?.name || !vendorConfig?.uploadFile || !credentials) { 43 | callback({ type: 'CANCEL_INPUT' }) 44 | return 45 | } 46 | 47 | const cleanUp = vendorConfig.uploadFile({ 48 | credentials, 49 | file: context.file, 50 | fileName: getFileRef({ 51 | file: context.file as File, 52 | storeOriginalFilename, 53 | }), 54 | onError: (error) => 55 | callback({ 56 | type: 'VENDOR_ERROR', 57 | error, 58 | }), 59 | updateProgress: (progress) => 60 | callback({ type: 'VENDOR_PROGRESS', data: progress }), 61 | onSuccess: (uploadedFile) => 62 | callback({ 63 | type: 'VENDOR_DONE', 64 | data: uploadedFile, 65 | }), 66 | }) 67 | 68 | return () => { 69 | if (typeof cleanUp === 'function') { 70 | cleanUp() 71 | } 72 | } 73 | }, 74 | uploadToSanity: (context) => { 75 | if (!context?.vendorUpload?.fileURL || !context?.file) { 76 | return new Promise((_resolve, reject) => 77 | reject('Invalid Vendor upload'), 78 | ) 79 | } 80 | return new Promise(async (resolve, reject) => { 81 | let screenshot: SanityImageAssetDocument | undefined 82 | if (context.videoScreenshot?.type === 'image/png') { 83 | try { 84 | screenshot = await sanityClient.assets.upload( 85 | 'image', 86 | context.videoScreenshot, 87 | { 88 | source: { 89 | id: `${vendorConfig.schemaPrefix}`, 90 | name: `${vendorConfig.schemaPrefix} (external DAM)`, 91 | }, 92 | filename: getFileRef({ 93 | file: context.file as File, 94 | storeOriginalFilename, 95 | }), 96 | }, 97 | ) 98 | } catch (error) { 99 | reject('Failed to save image') 100 | } 101 | } 102 | try { 103 | const document = await sanityClient.create({ 104 | _type: `${vendorConfig.schemaPrefix}.storedFile`, 105 | screenshot: screenshot 106 | ? { 107 | _type: 'image', 108 | asset: { 109 | _type: 'reference', 110 | _ref: screenshot?._id, 111 | }, 112 | } 113 | : undefined, 114 | ...getBasicFileMetadata({ 115 | file: context.file as File, 116 | storeOriginalFilename, 117 | }), 118 | ...context.vendorUpload, 119 | ...(context.formatMetadata || {}), 120 | } as SanityUpload) 121 | 122 | resolve(document) 123 | } catch (error) { 124 | reject('Failed to create document') 125 | } 126 | }) 127 | }, 128 | }, 129 | devTools: true, 130 | }) 131 | 132 | const dropzone = useDropzone({ 133 | onDrop: (acceptedFiles) => { 134 | send({ 135 | type: 'SELECT_FILE', 136 | file: acceptedFiles?.[0], 137 | }) 138 | removeFile?.() 139 | }, 140 | accept, 141 | // Only allow 1 file to be uploaded 142 | maxFiles: 1, 143 | }) 144 | 145 | function cancelUpload() { 146 | send({ 147 | type: 'CANCEL_INPUT', 148 | }) 149 | } 150 | 151 | function retry() { 152 | send({ 153 | type: 'RETRY', 154 | }) 155 | } 156 | 157 | React.useEffect(() => { 158 | if (state.value === 'success' && state.context.sanityUpload && onSuccess) { 159 | onSuccess(state.context.sanityUpload) 160 | send('RESET_UPLOAD') 161 | } 162 | }, [state.value]) 163 | 164 | return { 165 | dropzone, 166 | state, 167 | cancelUpload, 168 | retry, 169 | } 170 | } 171 | 172 | export default useUpload 173 | -------------------------------------------------------------------------------- /packages/aws/README.md: -------------------------------------------------------------------------------- 1 | # AWS S3 Digital Asset Management (DAM) plugin for Sanity.io 2 | 3 | Allows uploading, referencing and deleting video and audio files to S3 directly from your Sanity studio. Is a flavor of [sanity-plugin-external-files](https://github.com/hdoro/sanity-plugin-external-files). 4 | 5 | ![Screenshot of the plugin](https://raw.githubusercontent.com/hdoro/sanity-plugin-external-files/main/screenshots.png) 6 | 7 | ## Installing 8 | 9 | Start by installing the plugin: 10 | 11 | `sanity install s3-dam` 12 | 13 | The rest of the work must be done inside AWS' console. The video below is a full walkthrough, be sure to watch from start to finish to avoid missing small details that are hard to debug. 14 | 15 | [![Video screenshot](https://img.youtube.com/vi/O4j2fEDVeVw/0.jpg)](https://www.youtube.com/watch?v=O4j2fEDVeVw) 16 | 17 | ### Creating the S3 bucket 18 | 19 | If you already have a bucket, make sure to follow the configuration below. 20 | 21 | 1. Go into the console homepage for S3 and click on "Create bucket" 22 | 1. Choose a name and region as you see fit 23 | 1. "Object Ownership": ACL enabled & "Object writer" 24 | 1. Untick "Block all public access" 25 | 1. Disable "Bucket Versioning" 26 | 1. Disable "Default encryption" 27 | 1. Once created, click into the bucket's page and go into the "Permissions" tab to configure CORS 28 | 1. Configure CORS for your bucket to accept the origins your studio will be hosted in (including localhost) 29 | - Refer to [S3's guide on CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) if this is new to you (it was for me too!) 30 | - You can use the template at [s3Cors.example.json](https://github.com/hdoro/sanity-plugin-external-files/blob/main/packages/aws/s3Cors.example.json) 31 | - Be sure to allow CORS for both PUT and POST requests 32 | 33 | ### Creating the Lambda function's role for accessing the bucket 34 | 35 | 1. Go into the Identity and Access Management (IAM) console 36 | 1. Start by going into the "Access management -> Policies" tab and "Create new Policy" 37 | 1. In the "Create Policy" visual editor 38 | 1. choose S3 as the "Service" 39 | 1. Select the proper "Actions" 40 | - getSignedUrl needs **"Write->PutObject"** and **"Permissions Management->PutObjectAcl"** 41 | - deleteObject needs **"Write->DeleteObject"** 42 | 1. In "Resources" 43 | - "Specific" 44 | - Click on "Add ARN to restrict access" 45 | - Fill in the bucket name and "\*" for the object's name (or click on "Any") 46 | - Or use the ARN (Amazon Resource Name) of your bucket (find it under the bucket's properties tab) with an `/*` appended to it 47 | 1. Leave "Request conditions" empty 48 | 1. Create the policy 49 | 1. With the policy created, go into "Access management -> Roles" and "Create role" 50 | 1. "Trusted entity type": AWS Service 51 | 1. "Use case": Lambda 52 | 1. In "Add permissions", select the policy you created above 53 | 1. Name your role 54 | 1. Leave "Step 1: Select trusted entities" as is 55 | 1. Create the role 56 | 57 | ### Creating the Lambda function 58 | 59 | You'll need to create a Lambda function, which will create signed URLs for posting objects, and handle object deletion. Follow the steps below: 60 | 61 | #### Configuring functions' HTTP access 62 | 63 | 1. Go into the Lambda console 64 | 1. "Create function" 65 | 1. "Author from scratch" 66 | 1. Runtime: Node.js 20.x or higher 67 | 1. Architecture: your call - I'm using x86_64 68 | 1. "Permissions" -> "Change default execution role" -> "Use an existing role" 69 | - Select the role you created above 70 | 1. "Advanced settings" -> "Enable function URL" 71 | - "Auth type": NONE 72 | - Question:: is there a better way to do this? 73 | - Check "Configure cross-origin resource sharing (CORS)" 74 | - "Allow headers": content-type 75 | - "Allow methods": \* 76 | 1. Create the function 77 | 1. Open the function's page and, under the "Configuration" tab, select "Function URL" in the sidebar 78 | 1. Set "content-type" as an "Allowed Headers" and set "Allowed Methods" to "\*". 79 | 1. Save the new configuration 80 | 81 | Now we can change the source code of the function: 82 | 83 | #### Editing functions' code 84 | 85 | 💡 Use the template at [lambda.example.mjs](https://github.com/hdoro/sanity-plugin-external-files/blob/main/packages/aws/lambda.example.mjs). 86 | 87 | With the functions' URL in hand - which you can find in the Lambda dashboard -, open the plugin's configuration form in the Sanity tool, or modify the plugin's config in `sanity.config`. 88 | 89 | There, you'll fill in the bucket key (ex: `my-sanity-bucket`), the bucket region (ex: `ap-south-1`), the endpoints for create/delete operations (re-use the URL of the function created above) and an optional secret for validating input in functions. 90 | 91 | ## Using 92 | 93 | Now that everything is configured and you've tested uploading and deleting files via the plugin's studio tool, use the `s3-files.media` type in your schema to reference content from S3. Examples: 94 | 95 | ``` 96 | { 97 | name: "video", 98 | title: "Video (S3)", 99 | type: "s3-files.media", 100 | options: { 101 | accept: "video/*", 102 | storeOriginalFilename: true, 103 | }, 104 | }, 105 | { 106 | name: "anyFile", 107 | title: "File (S3)", 108 | type: "s3-files.media", 109 | options: { 110 | // Accept ANY file 111 | accept: "*", 112 | storeOriginalFilename: true, 113 | }, 114 | }, 115 | ``` 116 | 117 | ## Contributing, roadmap & acknowledgments 118 | 119 | Refer to [sanity-plugin-external-files](https://github.com/hdoro/sanity-plugin-external-files) for those :) 120 | -------------------------------------------------------------------------------- /packages/cloudflare-r2/README.md: -------------------------------------------------------------------------------- 1 | ## [`Sanity.io`](https://sanity.io) - [`Cloudflare R2`](https://www.cloudflare.com/de-de/developer-platform/r2/) 2 | 3 | Allows uploading, referencing and deleting files to Cloudflare R2 directly from your Sanity studio. Is a flavor of [sanity-plugin-external-files](https://github.com/hdoro/sanity-plugin-external-files). 4 | 5 | ![Screenshot of the plugin](https://raw.githubusercontent.com/hdoro/sanity-plugin-external-files/main/screenshots.png) 6 | 7 | ## Why Cloudflare? 8 | 9 | - **Cost-effective**: Cloudflare R2 is a cost-effective solution for storing large files. You only pay for what you use. No egress fees. 10 | - **Fast**: Cloudflare R2 is built on Cloudflare's global network, making it fast to upload and download files. 11 | - **Secure**: Cloudflare R2 is built on Cloudflare's security-first architecture, making it secure by default. 12 | - **Simplicity**: Cloudflare R2 is easy to set up and use. 13 | 14 | ## Usage 15 | 16 | 1. [Configure Cloudflare R2 Bucket](#configuring-the-cloudflare-r2-bucket) 17 | 2. [Configure Sanity Studio](#configuring-sanity-studio) 18 | 19 | ## Configuring Cloudflare 20 | 21 | 1. Create Cloudflare Account [here](https://dash.cloudflare.com/sign-up) 22 | 2. Create a new R2 Bucket (e. g. `sanity-media`) 23 | 3. Either [use the R2.dev public domain](#cloudflare-r2-bucket-with-r2dev-public-domain) or [add your custom domain](#cloudflare-r2-bucket-with-custom-public-domain) 24 | 4. Deploy the Cloudflare Worker [as described below](#deploy-cloudflare-worker) 25 | 5. Add the worker URL to your plugin configuration (`workerUrl`) 26 | 6. Add the R2 Bucket URL (either R2.dev subdomain or custom domain) to your plugin configuration (`url`) 27 | 28 | ### Deploy Cloudflare Worker 29 | 30 | The plugin requires a Cloudflare Worker to handle the file uploads and deletions. You can find the code for the worker in the `worker` directory of this repository. 31 | This is required because Sanity Studio doesn't support any server-side logic. 32 | 33 | 1. Install the [Wrangler CLI](https://developers.cloudflare.com/workers/cli-wrangler/install-update) 34 | 2. Login to your Cloudflare account by running `wrangler login` 35 | 3. `git clone` this repository (`git clone https://github.com/hdoro/sanity-plugin-external-files`) 36 | 4. `cd` into the `worker` directory (`cd packages/cloudflare-r2/worker`) 37 | 5. Adjust the `wrangler.toml` file and configure `ALLOWED_ORIGINS` and `bucket_name` to match your setup 38 | 6. Add `SECRET` as Cloudflare Secret as described [here](https://developers.cloudflare.com/workers/configuration/secrets/#adding-secrets-to-your-project) (e. g. `SECRET=your-secret`) 39 | 7. Run `wrangler publish` to deploy the worker 40 | 8. Copy the worker URL from the output and add it to your plugin configuration 41 | 42 | ### Cloudflare R2 Bucket with R2.dev Public Domain 43 | 44 | 1. Login to your Cloudflare account [here](https://dash.cloudflare.com/) 45 | 2. Go to "R2" and either create a new bucket or choose your existing one (e. g. `sanity-media`) 46 | 3. Go to "Settings" and choose "R2.dev subdomain" 47 | 4. Hit "Enable" 48 | 49 | ### Cloudflare R2 Bucket with Custom Public Domain 50 | 51 | 1. Login to your Cloudflare account [here](https://dash.cloudflare.com/) 52 | 2. Go to "Website" and choose "Add domain" (e. g. `example.com`) 53 | 3. Follow the instructions to add your domain 54 | 4. Go to "R2" and either create a new bucket or choose your existing one (e. g. `sanity-media`) 55 | 5. Go to "Settings" and choose "Custom domain" 56 | 6. Add your custom domain (or subdomain) by entering it and follow the instructions to add the necessary DNS records 57 | 58 | ## Configuring Sanity Studio 59 | 60 | 1. Install the plugin `sanity-plugin-r2-files` by running: 61 | 62 | ```bash 63 | npm i sanity-plugin-r2-files 64 | # or yarn / pnpm / bun 65 | ``` 66 | 67 | 2. Include the plugin in your `sanity.config.(js|ts)`: 68 | 69 | ```js 70 | import { cloudflareR2Files } from 'sanity-plugin-r2-files' 71 | import { defineConfig } from 'sanity' 72 | 73 | export default defineConfig({ 74 | plugins: [ 75 | cloudflareR2Files({ 76 | toolTitle: 'Media Library', 77 | credentials: { 78 | url: 'https://.r2.dev', 79 | workerUrl: 'https://..workers.dev', 80 | }, 81 | }), 82 | ], 83 | }) 84 | ``` 85 | 86 | 3. And use its `r2-files.media` type in schemas you want to use Cloudflare R2 files from: 87 | 88 | ```js 89 | export default { 90 | name: 'caseStudy', 91 | type: 'document', 92 | fields: [ 93 | { 94 | name: 'featuredVideo', 95 | type: 'r2-files.media', 96 | options: { 97 | accept: { 98 | 'video/*': ['mp4', 'webm', 'mov'], 99 | }, 100 | }, 101 | }, 102 | ], 103 | } 104 | ``` 105 | 106 | ## Data structure & querying 107 | 108 | Each media item is a Sanity document that holds information of the object stored in Cloudflare R2, like its `fileURL`, `contentType` and `fileSize`. It's analogous to Sanity's `sanity.imageAsset` and `sanity.fileAsset`: they're pointers to the actual blob, not the files themselves. 109 | 110 | These files' type is `r2-files.storedFile`. 111 | 112 | When selected by other document types, media is stored as references to these file documents. You can get the URL of the actual assets by following references in GROQ: 113 | 114 | ```groq 115 | *[_type == 'caseStudy'] { 116 | ..., 117 | 118 | featuredVideo-> { 119 | fileSize, 120 | fileURL, 121 | cloudflareR2 { 122 | fileKey, 123 | baseUrl, 124 | }, 125 | }, 126 | } 127 | ``` 128 | 129 | ## Contributing, roadmap & acknowledgments 130 | 131 | Refer to [sanity-plugin-external-files](https://github.com/hdoro/sanity-plugin-external-files) for those. 132 | -------------------------------------------------------------------------------- /packages/core/src/components/Uploader/UploadBox.tsx: -------------------------------------------------------------------------------- 1 | import { CloseIcon, RestoreIcon, UploadIcon } from '@sanity/icons' 2 | import { Button, Card, Inline, Spinner, Stack, Text } from '@sanity/ui' 3 | import React from 'react' 4 | 5 | import { Heading } from '@sanity/ui' 6 | import { VendorConfiguration } from '../../types' 7 | import { useUploadReturn } from './useUpload' 8 | 9 | interface UploadBox extends useUploadReturn { 10 | onUploadClick: () => void 11 | vendorConfig: VendorConfiguration 12 | } 13 | 14 | const UploadBox: React.FC = (props) => { 15 | const { dropzone, state, cancelUpload, retry, onUploadClick } = props 16 | const { 17 | inputRef, 18 | getRootProps, 19 | getInputProps, 20 | isDragActive, 21 | isDragReject, 22 | isDragAccept, 23 | } = dropzone 24 | 25 | const metadataStates = ['extractingVideoMetadata', 'extractingAudioMetadata'] 26 | const uploadingStates = ['uploadingToVendor', 'uploadingToSanity'] 27 | const loadingStates = [...metadataStates, ...uploadingStates] 28 | 29 | const rootProps = getRootProps() 30 | 31 | // By default, Sanity's dialogs will capture drag/drop events, breaking the dropzone. 32 | // So for UploadBox inside arrays, we need to capture these events first, hence the duplication 33 | // of handlers in their captured version. 34 | const adjustedRootProps: typeof rootProps = { 35 | ...rootProps, 36 | onDragEnterCapture: (e) => { 37 | rootProps.onDragEnter?.(e) 38 | }, 39 | onDragLeaveCapture: (e) => { 40 | rootProps.onDragLeave?.(e) 41 | }, 42 | onDragOverCapture: (e) => { 43 | rootProps.onDragOver?.(e) 44 | }, 45 | onDropCapture: (e) => { 46 | rootProps.onDrop?.(e) 47 | }, 48 | } 49 | return ( 50 | 64 | 70 | 71 | {state.value === 'failure' && ( 72 | <> 73 | 74 | {state.context.error?.title || 'Failed to upload'} 75 | 76 | {state.context.error?.subtitle && ( 77 | {state.context.error.subtitle} 78 | )} 79 | 80 | 313 | )} 314 | 315 | )} 316 | 317 | {props.context === 'input' && } 318 | 319 | ) 320 | } 321 | 322 | export default MediaPreview 323 | -------------------------------------------------------------------------------- /packages/core/src/components/Browser/Browser.tsx: -------------------------------------------------------------------------------- 1 | import { CogIcon, SearchIcon, UploadIcon } from '@sanity/icons' 2 | import { 3 | Box, 4 | Button, 5 | Card, 6 | Container, 7 | Dialog, 8 | Flex, 9 | Grid, 10 | Inline, 11 | Spinner, 12 | Stack, 13 | Text, 14 | TextInput, 15 | ThemeProvider, 16 | Tooltip, 17 | studioTheme, 18 | } from '@sanity/ui' 19 | import { useMachine } from '@xstate/react' 20 | import React from 'react' 21 | import { useSanityClient } from '../../scripts/sanityClient' 22 | import { SanityUpload, VendorConfiguration } from '../../types' 23 | import ConfigureCredentials from '../Credentials/ConfigureCredentials' 24 | import { CredentialsContext } from '../Credentials/CredentialsProvider' 25 | import Uploader, { UploaderProps } from '../Uploader/Uploader' 26 | import FileDetails from './FileDetails' 27 | import FilePreview from './FilePreview' 28 | import browserMachine from './browserMachine' 29 | import { Accept } from 'react-dropzone' 30 | 31 | interface BrowserProps { 32 | onSelect?: (file: SanityUpload) => void 33 | accept?: UploaderProps['accept'] 34 | vendorConfig: VendorConfiguration 35 | } 36 | 37 | function getFilterForExtension(accept?: Accept) { 38 | if (!accept) { 39 | return 40 | } 41 | 42 | /** @example ['pdf', 'image/png', '.html'] */ 43 | const acceptedExtensionsOrMimes = Object.entries(accept).flatMap( 44 | ([key, value]) => { 45 | return value.map((extension) => `${key.replace('*', extension)}`) 46 | }, 47 | ) 48 | 49 | return acceptedExtensionsOrMimes 50 | .map((extensionOrMime) => `contentType match "**${extensionOrMime}"`) 51 | .join(' || ') 52 | } 53 | 54 | const Browser: React.FC = (props) => { 55 | const { onSelect, accept = props.vendorConfig?.defaultAccept } = props 56 | const sanityClient = useSanityClient() 57 | const placement = props.onSelect ? 'input' : 'tool' 58 | const [state, send] = useMachine(browserMachine, { 59 | services: { 60 | fetchFiles: () => { 61 | const filters = [ 62 | `_type == "${props.vendorConfig?.schemaPrefix}.storedFile"`, 63 | 'defined(fileURL)', 64 | getFilterForExtension(accept), 65 | ] 66 | 67 | return sanityClient.fetch(/* groq */ ` 68 | *[${filters 69 | .filter(Boolean) 70 | .map((f) => `(${f})`) 71 | .join(' && ')}] | order(_createdAt desc) 72 | `) 73 | }, 74 | }, 75 | }) 76 | const { status } = React.useContext(CredentialsContext) 77 | 78 | return ( 79 | 80 | 88 | 89 | {state.matches('loading') ? ( 90 | 91 | 92 | 93 | ) : status === 'missingCredentials' ? ( 94 | 95 | 96 | 97 | ) : ( 98 | 99 | 100 | ) => 104 | send({ 105 | type: 'SEARCH_TERM', 106 | term: e.currentTarget.value, 107 | }) 108 | } 109 | placeholder="Search files" 110 | /> 111 | 112 | {status === 'success' && ( 113 |