├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── index.css ├── index.js ├── logo.svg └── serviceWorker.js └── staticwebsitedemo.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | services: storage 3 | platforms: javascript 4 | author: seguler 5 | --- 6 | 7 | # Static Website Sample - File Browser app for Blob Storage 8 | 9 | This sample application can be used as a static website on Azure Storage to list the contents of a Blob container (anonymously). The project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). The sample assumes your blob container is made public, however it can be modified to use Azure Active Directory authentication. The file browser also allows downloading each file with a single click. 10 | 11 | ## Demo 12 | Try the app here: https://staticwebsitedemo.z20.web.core.windows.net/ 13 | 14 | ## Pre-requsites 15 | - Create an [Azure Storage account](https://ms.portal.azure.com/#create/Microsoft.StorageAccount-ARM.3.0.5) (GPv2) and enable [Static Websites](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website) 16 | - Install VSCode, and Azure Storage extension (optional) 17 | - Install git 18 | 19 | ## Deploy the sample - step by step 20 | Follow the following steps to deploy the sample on your Azure Storage account. Once deployed, the sample app will provide you a file browser view of your **$web** container. If you desire so, you can change the container in the `index.js` to any other public container. 21 | 22 | - Clone the repository to your PC 23 | `git clone https://github.com/seguler/static-website-blob-browser` 24 | - Launch VS Code. Log on to the Azure Storage extension. 25 | - Install create-react-app using a terminal (VSCode) 26 | `npm install -g create-react-app` in 'static-website-blob-browser' folder 27 | - Open the sample in VSCode using `File>Open Folder` menu 28 | - On the terminal, run `npm install` and then `npm run build` to build the React app 29 | - Right click `build` folder in VSCode, and click `Deploy to Static Website` 30 | - Choose your storage account to deploy the static website 31 | 32 | Once you have deployed, configure the container as public, and set the CORS settings to allow access from the static website endpoint. 33 | - Go to Azure Portal, select your storage account 34 | - Click CORS on the menu. And add a new row 35 | * Allowed origin: https://staticwebsitedemo.z20.web.core.windows.net (your static website endpoint) 36 | * Allowed methods: GET, OPTIONS, DELETE, PUT, HEAD 37 | * Allowed headers and exposed headers: * 38 | - Go to Blobs menu 39 | - Click on the `...` next to the desired blob container (in the sample, $web is used) 40 | - Click on `Access Policy` and configure `Public Access for the Container`. This is required for anonymously listing blobs using the SDK. 41 | 42 | ![Blob browser - Static website](https://raw.githubusercontent.com/seguler/static-website-blob-browser/master/staticwebsitedemo.jpg) 43 | 44 | ## More information 45 | - [Azure Storage SDK for JS](https://github.com/azure/azure-storage-js) 46 | - [Static Websites on Azure Storage](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website) 47 | - [Deploy a Static Website with VSCode](https://code.visualstudio.com/tutorials/static-website/getting-started) 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blobbrowser", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@azure/storage-blob": "^10.1.0-preview", 7 | "namor": "^1.1.1", 8 | "react": "^16.6.0", 9 | "react-dom": "^16.6.0", 10 | "react-scripts": "2.0.5", 11 | "react-table": "^6.8.6" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seguler/static-website-blob-browser/2d4847cd2c4465cc1b618c3f6df317bee520f1a7/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | 16 | .rt-resizable-header-content { 17 | font-weight: bold; 18 | text-align: left; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import _ from "lodash"; 4 | import "./index.css"; 5 | 6 | // This sample uses React Table 7 | // Check it out at https://react-table.js.org/#/story/readme 8 | import ReactTable from "react-table"; 9 | import "react-table/react-table.css"; 10 | 11 | // Import Azure Storage Blob SDK 12 | import { Aborter, ServiceURL, ContainerURL, StorageURL, AnonymousCredential } from "@azure/storage-blob"; 13 | 14 | // Account name, and the container to list from 15 | const account = 'staticwebsitedemo' 16 | const container = '$web' 17 | 18 | class App extends React.Component { 19 | constructor() { 20 | super(); 21 | this.state = { 22 | data: [], 23 | pages: 2, 24 | markers: [], 25 | loading: true, 26 | prefix: "" 27 | }; 28 | this.fetchData = this.listBlobs.bind(this); 29 | } 30 | 31 | listBlobs(state, instance) { 32 | // this lists Blobs in pages defined in state.pageSize 33 | this.setState({ loading: true }); 34 | 35 | // Use AnonymousCredential since $web container is made a 'public container' 36 | // and does not require authorization 37 | const anonymousCredential = new AnonymousCredential(); 38 | const pipeline = StorageURL.newPipeline(anonymousCredential); 39 | 40 | const serviceURL = new ServiceURL( 41 | `https://${account}.blob.core.windows.net`, 42 | pipeline 43 | ); 44 | 45 | // If you are using a SAS token, simply append to ContainerURL here. 46 | // We will use anonymous access hence no SAS token 47 | const containerName = container //+ `?st=2018-11-06T06%3A15%3A24Z&se=2019-11-07T06%3A15%3A00Z&sp=rl&sv=2018-03-28&sr=c&sig=4vCT7aInDWRiypkuYlezN8dos0K2h2DvQ0pnNkMJSFs%3D`; 48 | const containerURL = ContainerURL.fromServiceURL(serviceURL, containerName); 49 | 50 | // Fetch the prefix in the query params to browse into folders 51 | const urlParams = new URLSearchParams(window.location.search); 52 | const prefix = urlParams.get('prefix'); 53 | 54 | // List objects from Blob storage using the prefix 55 | // Delimiter for virtual directories is a forward slash '/' here 56 | containerURL.listBlobHierarchySegment ( 57 | Aborter.none, 58 | "/", 59 | state.markers[state.page], 60 | { 61 | maxresults: state.pageSize, 62 | prefix: prefix 63 | } 64 | ).then(res => { 65 | // Store the nextMarker in an array for prev/next buttons only if there are more blobs to show 66 | const markers = state.markers.slice(); 67 | var totalPages = state.page+1; 68 | if (res.nextMarker) { 69 | markers[(state.page+1)] = res.nextMarker; 70 | totalPages++; 71 | } 72 | 73 | // Combine the found virtual directories and files 74 | Array.prototype.push.apply(res.segment.blobItems, res.segment.blobPrefixes) 75 | 76 | // This is to sort rows, and handles blobName, contentLength and lastModified time 77 | const sortedData = _.orderBy( 78 | res.segment.blobItems, 79 | state.sorted.map(sort => { 80 | return row => { 81 | if (row[sort.id] === null) { 82 | return -Infinity; 83 | } // TODO: following is a workaround to special case contentLength and lastModified 84 | else if(row[sort.id] === undefined){ 85 | if(row.properties === undefined) 86 | { 87 | return -Infinity; 88 | } else { 89 | return row.properties[sort.id]; 90 | } 91 | } 92 | return typeof row[sort.id] === "string" 93 | ? row[sort.id].toLowerCase() 94 | : row[sort.id]; 95 | }; 96 | }), 97 | state.sorted.map(d => (d.desc ? "desc" : "asc")) 98 | ); 99 | 100 | // Store the state 101 | this.setState({ 102 | data: sortedData, 103 | pages: totalPages, 104 | markers: markers, 105 | loading: false, 106 | prefix: prefix 107 | }); 108 | }); 109 | } 110 | 111 | // Custom links for various scenarios (handles blobs, directories and go back link) 112 | renderLink(blobName) { 113 | var link; 114 | if(blobName === "../") 115 | { 116 | link = "/" 117 | } 118 | else if(blobName.slice(-1) === "/") 119 | { 120 | link = "?prefix=" + blobName 121 | } else { 122 | link = "/" + blobName 123 | } 124 | return ( 125 | 126 | {blobName} 127 | 128 | ); 129 | } 130 | 131 | render() { 132 | const { data, pages, markers, loading, prefix } = this.state; 133 | 134 | // If this is a directory view, add a go back link for the root 135 | var dataset = data 136 | if(prefix !== null) 137 | { 138 | dataset = [{name: "../"}].concat(dataset); 139 | } 140 | 141 | return ( 142 |
143 | ( 150 | this.renderLink(row.value) 151 | ) 152 | }, 153 | { 154 | Header: "Last Modified", 155 | id: "lastModified", 156 | accessor: (d) => { 157 | if(typeof d.properties !== "undefined" ){ 158 | return d.properties.lastModified.toISOString() 159 | } 160 | }, 161 | maxWidth: 400 162 | }, 163 | { 164 | Header: "Content Length", 165 | id: "contentLength", 166 | accessor: (d) => { 167 | if(typeof d.properties !== "undefined"){ 168 | return d.properties.contentLength 169 | } 170 | }, 171 | maxWidth: 200 172 | } 173 | ]} 174 | manual // Do not paginate as we can only list objects in pages from Blob storage 175 | data={dataset} 176 | pages={pages} 177 | markers={markers} 178 | loading={loading} 179 | onFetchData={this.fetchData} 180 | defaultPageSize={10} 181 | className="-striped -highlight" 182 | /> 183 |
184 | ); 185 | } 186 | } 187 | 188 | render(, document.getElementById("root")); -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA. 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | installingWorker.onstatechange = () => { 64 | if (installingWorker.state === 'installed') { 65 | if (navigator.serviceWorker.controller) { 66 | // At this point, the updated precached content has been fetched, 67 | // but the previous service worker will still serve the older 68 | // content until all client tabs are closed. 69 | console.log( 70 | 'New content is available and will be used when all ' + 71 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 72 | ); 73 | 74 | // Execute callback 75 | if (config && config.onUpdate) { 76 | config.onUpdate(registration); 77 | } 78 | } else { 79 | // At this point, everything has been precached. 80 | // It's the perfect time to display a 81 | // "Content is cached for offline use." message. 82 | console.log('Content is cached for offline use.'); 83 | 84 | // Execute callback 85 | if (config && config.onSuccess) { 86 | config.onSuccess(registration); 87 | } 88 | } 89 | } 90 | }; 91 | }; 92 | }) 93 | .catch(error => { 94 | console.error('Error during service worker registration:', error); 95 | }); 96 | } 97 | 98 | function checkValidServiceWorker(swUrl, config) { 99 | // Check if the service worker can be found. If it can't reload the page. 100 | fetch(swUrl) 101 | .then(response => { 102 | // Ensure service worker exists, and that we really are getting a JS file. 103 | if ( 104 | response.status === 404 || 105 | response.headers.get('content-type').indexOf('javascript') === -1 106 | ) { 107 | // No service worker found. Probably a different app. Reload the page. 108 | navigator.serviceWorker.ready.then(registration => { 109 | registration.unregister().then(() => { 110 | window.location.reload(); 111 | }); 112 | }); 113 | } else { 114 | // Service worker found. Proceed as normal. 115 | registerValidSW(swUrl, config); 116 | } 117 | }) 118 | .catch(() => { 119 | console.log( 120 | 'No internet connection found. App is running in offline mode.' 121 | ); 122 | }); 123 | } 124 | 125 | export function unregister() { 126 | if ('serviceWorker' in navigator) { 127 | navigator.serviceWorker.ready.then(registration => { 128 | registration.unregister(); 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /staticwebsitedemo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seguler/static-website-blob-browser/2d4847cd2c4465cc1b618c3f6df317bee520f1a7/staticwebsitedemo.jpg --------------------------------------------------------------------------------