// Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License").
//
// You may not use this file except in compliance with the License. A copy
// of the License is located at
//
// http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file is distributed
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
// either express or implied. See the License for the specific language governing
// permissions and limitations under the License.
/* ESLint file-level overrides */
/* global AWS bootbox document moment window $ angular:true */
/* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */
/* eslint-disable no-console */
/* eslint no-plusplus: "off" */
/* eslint-env es6 */
const s3ExplorerColumns = {
check: 0, object: 1, folder: 2, date: 3, timestamp: 4, storageclass: 5, size: 6,
};
// Cache frequently-used selectors and data table
const $tb = $('#s3objects-table');
const $bc = $('#breadcrumb');
const $bl = $('#bucket-loader');
// Map S3 storage types to text
const mapStorage = {
STANDARD: 'Standard',
STANDARD_IA: 'Standard IA',
ONEZONE_IA: 'One Zone-IA',
REDUCED_REDUNDANCY: 'Reduced Redundancy',
GLACIER: 'Glacier',
INTELLIGENT_TIERING: 'Intelligent Tiering',
DEEP_ARCHIVE: 'Deep Archive',
};
// Debug utility to complement console.log
const DEBUG = (() => {
const timestamp = () => {};
timestamp.toString = () => `[DEBUG ${moment().format()}]`;
return {
log: console.log.bind(console, '%s', timestamp),
};
})();
// Utility to convert bytes to readable text e.g. "2 KB" or "5 MB"
function bytesToSize(bytes) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 Bytes';
const ii = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10);
return `${Math.round(bytes / (1024 ** ii), 2)} ${sizes[ii]}`;
}
// Escape strings of HTML
function htmlEscape(str) {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/')
.replace(/`/g, '`')
.replace(/=/g, '=');
}
// Convert cars/vw/golf.png to golf.png
function fullpath2filename(path, escape = false) {
const rc = path.replace(/^.*[\\/]/, '');
return escape ? htmlEscape(rc) : rc;
}
// Convert cars/vw/golf.png to cars/vw/
function fullpath2pathname(path, escape = false) {
const index = path.lastIndexOf('/');
const rc = (index === -1) ? '/' : path.substring(0, index + 1);
return escape ? htmlEscape(rc) : rc;
}
// Convert cars/vw/ to vw/
function prefix2folder(prefix, escape = false) {
const parts = prefix.split('/');
const rc = `${parts[parts.length - 2]}/`;
return escape ? htmlEscape(rc) : rc;
}
// Convert cars/vw/sedans/ to cars/vw/
function prefix2parentfolder(prefix, escape = false) {
const parts = prefix.split('/');
parts.splice(parts.length - 2, 1);
const rc = parts.join('/');
return escape ? htmlEscape(rc) : rc;
}
// Convert cars/vw/golf.png to cars/.../golf.png
const pathLimit = 80; // Max allowed path length
const pathHellip = String.fromCharCode(8230); // '…' char
function path2short(path, escape = false) {
if (path.length < pathLimit) return escape ? htmlEscape(path) : path;
const soft = `${prefix2parentfolder(fullpath2pathname(path)) + pathHellip}/${fullpath2filename(path)}`;
if (soft.length < pathLimit && soft.length > 2) return escape ? htmlEscape(soft) : soft;
const hard = `${path.substring(0, path.indexOf('/') + 1) + pathHellip}/${fullpath2filename(path)}`;
const rc = hard.length < pathLimit ? hard : path.substring(0, pathLimit) + pathHellip;
return escape ? htmlEscape(rc) : rc;
}
// Virtual-hosted-style URL, ex: https://mybucket1.s3.amazonaws.com/index.html
function object2hrefvirt(bucket, key, escape = false) {
const enckey = key.split('/').map(x => encodeURIComponent(x)).join('/');
const rc = `${document.location.protocol}//${bucket}.s3.amazonaws.com/${enckey}`;
return escape ? htmlEscape(rc) : rc;
}
// Path-style URLs, ex: https://s3.amazonaws.com/mybucket1/index.html
// eslint-disable-next-line no-unused-vars
function object2hrefpath(bucket, key, escape = false) {
const enckey = key.split('/').map(x => encodeURIComponent(x)).join('/');
const rc = `${document.location.protocol}//s3.amazonaws.com/${bucket}/${enckey}`;
return escape ? htmlEscape(rc) : rc;
}
function isfolder(path) {
return path.endsWith('/');
}
function stripLeadTrailSlash(s) {
return s.replace(/^\/+/g, '').replace(/\/+$/g, '');
}
// kenpo/, kokuho/, koukikourei/, all/ 配下のファイル名を適切に変換
function getDownloadFilename(key) {
const targetFolders = ['/kenpo/', '/kokuho/', '/koukikourei/', '/all/'];
for (const folder of targetFolders) {
const folderIndex = key.indexOf(folder);
if (folderIndex === -1) continue;
const pathAfter = key.substring(folderIndex + folder.length);
const parts = pathAfter.split('/');
if (parts.length >= 2) {
return `${parts[0]}_${parts[parts.length - 1]}`;
}
return fullpath2filename(key);
}
return fullpath2filename(key);
}
//
// Shared service that all controllers can use
//
function SharedService($rootScope) {
DEBUG.log('SharedService init');
const shared = {
settings: null, viewprefix: null, skew: true,
};
shared.getSettings = () => this.settings;
shared.addFiles = (files) => { this.added_files = files; };
shared.getAddedFiles = () => this.added_files;
shared.hasAddedFiles = () => Object.prototype.hasOwnProperty.call(this, 'added_files');
shared.resetAddedFiles = () => { delete this.added_files; };
shared.changeSettings = (settings) => {
DEBUG.log('SharedService::changeSettings');
DEBUG.log('SharedService::changeSettings settings', settings);
this.settings = settings;
this.viewprefix = null;
$.fn.dataTableExt.afnFiltering.length = 0;
// AWS.config.update(settings.cred);
// AWS.config.update({ region: settings.region });
AWS.config.update(Object.assign(settings.cred, { region: settings.region }));
if (this.skew) {
this.correctClockSkew(settings.bucket);
this.skew = false;
}
if (settings.mfa.use === 'yes') {
const iam = new AWS.IAM();
DEBUG.log('listMFADevices');
iam.listMFADevices({}, (err1, data1) => {
if (err1) {
DEBUG.log('listMFADevices error:', err1);
} else {
const sts = new AWS.STS();
DEBUG.log('listMFADevices data:', data1);
const params = {
DurationSeconds: 3600,
SerialNumber: data1.MFADevices[0].SerialNumber,
TokenCode: settings.mfa.code,
};
DEBUG.log('getSessionToken params:', params);
sts.getSessionToken(params, (err2, data2) => {
if (err2) {
DEBUG.log('getSessionToken error:', err2);
} else {
DEBUG.log('getSessionToken data:', data2);
this.settings.stscred = {
accessKeyId: data2.Credentials.AccessKeyId,
secretAccessKey: data2.Credentials.SecretAccessKey,
sessionToken: data2.Credentials.SessionToken,
};
AWS.config.update(this.settings.stscred);
$rootScope.$broadcast('broadcastChangeSettings', { settings: this.settings });
}
});
}
});
} else {
$rootScope.$broadcast('broadcastChangeSettings', { settings });
}
};
shared.changeViewPrefix = (prefix) => {
DEBUG.log('SharedService::changeViewPrefix');
if (this.settings.delimiter) {
// Folder-level view
this.settings.prefix = prefix;
this.viewprefix = null;
$.fn.dataTableExt.afnFiltering.length = 0;
$rootScope.$broadcast('broadcastChangePrefix', { prefix });
} else {
// Bucket-level view
this.viewprefix = prefix;
$rootScope.$broadcast('broadcastChangePrefix', { viewprefix: prefix });
}
};
shared.getViewPrefix = () => this.viewprefix || this.settings.prefix;
shared.viewRefresh = () => $rootScope.$broadcast('broadcastViewRefresh');
shared.trashObjects = (bucket, keys) => $rootScope.$broadcast('broadcastTrashObjects', { bucket, keys });
shared.downloadObjects = (bucket, keys) => $rootScope.$broadcast('broadcastDownloadObjects', { bucket, keys });
shared.addFolder = (_bucket, _folder) => $rootScope.$broadcast('broadcastViewRefresh');
// We use pre-signed URLs so that the user can securely download
// objects. For security reasons, we make these URLs time-limited and in
// order to do that we need the client's clock to be in sync with the AWS
// S3 endpoint otherwise we might create URLs that are immediately invalid,
// for example if the client's browser time is 55 minutes behind S3's time.
shared.correctClockSkew = (Bucket) => {
const s3 = new AWS.S3();
DEBUG.log('Invoke headBucket:', Bucket);
// Head the bucket to get a Date response. The 'date' header will need
// to be exposed in S3 CORS configuration.
s3.headBucket({ Bucket }, (err, data) => {
if (err) {
DEBUG.log('headBucket error:', err);
} else {
DEBUG.log('headBucket data:', JSON.stringify(data));
DEBUG.log('headBucket headers:', JSON.stringify(this.httpResponse.headers));
if (this.httpResponse.headers.date) {
const date = Date.parse(this.httpResponse.headers.date);
DEBUG.log('headers date:', date);
AWS.config.systemClockOffset = new Date() - date;
DEBUG.log('clock offset:', AWS.config.systemClockOffset);
// Can now safely generate presigned urls
}
}
});
};
// Common error handling is done here in the shared service.
shared.showError = (params, err) => {
DEBUG.log(err);
const { message, code } = err;
const errors = Object.entries(err).map(([key, value]) => ({ key, value }));
const args = {
params, message, code, errors,
};
$rootScope.$broadcast('broadcastError', args);
};
return shared;
}
//
// ViewController: code associated with the main S3 Explorer table that shows
// the contents of the current bucket/folder and allows the user to downloads
// files, delete files, and do various other S3 functions.
//
// eslint-disable-next-line no-shadow
function ViewController($scope, SharedService) {
DEBUG.log('ViewController init');
window.viewScope = $scope; // for debugging
$scope.view = {
bucket: null, prefix: null, settings: null, objectCount: 0, keys_selected: [],
};
$scope.stop = false;
// Delegated event handler for S3 object/folder clicks. This is delegated
// because the object/folder rows are added dynamically and we do not want
// to have to assign click handlers to each and every row.
$tb.on('click', 'a', (e) => {
const { currentTarget: target } = e;
e.preventDefault();
DEBUG.log('target href:', target.href);
DEBUG.log('target dataset:', JSON.stringify(target.dataset));
if (target.dataset.s3 === 'folder') {
// User has clicked on a folder so navigate into that folder
SharedService.changeViewPrefix(target.dataset.s3key);
} else if ($scope.view.settings.auth === 'anon') {
// Unauthenticated user has clicked on an object so download it
// in new window/tab
window.open(target.href, '_blank');
} else {
// Authenticated user has clicked on an object so create pre-signed
// URL and download it in new window/tab
const s3 = new AWS.S3();
const params = {
Bucket: $scope.view.settings.bucket, Key: target.dataset.s3key, Expires: 15,
};
DEBUG.log('params:', params);
s3.getSignedUrl('getObject', params, (err, url) => {
if (err) {
DEBUG.log('err:', err);
SharedService.showError(params, err);
} else {
DEBUG.log('url:', url);
window.open(url, '_blank');
}
});
}
return false;
});
// Delegated event handler for breadcrumb clicks.
$bc.on('click', 'a', (e) => {
DEBUG.log('breadcrumb li click');
e.preventDefault();
const { currentTarget: target } = e;
DEBUG.log('target dataset:', JSON.stringify(target.dataset));
SharedService.changeViewPrefix(target.dataset.prefix);
return false;
});
$scope.$on('broadcastChangeSettings', (e, args) => {
DEBUG.log('ViewController', 'broadcast change settings:', args.settings);
$scope.view.objectCount = 0;
$scope.view.settings = args.settings;
$scope.refresh();
});
$scope.$on('broadcastChangePrefix', (e, args) => {
DEBUG.log('ViewController', 'broadcast change prefix args:', args);
$scope.$apply(() => {
// Create breadcrumbs from current path (S3 bucket plus folder hierarchy)
$scope.folder2breadcrumbs($scope.view.settings.bucket, args.viewprefix || args.prefix);
if (args.viewprefix !== undefined && args.viewprefix !== null) {
// In bucket-level view we already have the data so we just need to
// filter it on prefix.
$.fn.dataTableExt.afnFiltering.length = 0;
$.fn.dataTableExt.afnFiltering.push(
// Filter function returns true to include item in view
(_o, d, _i) => d[1] !== args.viewprefix && d[1].startsWith(args.viewprefix),
);
// Re-draw the table
$tb.DataTable().draw();
} else {
// In folder-level view, we actually need to query the data for the
// the newly-selected folder.
$.fn.dataTableExt.afnFiltering.length = 0;
$scope.view.settings.prefix = args.prefix;
$scope.refresh();
}
});
});
$scope.$on('broadcastViewRefresh', () => {
DEBUG.log('ViewController', 'broadcast view refresh');
$scope.$apply(() => {
$scope.refresh();
});
});
$scope.renderObject = (data, _type, full) => {
// DEBUG.log('renderObject:', JSON.stringify(full));
const hrefv = object2hrefvirt($scope.view.settings.bucket, data);
function buildAnchor(s3key, href, text, download) {
const a = $('');
a.attr({ 'data-s3key': s3key });
a.attr({ href });
if (download) {
a.attr({ 'data-s3': 'object' });
a.attr({ download });
} else {
a.attr({ 'data-s3': 'folder' });
}
a.text(text);
return a.prop('outerHTML');
}
function render(d, href, text, download) {
if (download) {
return buildAnchor(d, href, text, download);
}
return buildAnchor(d, href, text);
}
if (full.CommonPrefix) {
if ($scope.view.settings.prefix) {
return render(data, hrefv, prefix2folder(data));
}
return render(data, hrefv, data);
}
return render(data, hrefv, fullpath2filename(data), fullpath2filename(data));
};
$scope.renderFolder = (data, _type, full) => (full.CommonPrefix ? '' : fullpath2pathname(data, true));
$scope.progresscb = (objects, folders) => {
DEBUG.log('ViewController', 'Progress cb objects:', objects);
DEBUG.log('ViewController', 'Progress cb folders:', folders);
$scope.$apply(() => {
$scope.view.objectCount += objects + folders;
});
};
$scope.refresh = () => {
DEBUG.log('refresh');
if ($scope.running()) {
DEBUG.log('running, stop');
$scope.listobjectsstop();
} else {
DEBUG.log('refresh', $scope.view.settings);
$scope.view.objectCount = 0;
$scope.folder2breadcrumbs(
$scope.view.settings.bucket,
SharedService.getViewPrefix(),
);
$scope.listobjects(
$scope.view.settings.bucket,
$scope.view.settings.prefix,
$scope.view.settings.delimiter,
);
}
};
$scope.upload = () => {
DEBUG.log('Add files');
$('#addedFiles').trigger('click');
};
$scope.trash = () => {
DEBUG.log('Trash:', $scope.view.keys_selected);
if ($scope.view.keys_selected.length > 0) {
SharedService.trashObjects($scope.view.settings.bucket, $scope.view.keys_selected);
}
};
$scope.download = () => {
DEBUG.log('Download:', $scope.view.keys_selected);
if ($scope.view.keys_selected.length === 0) return;
// ダウンロードボタンを無効化(連続クリック防止)
$('#bucket-download').css('pointer-events', 'none').css('opacity', '0.5');
// SharedServiceを経由してダウンロード処理を開始
SharedService.downloadObjects($scope.view.settings.bucket, $scope.view.keys_selected);
};
$scope.running = () => $bl.hasClass('fa-spin');
$scope.folder2breadcrumbs = (bucket, prefix) => {
DEBUG.log('Breadcrumbs bucket:', bucket);
DEBUG.log('Breadcrumbs prefix:', prefix);
// Empty the current breadcrumb list
$('#breadcrumb li').remove();
// This array will contain the needed prefixes for each folder level.
const prefixes = [''];
let buildprefix = '';
if (prefix) {
prefixes.push(...prefix.replace(/\/$/g, '').split('/'));
}
// Add bucket followed by prefix segments to make breadcrumbs
for (let ii = 0; ii < prefixes.length; ii++) {
let li;
// Bucket
if (ii === 0) {
const a1 = $('').attr('href', '#').text(bucket);
li = $('').append(a1);
// Followed by n - 1 intermediate folders
} else if (ii < prefixes.length - 1) {
const a2 = $('').attr('href', '#').text(prefixes[ii]);
li = $('').append(a2);
// Followed by current folder
} else {
li = $('').text(prefixes[ii]);
}
// Accumulate prefix
if (ii) {
buildprefix = `${buildprefix}${prefixes[ii]}/`;
}
// Save prefix & bucket data for later click handler
li.children('a').attr('data-prefix', buildprefix).attr('data-bucket', bucket);
// Add to breadcrumbs
$bc.append(li);
}
// Make last breadcrumb active
$('#breadcrumb li:last').addClass('active');
};
$scope.listobjectsstop = (stop) => {
DEBUG.log('ViewController', 'listobjectsstop:', stop || true);
$scope.stop = stop || true;
};
// This is the listObjects callback
$scope.listobjectscb = (err, data) => {
DEBUG.log('Enter listobjectscb');
if (err) {
DEBUG.log('Error:', JSON.stringify(err));
DEBUG.log('Error:', err.stack);
$bl.removeClass('fa-spin');
const params = { bucket: $scope.view.bucket, prefix: $scope.view.prefix };
SharedService.showError(params, err);
} else {
let marker;
// Store marker before filtering data. Note that Marker is the
// previous request marker, not the marker to use on the next call
// to listObject. For the one to use on the next invocation you
// need to use NextMarker or retrieve the key of the last item.
if (data.IsTruncated) {
if (data.NextMarker) {
marker = data.NextMarker;
} else if (data.Contents.length > 0) {
marker = data.Contents[data.Contents.length - 1].Key;
}
}
const count = { objects: 0, folders: 0 };
// NOTE: folders are returned in CommonPrefixes if delimiter is
// supplied on the listObjects call and in Contents if delimiter
// is not supplied on the listObjects call, so we may need to
// source our DataTable folders from Contents or CommonPrefixes.
// DEBUG.log("Contents", data.Contents);
$.each(data.Contents, (index, value) => {
if (value.Key === data.Prefix) {
// ignore this folder
} else if (isfolder(value.Key)) {
$tb.DataTable().row.add({
CommonPrefix: true, Key: value.Key, StorageClass: null,
});
count.folders++;
} else {
$tb.DataTable().row.add(value);
count.objects++;
}
});
// Add folders to the datatable. Note that folder entries in the
// DataTable will have different content to object entries and the
// folders can be identified by CommonPrefix=true.
// DEBUG.log("CommonPrefixes:", data.CommonPrefixes);
$.each(data.CommonPrefixes, (index, value) => {
$tb.DataTable().rows.add([
{ CommonPrefix: true, Key: value.Prefix, StorageClass: null },
]);
count.objects++;
});
// Re-draw the table
$tb.DataTable().draw();
// Make progress callback to report objects read so far
$scope.progresscb(count.objects, count.folders);
const params = {
Bucket: data.Name, Prefix: data.Prefix, Delimiter: data.Delimiter, Marker: marker,
};
// DEBUG.log("AWS.config:", JSON.stringify(AWS.config));
if ($scope.stop) {
DEBUG.log('Bucket', data.Name, 'stopped');
$bl.removeClass('fa-spin');
} else if (data.IsTruncated) {
DEBUG.log('Bucket', data.Name, 'truncated');
const s3 = new AWS.S3(AWS.config);
if (AWS.config.credentials && AWS.config.credentials.accessKeyId) {
DEBUG.log('Make S3 authenticated call to listObjects');
s3.listObjects(params, $scope.listobjectscb);
} else {
DEBUG.log('Make S3 unauthenticated call to listObjects');
s3.makeUnauthenticatedRequest('listObjects', params, $scope.listobjectscb);
}
} else {
DEBUG.log('Bucket', data.Name, 'listing complete');
$bl.removeClass('fa-spin');
}
}
};
// Start the spinner, clear the table, make an S3 listObjects request
$scope.listobjects = (Bucket, Prefix, Delimiter, Marker) => {
DEBUG.log('Enter listobjects');
// If this is the initial listObjects
if (!Marker) {
// Checked on each event cycle to stop list prematurely
$scope.stop = false;
// Start spinner and clear table
$scope.view.keys_selected = [];
$bl.addClass('fa-spin');
$tb.DataTable().clear();
$tb.DataTable().column(s3ExplorerColumns.folder).visible(!Delimiter);
}
const s3 = new AWS.S3(AWS.config);
const params = {
Bucket, Prefix, Delimiter, Marker,
};
// DEBUG.log("AWS.config:", JSON.stringify(AWS.config));
// Now make S3 listObjects call(s)
if (AWS.config.credentials && AWS.config.credentials.accessKeyId) {
DEBUG.log('Make S3 authenticated call to listObjects, params:', params);
s3.listObjects(params, $scope.listobjectscb);
} else {
DEBUG.log('Make S3 unauthenticated call to listObjects, params:', params);
s3.makeUnauthenticatedRequest('listObjects', params, $scope.listobjectscb);
}
};
this.isfolder = path => path.endsWith('/');
// Individual render functions so that we can control how column data appears
this.renderSelect = (data, type, _full) => {
if (type === 'display') {
return '';
}
return '';
};
this.renderObject = (data, type, full) => {
if (type === 'display') {
return $scope.renderObject(data, type, full);
}
return data;
};
this.renderFolder = (data, type, full) => $scope.renderFolder(data, type, full);
this.renderLastModified = (data, _type, _full) => {
if (data) {
return moment(data).fromNow();
}
return '';
};
this.renderTimestamp = (data, _type, _full) => {
if (data) {
return moment(data).local().format('YYYY-MM-DD HH:mm:ss');
}
return '';
};
this.renderStorageClass = (data, _type, _full) => {
if (data) {
return mapStorage[data];
}
return '';
};
// Object sizes are displayed in nicer format e.g. 1.2 MB but are otherwise
// handled as simple number of bytes e.g. for sorting purposes
this.dataSize = (source, type, _val) => {
if (source.Size) {
return (type === 'display') ? bytesToSize(source.Size) : source.Size;
}
return '';
};
// Initial DataTable settings (must only do this one time)
$tb.DataTable({
iDisplayLength: 25,
order: [[2, 'asc'], [1, 'asc']],
aoColumnDefs: [
{
aTargets: [0], mData: null, mRender: this.renderSelect, sClass: 'text-center', sWidth: '20px', bSortable: false,
},
{
aTargets: [1], mData: 'Key', mRender: this.renderObject, sType: 'key',
},
{
aTargets: [2], mData: 'Key', mRender: this.renderFolder,
},
{
aTargets: [3], mData: 'LastModified', mRender: this.renderLastModified,
},
{
aTargets: [4], mData: 'LastModified', mRender: this.renderTimestamp,
},
{
aTargets: [5], mData: 'StorageClass', mRender: this.renderStorageClass,
},
{
aTargets: [6], mData: this.dataSize,
},
],
});
// Custom ascending sort for Key column so folders appear before objects
$.fn.dataTableExt.oSort['key-asc'] = (a, b) => {
const x = (isfolder(a) ? `0-${a}` : `1-${a}`).toLowerCase();
const y = (isfolder(b) ? `0-${b}` : `1-${b}`).toLowerCase();
if (x < y) return -1;
if (x > y) return 1;
return 0;
};
// Custom descending sort for Key column so folders appear before objects
$.fn.dataTableExt.oSort['key-desc'] = (a, b) => {
const x = (isfolder(a) ? `1-${a}` : `0-${a}`).toLowerCase();
const y = (isfolder(b) ? `1-${b}` : `0-${b}`).toLowerCase();
if (x < y) return 1;
if (x > y) return -1;
return 0;
};
// Handle click on selection checkbox
$('#s3objects-table tbody').on('click', 'input[type="checkbox"]', (e1) => {
const checkbox = e1.currentTarget;
const $row = $(checkbox).closest('tr');
const data = $tb.DataTable().row($row).data();
let index = -1;
// Prevent click event from propagating to parent
e1.stopPropagation();
// Find matching key in currently checked rows
index = $scope.view.keys_selected.findIndex(e2 => e2.Key === data.Key);
// Remove or add checked row as appropriate
if (checkbox.checked && index === -1) {
$scope.view.keys_selected.push(data);
} else if (!checkbox.checked && index !== -1) {
$scope.view.keys_selected.splice(index, 1);
}
$scope.$apply(() => {
// Doing this to force Angular to update models
DEBUG.log('Selected rows:', $scope.view.keys_selected);
});
if (checkbox.checked) {
$row.addClass('selected');
} else {
$row.removeClass('selected');
}
});
// Handle click on table cells
$('#s3objects-table tbody').on('click', 'td', (e) => {
$(e.currentTarget).parent().find('input[type="checkbox"]').trigger('click');
});
}
//
// AddFolderController: code associated with the add folder function.
//
// eslint-disable-next-line no-shadow
function AddFolderController($scope, SharedService) {
DEBUG.log('AddFolderController init');
$scope.add_folder = {
settings: null, bucket: null, entered_folder: '', view_prefix: '/',
};
window.addFolderScope = $scope; // for debugging
DEBUG.log('AddFolderController add_folder init', $scope.add_folder);
$scope.$on('broadcastChangeSettings', (e, args) => {
DEBUG.log('AddFolderController', 'broadcast change settings bucket:', args.settings.bucket);
$scope.add_folder.settings = args.settings;
$scope.add_folder.bucket = args.settings.bucket;
$scope.add_folder.view_prefix = args.settings.prefix || '/';
DEBUG.log('AddFolderController add_folder bcs', $scope.add_folder);
});
$scope.$on('broadcastChangePrefix', (e, args) => {
DEBUG.log('AddFolderController', 'broadcast change prefix args:', args);
$scope.add_folder.view_prefix = args.prefix || args.viewprefix || '/';
DEBUG.log('AddFolderController add_folder bcp', $scope.add_folder);
});
$scope.addFolder = () => {
DEBUG.log('Add folder');
DEBUG.log('Current prefix:', $scope.add_folder.view_prefix);
const ef = stripLeadTrailSlash($scope.add_folder.entered_folder);
const vpef = $scope.add_folder.view_prefix + ef;
const folder = `${stripLeadTrailSlash(vpef)}/`;
DEBUG.log('Calculated folder:', folder);
const s3 = new AWS.S3(AWS.config);
const params = { Bucket: $scope.add_folder.bucket, Key: folder };
DEBUG.log('Invoke headObject:', params);
// Test if an object with this key already exists
s3.headObject(params, (err1, _data1) => {
if (err1 && err1.code === 'NotFound') {
DEBUG.log('Invoke putObject:', params);
// Create a zero-sized object to simulate a folder
s3.putObject(params, (err2, _data2) => {
if (err2) {
DEBUG.log('putObject error:', err2);
bootbox.alert('Error creating folder:', err2);
} else {
SharedService.addFolder(params.Bucket, params.Key);
$('#AddFolderModal').modal('hide');
$scope.add_folder.entered_folder = '';
}
});
} else if (err1) {
bootbox.alert('Error checking existence of folder:', err1);
} else {
bootbox.alert('Error: folder or object already exists at', params.Key);
}
});
};
}
//
// InfoController: code associated with the Info modal where the user can
// view bucket policies, CORS configuration and About text.
//
// Note: do not be tempted to correct the eslint no-unused-vars error
// with SharedService below. Doing so will break injection.
//
// eslint-disable-next-line no-shadow
function InfoController($scope) {
DEBUG.log('InfoController init');
window.infoScope = $scope; // for debugging
$scope.info = {
cors: null, policy: null, bucket: null, settings: null,
};
$scope.$on('broadcastChangeSettings', (e, args) => {
DEBUG.log('InfoController', 'broadcast change settings bucket:', args.settings.bucket);
$scope.info.settings = args.settings;
$scope.info.bucket = args.settings.bucket;
$scope.getBucketCors(args.settings.bucket);
$scope.getBucketPolicy(args.settings.bucket);
});
$scope.getBucketPolicy = (Bucket) => {
const params = { Bucket };
$scope.info.policy = null;
DEBUG.log('call getBucketPolicy:', Bucket);
new AWS.S3(AWS.config).getBucketPolicy(params, (err, data) => {
let text;
if (err && err.code === 'NoSuchBucketPolicy') {
DEBUG.log(err);
text = 'No bucket policy.';
} else if (err) {
DEBUG.log(err);
text = JSON.stringify(err);
} else {
DEBUG.log(data.Policy);
$scope.info.policy = data.Policy;
DEBUG.log('Info:', $scope.info);
text = JSON.stringify(JSON.parse(data.Policy.trim()), null, 2);
}
$('#info-policy').text(text);
});
};
$scope.getBucketCors = (Bucket) => {
const params = { Bucket };
$scope.info.cors = null;
DEBUG.log('call getBucketCors:', Bucket);
new AWS.S3(AWS.config).getBucketCors(params, (err, data) => {
let text;
if (err && err.code === 'NoSuchCORSConfiguration') {
DEBUG.log(err);
text = 'This bucket has no CORS configuration.';
} else if (err) {
DEBUG.log(err);
text = JSON.stringify(err);
} else {
DEBUG.log(data.CORSRules);
[$scope.info.cors] = data.CORSRules;
DEBUG.log('Info:', $scope.info);
text = JSON.stringify(data.CORSRules, null, 2);
}
$('#info-cors').text(text);
});
};
}
//
// SettingsController: code associated with the Settings dialog where the
// user provides credentials and bucket information.
//
// eslint-disable-next-line no-shadow
function SettingsController($scope, SharedService) {
DEBUG.log('SettingsController init');
window.settingsScope = $scope; // for debugging
// Initialized for an unauthenticated user exploring the current bucket
// TODO: calculate current bucket and initialize below
// NOTE: `entered_bucket`はterraformから値を注入しにくいので環境問わず固定値とする
$scope.settings = {
auth: 'auth', region: 'ap-northeast-1', bucket: '', entered_bucket: 'dupf-delivery-data-dev', selected_bucket: '', view: 'folder', delimiter: '/', prefix: '',
};
$scope.settings.mfa = { use: 'no', code: '' };
$scope.settings.cred = { accessKeyId: '', secretAccessKey: '', sessionToken: '' };
$scope.settings.stscred = null;
// TODO: at present the Settings dialog closes after credentials have been supplied
// even if the subsequent AWS calls fail with networking or permissions errors. It
// would be better for the Settings dialog to synchronously make the necessary API
// calls and ensure they succeed before closing the modal dialog.
$scope.update = () => {
DEBUG.log('Settings updated');
$('#SettingsModal').modal('hide');
$scope.settings.bucket = $scope.settings.selected_bucket || $scope.settings.entered_bucket;
// If manually entered bucket then add it to list of buckets for future
if ($scope.settings.entered_bucket) {
if (!$scope.settings.buckets) {
$scope.settings.buckets = [];
}
if ($.inArray($scope.settings.entered_bucket, $scope.settings.buckets) === -1) {
$scope.settings.buckets.push($scope.settings.entered_bucket);
$scope.settings.buckets = $scope.settings.buckets.sort();
}
}
// If anonymous usage then create empty set of credentials
if ($scope.settings.auth === 'anon') {
$scope.settings.cred = { accessKeyId: null, secretAccessKey: null };
}
SharedService.changeSettings($scope.settings);
};
}
//
// UploadController: code associated with the Upload dialog where the
// user reviews the list of dropped files and request upload to S3.
//
// eslint-disable-next-line no-shadow
function UploadController($scope, SharedService) {
DEBUG.log('UploadController init');
window.uploadScope = $scope; // for debugging
$scope.upload = {
button: null, title: null, files: [], uploads: [],
};
// Cache jquery selectors
const $btnUpload = $('#upload-btn-upload');
const $btnCancel = $('#upload-btn-cancel');
// Add new click handler for Cancel button
$btnCancel.click((e2) => {
e2.preventDefault();
// If uploads still in progress then cancel them all
if ($scope.upload.uploads && $scope.upload.uploads.length > 0) {
console.log(`Cancel ${$scope.upload.uploads.length} uploads`);
$scope.upload.uploads.forEach(upl => upl.abort());
$scope.upload.uploads = [];
} else {
console.log('Close upload modal');
$('#UploadModal').modal('hide');
}
});
//
// Upload a list of local files to the provided bucket and prefix
//
$scope.uploadFiles = (Bucket, prefix) => {
$scope.$apply(() => {
$scope.upload.uploads = [];
$scope.upload.uploading = true;
});
DEBUG.log('Dropped files:', $scope.upload.files);
$scope.upload.files.forEach((file, ii) => {
DEBUG.log('File:', file);
DEBUG.log('Index:', ii);
$(`#upload-td-${ii}`).html(
`0%
`,
);
const s3 = new AWS.S3(AWS.config);
const params = {
Body: file.file, Bucket, Key: (prefix || '') + (file.file.fullPath ? file.file.fullPath : file.file.name), ContentType: file.file.type,
};
const upl = s3.upload(params);
$scope.$apply(() => {
$scope.upload.uploads.push(upl);
});
const funcprogress = (evt) => {
DEBUG.log('File:', file, 'Part:', evt.part, evt.loaded, 'of', evt.total);
const pc = evt.total ? ((evt.loaded * 100.0) / evt.total) : 0;
const pct = Math.round(pc);
const pcts = `${pct}%`;
const col = $(`#upload-td-progress-${ii}`);
col.attr('data-percent', pct);
col.css('width', pcts).text(pcts);
};
const funccancelled = (_file) => {
const col = $(`#upload-td-progress-${ii}`);
col.attr('data-percent', 100);
col.css('width', '100%').text('Cancelled');
col.addClass('progress-bar-danger');
};
const funcsend = (err, data) => {
let count = $btnUpload.attr('data-filecount');
DEBUG.log('Upload count was', count, 'now', count - 1);
$btnUpload.attr('data-filecount', --count);
if (err) {
// AccessDenied is a normal consequence of lack of permission
// and we do not treat this as completely unexpected
if (err.code === 'AccessDenied') {
$(`#upload-td-${ii}`).html('Access Denied');
} else if (err.code === 'RequestAbortedError') {
DEBUG.log('Abort upload:', file);
funccancelled(file);
$btnUpload.hide();
$btnCancel.text('Close');
} else {
DEBUG.log(JSON.stringify(err));
$(`#upload-td-${ii}`).html(`Failed: ${err.code}`);
SharedService.showError(params, err);
}
} else {
DEBUG.log('Uploaded', file.file.name, 'to', data.Location);
$(`#upload-td-progress-${ii}`).addClass('progress-bar-success');
$scope.$apply(() => {
$scope.upload.button = `Upload (${count})`;
$scope.upload.uploads = $scope.upload.uploads.filter(f => f !== upl);
});
}
// If all files complete then update buttons and refresh view
if (count === 0) {
$btnUpload.hide();
$btnCancel.text('Close');
SharedService.viewRefresh();
}
};
// Kick off the upload and report progress
upl.on('httpUploadProgress', funcprogress).send(funcsend);
});
};
// Wrap readEntries in a promise to make working with readEntries easier
async function readEntriesPromise(directoryReader) {
try {
return new Promise((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});
} catch (err) {
DEBUG.log(err);
return undefined;
}
}
// Get all the entries (files or sub-directories) in a directory
// by calling readEntries until it returns empty array
async function readAllDirectoryEntries(directoryReader) {
const entries = [];
let readEntries = await readEntriesPromise(directoryReader);
while (readEntries.length > 0) {
entries.push(...readEntries);
// eslint-disable-next-line no-await-in-loop
readEntries = await readEntriesPromise(directoryReader);
}
return entries;
}
// Retrieve File object from FileEntry
async function filePromise(fileEntry) {
try {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject);
});
} catch (err) {
DEBUG.log(err);
return undefined;
}
}
// Get all files recursively
async function getAllFileEntries(dataTransferItemList) {
const fileEntries = [];
const queue = [];
for (let i = 0; i < dataTransferItemList.length; i++) {
const dtItem = dataTransferItemList[i];
queue.push(typeof dtItem.webkitGetAsEntry === 'function' ? dtItem.webkitGetAsEntry() : dtItem.getAsEntry());
}
while (queue.length > 0) {
const entry = queue.shift();
if (entry) {
if (entry.isFile) {
// eslint-disable-next-line no-await-in-loop
const file = await filePromise(entry);
file.fullPath = entry.fullPath.substring(1);
fileEntries.push(file);
} else if (entry.isDirectory) {
const reader = entry.createReader();
// eslint-disable-next-line no-await-in-loop
queue.push(...await readAllDirectoryEntries(reader));
}
}
}
return fileEntries;
}
// Wrapper to get files safely
async function getFilesList(dataTransfer) {
if (dataTransfer.items.length > 0) {
if (typeof dataTransfer.items[0].webkitGetAsEntry === 'function' ||
typeof dataTransfer.items[0].getAsEntry === 'function') {
return getAllFileEntries(dataTransfer.items);
}
DEBUG.log('Cannot do folders upload, falling back to files only');
return dataTransfer.files;
}
return [];
}
//
// Drag/drop handler for files to be uploaded
//
$scope.dropZone = (target) => {
target
.on('dragover', () => {
target.addClass('dragover');
return false;
})
.on('dragend dragleave', () => {
target.removeClass('dragover');
return false;
})
.on('drop', async (e) => {
DEBUG.log('Dropped files');
e.stopPropagation();
e.preventDefault();
target.removeClass('dragover');
$bl.addClass('fa-spin');
const files = SharedService.hasAddedFiles()
? SharedService.getAddedFiles()
: await getFilesList(e.originalEvent.dataTransfer);
$bl.removeClass('fa-spin');
if (!files.length) {
DEBUG.log('Nothing to upload');
return false;
}
$scope.$apply(() => {
$scope.upload.files = [];
for (let ii = 0; ii < files.length; ii++) {
const fileii = files[ii];
// See https://github.com/awslabs/aws-js-s3-explorer/issues/71
if (fileii.type || fileii.size % 4096 !== 0 || fileii.size > 1048576) {
DEBUG.log('File:', fileii.name, 'Size:', fileii.size, 'Type:', fileii.type);
$scope.upload.files.push({
file: fileii,
name: fileii.fullPath || fileii.name,
type: fileii.type,
size: bytesToSize(fileii.size),
short: path2short(fileii.fullPath || fileii.name),
});
}
}
});
const { bucket } = SharedService.getSettings();
const prefix = SharedService.getViewPrefix();
// Remove any prior click handler from Upload button
$btnUpload.unbind('click');
// Add new click handler for Upload button
$btnUpload.click((e2) => {
e2.preventDefault();
$scope.uploadFiles(bucket, prefix);
});
// Reset buttons for initial use
$btnUpload.show();
$btnCancel.text('Cancel');
// Bind file count into button
$btnUpload.attr('data-filecount', files.length);
$scope.$apply(() => {
$scope.upload.title = `${bucket}/${prefix || ''}`;
$scope.upload.button = `Upload (${files.length})`;
$scope.upload.uploading = false;
});
// Reset files selector
if (SharedService.hasAddedFiles()) {
SharedService.resetAddedFiles();
$('#addedFiles').val('');
}
// Launch the uploader modal
$('#UploadModal').modal({ keyboard: true, backdrop: 'static' });
return false;
});
};
// Enable dropzone behavior and highlighting
$scope.dropZone($('.dropzone'));
// Simulate drop event on change of files selector
$('#addedFiles').on('change', (e) => {
SharedService.addFiles(e.target.files);
$('.dropzone').trigger('drop');
});
}
//
// ErrorController: code associated with displaying runtime errors.
//
function ErrorController($scope) {
DEBUG.log('ErrorController init');
window.errorScope = $scope; // for debugging
$scope.error = {
errors: [], message: '',
};
$scope.$on('broadcastError', (e, args) => {
DEBUG.log('ErrorController', 'broadcast error', args);
$scope.$apply(() => {
Object.assign($scope.error, args);
DEBUG.log('scope errors', $scope.error.errors);
});
// Launch the error modal
$('#ErrorModal').modal({ keyboard: true, backdrop: 'static' });
});
}
//
// TrashController: code associated with the Trash modal where the user can
// delete objects.
//
// eslint-disable-next-line no-shadow
function TrashController($scope, SharedService) {
DEBUG.log('TrashController init');
window.trashScope = $scope; // for debugging
$scope.trash = { title: null, button: null, objects: [] };
// Cache jquery selectors
const $btnDelete = $('#trash-btn-delete');
const $btnCancel = $('#trash-btn-cancel');
//
// Delete a list of objects from the provided S3 bucket
//
$scope.deleteFiles = (Bucket, objects, recursion) => {
DEBUG.log('Delete files:', objects);
$scope.$apply(() => {
$scope.trash.trashing = true;
});
for (let ii = 0; ii < objects.length; ii++) {
DEBUG.log('Delete key:', objects[ii].Key);
DEBUG.log('Object:', objects[ii]);
DEBUG.log('Index:', ii);
const s3 = new AWS.S3(AWS.config);
// If the user is deleting a folder then recursively list
// objects and delete them
if (isfolder(objects[ii].Key) && SharedService.getSettings().delimiter) {
const params = { Bucket, Prefix: objects[ii].Key };
s3.listObjects(params, (err, data) => {
if (err) {
if (!recursion) {
// AccessDenied is a normal consequence of lack of permission
// and we do not treat this as completely unexpected
if (err.code === 'AccessDenied') {
$(`#trash-td-${ii}`).html('Access Denied');
} else {
DEBUG.log(JSON.stringify(err));
$(`#trash-td-${ii}`).html(`Failed: ${err.code}`);
SharedService.showError(params, err);
}
} else {
DEBUG.log(JSON.stringify(err));
SharedService.showError(params, err);
}
} else if (data.Contents.length > 0) {
$scope.deleteFiles(Bucket, data.Contents, true);
}
});
}
const params = { Bucket, Key: objects[ii].Key };
DEBUG.log('Delete params:', params);
s3.deleteObject(params, (err, _data) => {
if (err) {
if (!recursion) {
// AccessDenied is a normal consequence of lack of permission
// and we do not treat this as completely unexpected
if (err.code === 'AccessDenied') {
$(`#trash-td-${ii}`).html('Access Denied');
} else {
DEBUG.log(JSON.stringify(err));
$(`#trash-td-${ii}`).html(`Failed: ${err.code}`);
SharedService.showError(params, err);
}
} else {
DEBUG.log(JSON.stringify(err));
SharedService.showError(params, err);
}
} else {
DEBUG.log('Deleted', objects[ii].Key, 'from', Bucket);
let count = $btnDelete.attr('data-filecount');
if (!recursion) {
$(`#trash-td-${ii}`).html('Deleted');
DEBUG.log('Delete count was', count, 'now', count - 1);
$btnDelete.attr('data-filecount', --count);
}
// Update count in Delete button
$scope.$apply(() => {
$scope.trash.button = `Delete (${count})`;
});
// If all files deleted then update buttons
if (count === 0) {
$btnDelete.hide();
$btnCancel.text('Close');
}
// Refresh underlying folder view
SharedService.viewRefresh();
}
});
}
};
$scope.$on('broadcastTrashObjects', (e, args) => {
DEBUG.log('TrashController', 'broadcast trash objects', args);
$scope.trash.objects = [];
// Populate scope trash object array with objects to be deleted
for (let ii = 0; ii < args.keys.length; ii++) {
const obj = args.keys[ii];
DEBUG.log('Object to be deleted:', obj);
const object = path2short(isfolder(obj.Key)
? prefix2folder(obj.Key)
: fullpath2filename(obj.Key));
const folder = path2short(isfolder(obj.Key)
? prefix2parentfolder(obj.Key)
: fullpath2pathname(obj.Key));
const lastmodified = isfolder(obj.Key)
? ''
: moment(obj.LastModified).fromNow();
const timestamp = obj.LastModified
? moment(obj.LastModified).local().format('YYYY-MM-DD HH:mm:ss')
: '';
const objectclass = isfolder(obj.Key)
? ''
: mapStorage[obj.StorageClass];
const size = isfolder(obj.Key)
? ''
: bytesToSize(obj.Size);
$scope.trash.objects.push({
object,
folder,
lastmodified,
timestamp,
objectclass,
size,
});
}
// Remove any prior click handler from Delete button
$btnDelete.unbind('click');
// Add new click handler for Delete button
$btnDelete.click((e2) => {
e2.preventDefault();
$scope.deleteFiles(args.bucket, args.keys);
});
// Reset buttons for initial use
$btnDelete.show();
$btnCancel.text('Cancel');
// Bind file count into button
$btnDelete.attr('data-filecount', args.keys.length);
$scope.trash.count = args.keys.length;
$scope.trash.button = `Delete (${args.keys.length})`;
$scope.trash.trashing = false;
$('#TrashModal').modal({ keyboard: true, backdrop: 'static' });
});
}
//
// DownloadController: ダウンロードモーダルに関連するコード
// ダウンロードの進捗表示とキャンセル機能
//
function DownloadController($scope, $timeout, SharedService) {
DEBUG.log('DownloadController init');
window.downloadScope = $scope; // デバッグ用
$scope.download = { title: null, button: null, files: [], count: 0, downloading: false };
// jQueryセレクターをキャッシュ
const $btnStart = $('#download-btn-start');
const $btnCancel = $('#download-btn-cancel');
// キャンセルフラグ
let cancelRequested = false;
// キャンセルボタンのクリックハンドラー
$btnCancel.click((e2) => {
e2.preventDefault();
// ダウンロード実行中の場合はキャンセル
if ($scope.download.downloading) {
console.log('Cancel download requested');
cancelRequested = true;
$btnCancel.prop('disabled', true);
$btnCancel.text('Cancelling...');
} else {
console.log('Close download modal');
// モーダルを閉じる際にデータをクリア
$scope.download.files = [];
$scope.download.count = 0;
// ダウンロードボタンを有効化
$('#bucket-download').css('pointer-events', 'auto').css('opacity', '1');
$('#DownloadModal').modal('hide');
}
});
//
// S3からファイルリストをダウンロード
//
$scope.downloadFiles = (Bucket, filesToDownload) => {
$scope.$apply(() => {
$scope.download.downloading = true;
});
DEBUG.log('Files to download:', filesToDownload);
const downloadNext = (index) => {
// キャンセルチェック
if (cancelRequested) {
DEBUG.log('Download cancelled at index:', index);
$(`#download-td-${index}`).html('Cancelled');
// 残りのファイルもキャンセル済みにする
for (let i = index; i < filesToDownload.length; i++) {
$(`#download-td-${i}`).html('Cancelled');
}
$btnStart.hide();
$btnCancel.prop('disabled', false);
$btnCancel.text('Close');
$scope.$apply(() => {
$scope.download.downloading = false;
});
return;
}
if (index >= filesToDownload.length) {
// 全て完了
$btnStart.hide();
$btnCancel.text('Close');
$scope.$apply(() => {
$scope.download.downloading = false;
});
return;
}
// ダウンロード中表示
$(`#download-td-${index}`).html('Downloading...');
const downloadFilename = getDownloadFilename(filesToDownload[index].Key);
// 日本語対応:RFC 2231準拠のエンコード
const encodedFilename = encodeURIComponent(downloadFilename);
const contentDisposition = `attachment; filename="${downloadFilename.replace(/[^\x00-\x7F]/g, '_')}"; filename*=UTF-8''${encodedFilename}`;
const params = {
Bucket,
Key: filesToDownload[index].Key,
Expires: 60,
ResponseContentDisposition: contentDisposition
};
const s3 = new AWS.S3(AWS.config);
s3.getSignedUrl('getObject', params, (err, url) => {
if (err) {
SharedService.showError(params, err);
$(`#download-td-${index}`).html('Error');
// エラーが発生しても次のファイルへ続行
setTimeout(() => downloadNext(index + 1), 500);
return;
}
const link = document.createElement('a');
link.href = url;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 完了表示
$(`#download-td-${index}`).html('Downloaded');
// 成功時のみ次のファイルへ(500ms待機)
setTimeout(() => downloadNext(index + 1), 500);
});
};
downloadNext(0);
};
$scope.$on('broadcastDownloadObjects', (e, args) => {
DEBUG.log('DownloadController', 'broadcast download objects', args);
// キャンセルフラグをリセット
cancelRequested = false;
// 親フォルダでカバーされるアイテムを除外
const filteredSelection = args.keys.filter((item) => {
return !args.keys.some((other) => {
return isfolder(other.Key) &&
other.Key !== item.Key &&
item.Key.startsWith(other.Key);
});
});
DEBUG.log('Filtered selection:', filteredSelection.length, 'items');
const s3 = new AWS.S3(AWS.config);
const filesToDownload = [];
let pendingFolders = 0;
let hasError = false;
// ローディング表示
$bl.addClass('fa-spin');
// フォルダ内の全ファイルを再帰的に取得(1000件制限対応)
function listAllObjects(prefix, callback) {
const allFiles = [];
function listRecursive(marker) {
const params = {
Bucket: args.bucket,
Prefix: prefix,
Marker: marker
};
s3.listObjects(params, (err, data) => {
if (err) {
callback(err, null);
return;
}
// ファイルのみを追加(フォルダは除外)
data.Contents.forEach((item) => {
if (!isfolder(item.Key)) {
allFiles.push(item);
}
});
// 1000件以上ある場合は続きを取得
if (data.IsTruncated) {
const nextMarker = data.NextMarker || data.Contents[data.Contents.length - 1].Key;
listRecursive(nextMarker);
} else {
// 全件取得完了
callback(null, allFiles);
}
});
}
listRecursive(null);
}
// フォルダの場合は配下のファイルを取得、ファイルの場合はそのまま追加
filteredSelection.forEach((obj) => {
if (isfolder(obj.Key)) {
// フォルダの場合
pendingFolders++;
listAllObjects(obj.Key, (err, files) => {
pendingFolders--;
if (err) {
SharedService.showError({ Prefix: obj.Key }, err);
hasError = true;
} else {
// 取得したファイルを追加
filesToDownload.push(...files);
}
// 全てのフォルダ処理が完了したらモーダル表示
if (pendingFolders === 0) {
$bl.removeClass('fa-spin');
// ダウンロードボタンを有効化
$('#bucket-download').css('pointer-events', 'auto').css('opacity', '1');
if (!hasError) {
showDownloadModal();
}
}
});
} else {
// ファイルの場合はそのまま追加
filesToDownload.push(obj);
}
});
// フォルダがない場合は即座にモーダル表示
if (pendingFolders === 0) {
$bl.removeClass('fa-spin');
// ダウンロードボタンを有効化
$('#bucket-download').css('pointer-events', 'auto').css('opacity', '1');
showDownloadModal();
}
function showDownloadModal() {
if (filesToDownload.length === 0) {
return;
}
// $timeoutを使ってAngularのダイジェストサイクルを確実に実行
$timeout(() => {
// ファイルリストを作成
$scope.download.files = [];
filesToDownload.forEach((file) => {
$scope.download.files.push({
name: fullpath2filename(file.Key),
folder: fullpath2pathname(file.Key),
size: bytesToSize(file.Size),
key: file.Key
});
});
$scope.download.count = filesToDownload.length;
$scope.download.button = `Download (${filesToDownload.length})`;
$scope.download.downloading = false;
// 前回のクリックハンドラーを削除
$btnStart.unbind('click');
// ダウンロード開始ボタンのクリックハンドラーを追加
$btnStart.click((e2) => {
e2.preventDefault();
$scope.downloadFiles(args.bucket, filesToDownload);
});
// ボタンの初期状態にリセット
$btnStart.show();
$btnCancel.prop('disabled', false);
$btnCancel.text('Cancel');
// ダウンロードモーダルを表示
$('#DownloadModal').modal({ keyboard: false, backdrop: 'static' });
}, 0);
}
});
}
// Create Angular module and attach factory and controllers
angular.module('aws-js-s3-explorer', [])
.factory('SharedService', SharedService)
.controller('ErrorController', ErrorController)
.controller('ViewController', ViewController)
.controller('AddFolderController', AddFolderController)
.controller('InfoController', InfoController)
.controller('SettingsController', SettingsController)
.controller('UploadController', UploadController)
.controller('TrashController', TrashController)
.controller('DownloadController', DownloadController);
$(document).ready(() => {
DEBUG.log('Version jQuery', $.fn.jquery);
// Default AWS region and v4 signature
AWS.config.update({ region: '' });
AWS.config.update({ signatureVersion: 'v4' });
// Show navbuttons
$('#navbuttons').removeClass('hidden');
// Close handler for the alert
$('[data-hide]').on('click', (e) => {
$(e.currentTarget).parent().hide();
});
// Initialize the moment library (for time formatting utilities) and
// launch the initial Settings dialog requesting bucket & credentials.
moment().format();
$('#SettingsModal').modal({ keyboard: true, backdrop: 'static' });
});