Repository: RafalWilinski/s3-uploader
Branch: master
Commit: a0d81fe669a4
Files: 24
Total size: 39.7 KB
Directory structure:
gitextract_gearmds4/
├── .babelrc
├── .eslintignore
├── .eslintrc
├── .gitignore
├── ConfigurationService.js
├── README.md
├── S3Service.js
├── app/
│ ├── IpcRendererService.js
│ ├── components/
│ │ ├── AccessForm.jsx
│ │ ├── SettingsMenu.jsx
│ │ └── StatusMenu.jsx
│ ├── containers/
│ │ └── app.jsx
│ ├── index.jsx
│ └── styles/
│ ├── base/
│ │ ├── _animations.scss
│ │ ├── _main.scss
│ │ └── _typography.scss
│ ├── components/
│ │ ├── _accessForm.scss
│ │ ├── _settingsMenu.scss
│ │ └── _statusMenu.scss
│ └── main.scss
├── index.html
├── main.js
├── package.json
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": ["es2015", "react"]
}
================================================
FILE: .eslintignore
================================================
public/
node_modules/
webpack.config.js
================================================
FILE: .eslintrc
================================================
{
"extends": "airbnb-base",
"rules": {
"import/no-extraneous-dependencies": 0,
"import/no-unresolved": 0,
"no-case-declarations": 0,
"no-param-reassign": 0,
"consistent-return": 0
}
}
================================================
FILE: .gitignore
================================================
node_modules/
.idea
public/
s3-uploader-darwin-x64/
*.log
.awscredentials.json
.DS_Store
================================================
FILE: ConfigurationService.js
================================================
const fs = require('fs');
/**
* Default file path where credentials will be stored.
* @type {string}
*/
const configFilePath = __dirname + '/.awscredentials.json';
/**
* Encoding used while reading persistent storage.
* @type {string}
*/
const defaultEncoding = 'utf-8';
/**
* Initial Configuration.
* @type {{accessKey: string, secretKey: string, bucket: string, ACL: string, storageClass: string,
* encryption: boolean}}
*/
const configuration = {
accessKey: '',
secretKey: '',
bucket: '',
folder: '',
ACL: '',
storageClass: '',
encryption: false,
};
/**
* Merges old config with provided one. Saves changes to persistent storage.
* @param newConfig
*/
const updateConfig = (newConfig) => {
Object.assign(configuration, newConfig);
fs.writeFile(configFilePath, JSON.stringify(configuration), {}, (err) => {
if (err) throw new Error(err);
});
};
/**
* Loads configuration from persistent storage if it's possible.
*/
const loadConfig = () => new Promise((resolve, reject) => {
fs.readFile(configFilePath, defaultEncoding, (err, data) => {
if (err) {
return reject(err);
}
Object.assign(configuration, JSON.parse(data));
return resolve(configuration);
});
});
/**
* Returns value for given key from configuration directory. Fast, in-memory function (fs not used).
* @param key
*/
const getItem = (key) => configuration[key];
module.exports = {
getItem,
updateConfig,
loadConfig,
};
================================================
FILE: README.md
================================================
## aws-s3-uploader
Simple macOS app for uploading files to Amazon Web Services.
Just drag & drop files you'd like to upload on bucket icon displayed on your status bar or application window.

#### Configuration
Simply click on bucket and follow instructions. You'll need access key and secret key for AWS user with S3 Exec Role.
You can also try modifying `.awscredentials.json` file on your own.
#### Features
- logging in to AWS S3 account with access + secret keys
- upload files by dragging them on status bar icon and on window
- settings permissions and storage classes for uploads
- get S3 link by clicking uploaded file from list
- support for dropping many files at once and directories
#### Todo
- [x] Automatic login on start (no need to enter credentials with every start)
- [ ] Add possibility to abort uploads
- [ ] Tests!
- [ ] Packages for distribution
- [ ] Possibility to disable notifications & automatic URL-to-clipboard write
#### Credits
Special thanks to [parkjisun](https://thenounproject.com/naripuru/), [Sergey Furtaev](https://thenounproject.com/furtaev/), [Timothy Miller](https://thenounproject.com/tmthymllr/), [Joe Mortell](https://thenounproject.com/JoeMortell/) for Icons from [nounproject.com](https://thenounproject.com/)
================================================
FILE: S3Service.js
================================================
const AWS = require('aws-sdk');
const EventEmitter = require('events');
const fileType = require('file-type');
const configService = require('./ConfigurationService');
/**
* High-level wrapper for AWS.S3 API with Promises instead of callbacks.
*/
class S3Service {
/**
* Constructor, creates S3Service object with AWS.S3 property.
*
* Constructor does not validate credentials validity.
* @param accessKey
* @param secretKey
*/
constructor(accessKey, secretKey) {
this.accessKey = accessKey;
this.secretKey = secretKey;
AWS.config.update({
credentials: new AWS.Credentials({
accessKeyId: accessKey,
secretAccessKey: secretKey,
}),
});
this.s3 = new AWS.S3();
}
/**
* AWS.S3.listBuckets wrapper.
* @returns {Promise}
*/
getBuckets() {
return new Promise((resolve, reject) => {
this.s3.listBuckets((err, data) => {
if (err) {
return reject(err);
}
return resolve(data);
});
});
}
/**
* AWS.S3.upload wrapper with some values preloaded from local configuration.
* Returns EventEmitter emiting following events:
* - error
* - progress
* - success
* @returns {EventEmitter}
*/
uploadFile(fileName, data) {
const uploadEventEmitter = new EventEmitter();
const mimeType = fileType(data);
this.s3.upload({
Body: data,
ContentType: (mimeType) ? mimeType.mime : 'application/octet-stream',
ACL: configService.getItem('ACL'),
Key: configService.getItem('folder') + '/' + fileName,
StorageClass: configService.getItem('storageClass'),
Bucket: configService.getItem('bucket'),
}, (err, payload) => {
if (err) uploadEventEmitter.emit('error', err);
uploadEventEmitter.emit('success', payload);
}).on('httpUploadProgress', (progress) => {
uploadEventEmitter.emit('progress', progress);
});
return uploadEventEmitter;
}
}
module.exports = S3Service;
================================================
FILE: app/IpcRendererService.js
================================================
const { ipcRenderer } = require('electron');
/**
* IPC Renderer Service
*
* Service for communicating renderer process with main Electron process.
*/
/**
* Send action via IPC to request S3 Buckets from AWS using provided credentials.
* @param accessKey
* @param secretKey
*/
const requestBuckets = (accessKey, secretKey) => new Promise((resolve, reject) => {
ipcRenderer.send('GET_BUCKETS', {
accessKey,
secretKey,
});
ipcRenderer.on('GET_BUCKETS_REPLY', (event, arg) => {
if (arg.success) return resolve(arg.data);
return reject(arg.error);
});
});
/**
* Sends files dropped on browser window to main process.
* @param files
*/
const sendDroppedFiles = (files) => {
// FileList is not an array - forEach & map are not allowed
const newFiles = [];
for (let i = 0; i < files.length; i++) {
newFiles.push(files[i].path);
}
ipcRenderer.send('DROP_FILES', {
files: newFiles,
});
};
/**
* Send action via IPC to save configuration.
* @param config
*/
const saveConfig = (config) => {
ipcRenderer.send('UPDATE_CONFIG', config);
};
/**
* Procedure which forwards upload events from IPC bus to renderer process callbacks.
* @param errorCallback
* @param successCallback
* @param progressCallback
* @param startCallback
*/
const subscribeUploadEvents = (errorCallback, successCallback, progressCallback, startCallback) => {
ipcRenderer.on('UPLOAD_SUCCESS', (event, arg) => {
successCallback(arg);
});
ipcRenderer.on('UPLOAD_ERROR', (event, arg) => {
errorCallback(arg);
});
ipcRenderer.on('UPLOAD_PROGRESS', (event, arg) => {
progressCallback(arg);
});
ipcRenderer.on('UPLOAD_START', (event, arg) => {
startCallback(arg);
});
};
module.exports = {
subscribeUploadEvents,
requestBuckets,
saveConfig,
sendDroppedFiles,
};
================================================
FILE: app/components/AccessForm.jsx
================================================
import React from 'react';
import '../styles/base/_animations.scss';
/**
* Presentational Component for displaying 'login form'.
*
* Handles validation and passes data to higher-order components/containers.
*/
class AccessForm extends React.Component {
/**
* Constructor, sets default state and binds context to used functions.
* @param props
*/
constructor(props) {
super(props);
this.state = {
accessKey: '',
secretKey: '',
};
this.handleSubmit = this._handleSubmit.bind(this);
this.handleAccessKeyChange = this._handleAccessKeyChange.bind(this);
this.handleSecretKeyChange = this._handleSecretKeyChange.bind(this);
};
/**
* React built-in function.
*
* After mounting component, tries to automatically fill out form with saved credentials.
*/
componentDidMount() {
const savedAccessKey = window.localStorage.getItem('accessKey') || '';
const savedSecretKey = window.localStorage.getItem('secretKey') || '';
document.getElementById('access_key_input').value = savedAccessKey;
document.getElementById('secret_key_input').value = savedSecretKey;
this.setState({
accessKey: savedAccessKey,
secretKey: savedSecretKey,
});
}
/**
* Procedure called when submitting form.
*
* Trims input, handles it and adds some fancy animations if something is wrong.
* @param event
* @private
*/
_handleSubmit(event) {
event.preventDefault();
const accessKey = this.state.accessKey.trim();
const secretKey = this.state.secretKey.trim();
if (accessKey !== '' && secretKey !== '') {
this.props.onCredentialsSubmitted(accessKey, secretKey);
} else {
if (accessKey === '') {
document.getElementById('access_key_input').focus();
document.getElementById('access_key_input').className = 'animated shake';
} else if (secretKey === '') {
document.getElementById('secret_key_input').focus();
document.getElementById('secret_key_input').className = 'animated shake';
}
// Diminish animation after it ends.
setTimeout(() => {
document.getElementById('access_key_input').className = '';
document.getElementById('secret_key_input').className = '';
}, 1500);
}
};
/**
* Changes this.state when user types something to access key field.
* @param event
* @private
*/
_handleAccessKeyChange(event) {
this.setState({
accessKey: event.target.value
});
};
/**
* Changes this.state when user types something to secret key field.
* @param event
* @private
*/
_handleSecretKeyChange(event) {
this.setState({
secretKey: event.target.value
});
};
/**
* React built-in function.
* @returns {XML}
*/
render() {
return (
In order to access S3, please provide AWS Access Key and Secret Key of user with sufficient permissions
{ this.props.error !== null
?
{this.props.error.message}
: ''
}
);
};
}
AccessForm.propTypes = {
// Function forwarded from app container, called after validation
onCredentialsSubmitted: React.PropTypes.func.isRequired,
// Error data returned from container
error: React.PropTypes.object.isRequired,
};
export default AccessForm;
================================================
FILE: app/components/SettingsMenu.jsx
================================================
import React from 'react';
/**
* List of available storage options in AWS S3.
* @type {*[]}
*/
const storageOptions = [
{
displayName: 'Standard',
id: 'STANDARD',
}, {
displayName: 'Reduced Redundancy',
id: 'REDUCED_REDUNDANCY',
}, {
displayName: 'Infrequent Access',
id: 'STANDARD_IA',
},
];
/**
* List of available ACL policies in AWS S3.
* @type {*[]}
*/
const permissionsOptions = [
{
displayName: 'Private',
id: 'private',
}, {
displayName: 'Public Read',
id: 'public-read',
}, {
displayName: 'Public Read/Write',
id: 'public-read-write',
}
];
class SettingsMenu extends React.Component {
/**
* Constructor, sets default state and binds context to used functions.
* @param props
*/
constructor(props) {
super(props);
this.state = {
storageClass: '',
ACL: '',
encryption: '',
bucket: '',
folder: '',
};
this.storageOptionChange = this._storageOptionChange.bind(this);
this.permissionOptionChange = this._permissionOptionChange.bind(this);
this.encryptionOptionChange = this._encryptionOptionChange.bind(this);
this.changeDefault = this._changeDefault.bind(this);
this.checkSelection = this._checkSelection.bind(this);
this.folderChange = this._changeFolder.bind(this);
}
/**
* Changes this.state when user selects new default bucket.
* @param bucket
* @private
*/
_changeDefault(bucket) {
this.setState({
bucket,
});
}
/**
* Changes this.folder with user supplied folder path
* @param folder
* @private
*/
_changeFolder(folder) {
this.setState({
folder,
});
}
/**
* Checks if selected item is currently selected.
* @param item
* @param selection
* @param defaultClasses
* @private
*/
_checkSelection(item, selection, defaultClasses = '') {
if(item === selection) {
return 'selected ' + defaultClasses;
} else {
return defaultClasses;
}
}
/**
* Changes this.state when user changes storage class.
* @param event
* @param storageClass
* @private
*/
_storageOptionChange(event, storageClass) {
event.preventDefault();
this.setState({
storageClass,
});
}
/**
* Changes this.state when user changes default upload permissions
* @param event
* @param ACL
* @private
*/
_permissionOptionChange(event, ACL) {
event.preventDefault();
this.setState({
ACL,
});
}
/**
* Changes this.state when user toggles encryption
* @param event
* @private
*/
_encryptionOptionChange(event) {
this.setState({
encryption: event.target.checked ? 'AES256' : '',
});
}
/**
* React built-in function
* @returns {XML}
*/
render() {
return (
);
}
}
SettingsMenu.propTypes = {
// Function forwarded from app container, to be called when confirming settings
onSettingsSelected: React.PropTypes.func.isRequired,
// List of buckets returned from S3Service
buckets: React.PropTypes.array.isRequired,
};
export default SettingsMenu;
================================================
FILE: app/components/StatusMenu.jsx
================================================
import React from 'react';
const { clipboard } = require('electron');
/**
* Changes clipboard contents to file URL from AWS S3 if file was uploaded successfully.
* @param file
*/
const saveLinkToClipboard = (file) => {
if (file.status === 'uploaded' && file.url !== '') {
clipboard.writeText(file.url);
} else {
console.warn('File has been not uploaded yet!');
}
};
/**
* Presentational component showing all processed files statuses and current mode.
*/
const StatusMenu = (props) => (
{props.bucket}
Folder: {props.folder}
Permissions: {props.ACL}
Storage Class: {props.storageClass}
props.resetSettings()}>
{
props.files.length === 0
?
No files were uploaded yet.
:
{
props.files.reverse().map((file, index) =>
- saveLinkToClipboard(file)}>
{file.key.split('/').pop()}
)
}
}
);
StatusMenu.propTypes = {
// Current permissions e.g. public-read-write
ACL: React.PropTypes.string.isRequired,
// Bucket where files fill be uploaded
bucket: React.PropTypes.string.isRequired,
// Folder (Prefix) to where the file will be uploaded.
folder: React.PropTypes.string.isRequired,
// Array of all files (uploaded, failed, in progress)
files: React.PropTypes.arrayOf(React.PropTypes.shape({
// Filename
key: React.PropTypes.string.isRequired,
// Absolute path to file in local filesystem
path: React.PropTypes.string.isRequired,
// File status
status: React.PropTypes.oneOf(['uploaded', 'uploading', 'failed']),
// URL to file stored in AWS S3 service, will be set to '' if file is not uploaded
url: React.PropTypes.string.isRequired,
}).isRequired).isRequired,
// Current storage class mode e.g. STANDARD_IA (Infrequent Access)
storageClass: React.PropTypes.string.isRequired,
// Function invoked when user wants to reset settings
resetSettings: React.PropTypes.func.isRequired,
};
export default StatusMenu;
================================================
FILE: app/containers/app.jsx
================================================
import React from 'react';
import AccessForm from '../components/AccessForm.jsx';
import StatusMenu from '../components/StatusMenu.jsx';
import SettingsMenu from '../components/SettingsMenu.jsx';
import IpcService from '../IpcRendererService';
import '../styles/main.scss';
/**
* Main Application react container responsible for front-end logic.
* Subscribes to IPCRendererService for main process events, displays presentational components, saves credentials and more.
* Due to lack of Redux, every information is stored in this.state.
*/
class Application extends React.Component {
/**
* constructor
* @param {object} props
*/
constructor(props) {
super(props);
this.state = {
// Default permissions e.g. public-read-write
ACL: window.localStorage.getItem('ACL') || false,
// Default bucket name
bucket: window.localStorage.getItem('bucket') || '',
// Default folder name
folder: window.localStorage.getItem('folder') || '',
// Available buckets array
buckets: [],
// List of files which are being uploaded and were already uploaded
files: [],
// Tells whether user has entered correct AWS credentials
isLoggedIn: window.localStorage.getItem('isSetupCorrectly') || false,
// Set to true during S3.listBuckets
isLoading: false,
// Set to true after confirming settings in StatusMenu component
isSettingsSet: window.localStorage.getItem('isSetupCorrectly') || false,
// Contains error information if applicable
loginError: {},
// Default storage class, e.g. REDUCED_REDUNDANCY
storageClass: window.localStorage.getItem('storageClass') || '',
};
this.bucketsLoaded = this._bucketsLoaded.bind(this);
this.credentialsSubmitted = this._credentialsSubmitted.bind(this);
this.getMenu = this._getMenu.bind(this);
this.resetSettings = this._resetSettings.bind(this);
this.settingsSet = this._settingsSet.bind(this);
this.startLoading = this._startLoading.bind(this);
this.uploadStarted = this._uploadStarted.bind(this);
this.uploadFailed = this._uploadFailed.bind(this);
this.uploadSucceeded = this._uploadSucceeded.bind(this);
this.uploadProgressed = this._uploadProgressed.bind(this);
/**
* Subscribe for events from Electron main process related with uploading files.
*/
IpcService.subscribeUploadEvents(
this.uploadFailed,
this.uploadSucceeded,
this.uploadProgressed,
this.uploadStarted
);
/**
* Add possibility to drag files on window and prevent loading content of it.
*/
window.addEventListener('dragover', (event) => {
event.preventDefault();
});
window.addEventListener('drop', (event) => {
event.preventDefault();
IpcService.sendDroppedFiles(event.dataTransfer.files);
});
}
/**
* Procedure fired when bucket list is fetched using AWS SDK.
* @param payload
* @private
*/
_bucketsLoaded(payload) {
this.setState({
buckets: payload.Buckets,
isLoggedIn: true,
loginError: {},
isLoading: false,
});
}
/**
* Procedure fired when user enters credentials in AccessForm component. Calls AWS.S3.listBuckets
* function with provided keys.
* @param accessKey
* @param secretKey
* @private
*/
_credentialsSubmitted(accessKey, secretKey) {
this.startLoading();
IpcService.requestBuckets(accessKey, secretKey)
.then((data) => {
this.bucketsLoaded(data);
window.localStorage.setItem('accessKey', accessKey);
window.localStorage.setItem('secretKey', secretKey);
})
.catch((loginError) => {
this.setState({
loginError,
isLoggedIn: false,
isLoading: false,
})
});
}
/**
* Rendering helper function, returns presentational component suitable for current app status.
* E.g. After entering credentials and before S3 API response, loading indicator is displayed.
* @returns {XML}
* @private
*/
_getMenu() {
if (this.state.isLoading) {
return ;
}
if (this.state.isLoggedIn) {
if (this.state.isSettingsSet) {
return ;
} else {
return ;
}
} else {
return
}
}
_resetSettings() {
window.localStorage.setItem('isSetupCorrectly', false);
this.setState({
isLoggedIn: false,
isSettingsSet: false,
});
}
/**
* Procedure fired after saving settings in SettingsMenu presentational component.
* Saves configuration in localStorage and current state. Also sends this information to main
* electron process.
*
* @param settings
* @private
*/
_settingsSet(settings) {
window.localStorage.setItem('storageClass', settings.storageClass);
window.localStorage.setItem('ACL', settings.ACL);
window.localStorage.setItem('encryption', settings.encryption);
window.localStorage.setItem('bucket', settings.bucket);
window.localStorage.setItem('folder', settings.folder);
window.localStorage.setItem('isSetupCorrectly', true);
IpcService.saveConfig({
ACL: settings.ACL,
storageClass: settings.storageClass,
encryption: settings.encryption,
bucket: settings.bucket,
folder: settings.folder,
});
this.setState({
ACL: settings.ACL,
bucket: settings.bucket,
folder: settings.folder,
isSettingsSet: true,
storageClass: settings.storageClass,
});
}
/**
* Shows loading indicator
* @private
*/
_startLoading() {
this.setState({
isLoading: true
});
}
// TODO: Add directories support and abort function
/**
* Procedure fired when Electron main process notifies renderer process via IPC about upload process initiation.
* Appends this.state.files with dropped files.
* Contains file path dropped on icon.
*
* @param file
* @private
*/
_uploadStarted(file) {
const files = this.state.files;
const folder = this.state.folder;
files.push({
path: file.data,
key: folder + '/' + file.data.split('/').pop(),
status: 'uploading',
url: '',
});
this.setState({
files
});
}
/**
* Procedure fired when Electron main process notifies renderer process via IPC about error during upload.
* Contains error information.
*
* @param error
* @private
*/
_uploadFailed(error) {
const newFiles = this.state.files;
let updated = this.state.files.find((file) => file.key === error.data.Key);
updated.status = 'failed';
updated.error = error;
this.setState({
files: newFiles,
});
}
/**
* Procedure fired when Electron main process notifies renderer process via IPC about successful upload.
* @param data
* @private
*/
_uploadSucceeded(data) {
const newFiles = this.state.files;
let updated = this.state.files.find((file) => file.key === data.data.Key);
updated.url = data.data.Location;
updated.status = 'uploaded';
this.setState({
files: newFiles.reverse(),
});
}
/**
* Procedure fired when Electron main process notifies renderer process via IPC about progression in uploading a file(s).
* @param progress
* @private
*/
_uploadProgressed(progress) {
}
/**
* React built-in function
* @returns {XML}
*/
render() {
return (
{this.getMenu()}
);
}
}
export default Application;
================================================
FILE: app/index.jsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';
import Application from './containers/app.jsx';
/**
* Renders Application Container into DOM
*/
ReactDOM.render(, document.getElementById('app'));
================================================
FILE: app/styles/base/_animations.scss
================================================
// Wrong input animation
@keyframes shake {
from, to {
transform: translate3d(0, 0, 0);
}
10%, 30%, 50%, 70%, 90% {
transform: translate3d(-10px, 0, 0);
}
20%, 40%, 60%, 80% {
transform: translate3d(10px, 0, 0);
}
}
.shake {
animation-name: shake;
}
.animated {
animation-duration: 1s;
animation-fill-mode: both;
}
.animated.infinite {
animation-iteration-count: infinite;
}
.animated.hinge {
animation-duration: 2s;
}
.animated.flipOutX,
.animated.flipOutY,
.animated.bounceIn,
.animated.bounceOut {
animation-duration: .75s;
}
// Loading Animation
$boxSize: 15;
$color: #dfdedd;
$color2: #4f4d49;
.spin-box {
position: absolute;
margin: auto;
left: 0;
top: 0;
bottom: 0;
right: 0;
border-radius: 100%;
width: $boxSize * 1px;
height: $boxSize * 1px;
box-shadow:
$boxSize*1px $boxSize*1px $color2,
$boxSize*-1px $boxSize*1px $color,
$boxSize*-1px $boxSize*-1px $color2,
$boxSize*1px $boxSize*-1px $color;
animation: spin ease infinite 4s;
}
@keyframes spin {
0%, 100% {
box-shadow:
$boxSize*1px $boxSize*1px $color2,
$boxSize*-1px $boxSize*1px $color,
$boxSize*-1px $boxSize*-1px $color2,
$boxSize*1px $boxSize*-1px $color;
}
25% {
box-shadow:
$boxSize*-1px $boxSize*1px $color,
$boxSize*-1px $boxSize*-1px $color2,
$boxSize*1px $boxSize*-1px $color,
$boxSize*1px $boxSize*1px $color2;
}
50% {
box-shadow:
$boxSize*-1px $boxSize*-1px $color2,
$boxSize*1px $boxSize*-1px $color,
$boxSize*1px $boxSize*1px $color2,
$boxSize*-1px $boxSize*1px $color;
}
75% {
box-shadow:
$boxSize*1px $boxSize*-1px $color,
$boxSize*1px $boxSize*1px $color2,
$boxSize*-1px $boxSize*1px $color,
$boxSize*-1px $boxSize*-1px $color2;
}
}
================================================
FILE: app/styles/base/_main.scss
================================================
$white-color: #EAEAEA;
$grey-color: #b4b4b4;
$black-color: #424242;
$green-color: #2dc83a;
html {
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body, #app {
height: 100%;
margin: 0;
}
button {
background: none;
border: none;
color: #FFFFFF;
font-size: 1.2em;
font-weight: 400;
}
::-webkit-scrollbar {
background: transparent;
width: 0;
}
*:focus {
outline: none;
}
.white {
color: $white-color;
}
.extra-margin {
margin: 10px;
}
================================================
FILE: app/styles/base/_typography.scss
================================================
@import './_main.scss';
p, span, form, input, button {
color: $black-color;
font-family: "Futura-Medium", sans-serif;
font-size: 10px;
letter-spacing: -0.30px;
line-height: 1;
margin-top: 0;
text-transform: none;
text-align: center;
}
.big {
font-size: 14px;
}
================================================
FILE: app/styles/components/_accessForm.scss
================================================
@import '../base/_main.scss';
.access-form-container {
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
justify-content: center;
height: 90vh;
form {
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
justify-content: center;
margin: 20px;
input {
width: 200px;
border-width: 0 0 1px 0;
}
#submitButton {
margin-top: 10px;
border-radius: 50px;
font-family: "Montserrat", sans-serif;
font-size: 1.2em;
font-weight: 700;
padding: 10px 40px 10px 40px;
border: none;
background-color: $green-color;
color: $white-color;
}
}
}
================================================
FILE: app/styles/components/_settingsMenu.scss
================================================
@import '../base/_main';
.permissions-dialog-container {
display: flex;
flex-direction: row;
.column {
width: 50%;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.left {
background: #F5F5F5;
}
.selected {
text-decoration: underline;
}
.right {
background-color: $green-color;
}
form {
margin: 10px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
p {
margin-top: 5px;
margin-bottom: 5px;
font-size: 9px;
color: #FFFFFF;
}
}
.form-head {
opacity: 0.7;
}
ul {
padding: 0;
margin: auto;
}
li {
list-style: none;
overflow: hidden;
white-space: nowrap;
}
}
================================================
FILE: app/styles/components/_statusMenu.scss
================================================
@import '../base/_main';
.status-menu-container {
width: 100%;
height: 95vh;
}
.status-menu-bar {
background-color: $green-color;
display: flex;
flex-direction: row;
padding: 10px;
width: 100%;
height: 40px;
p, span {
color: $white-color;
}
div {
display: flex;
flex-direction: column;
}
.align-start {
align-items: flex-start;
}
.bigger {
font-size: 1.1em;
}
.settings {
height: 20px;
width: 20px;
background-image: url('../images/settings.png');
background-size: contain;
background-repeat: no-repeat;
top: 0;
right: 0;
margin: 10px;
position: absolute;
}
}
.status-menu-filelist {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
li {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
height: 15px;
padding: 8px;
}
li:hover {
background-color: $grey-color;
}
.status-icon {
background-size: contain;
background-repeat: no-repeat;
height: 16px;
width: 16px;
}
.uploaded {
background-image: url('../images/okay.png');
}
.failed {
background-image: url('../images/error.png');
}
.uploading {
animation: blink 1s linear infinite;
background-color: $green-color;
border-radius: 100%;
width: 8px;
height: 8px;
}
}
.status-menu-nofiles {
align-content: center;
align-items: center;
display: flex;
height: 80%;
justify-content: center;
width: 100%;
}
@keyframes blink {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
================================================
FILE: app/styles/main.scss
================================================
// Base
@import 'base/_typography';
@import 'base/_main';
@import 'base/_animations';
// Components
@import 'components/_accessForm.scss';
@import 'components/settingsMenu';
@import 'components/_statusMenu.scss';
================================================
FILE: index.html
================================================
S3 Uploader
================================================
FILE: main.js
================================================
const menubar = require('menubar');
const fs = require('fs');
const path = require('path');
const notifier = require('node-notifier');
const { ipcMain, clipboard, Menu } = require('electron');
const configService = require('./ConfigurationService');
const S3Service = require('./S3Service');
/**
* Electron window high-level wrapped constructor.
*/
const mb = menubar({
width: 400,
height: 300,
});
let s3 = null;
/**
* Sends data from main Electron process to renderer process.
* Works only if window has been instantiated first.
* @param thread
* @param data
*/
const sendWebContentsMessage = (thread, data) => {
if (mb.window !== undefined) {
mb.window.webContents.send(thread, {
data,
});
} else {
console.warn('mb.window is not defined, sending message ignored...');
}
};
/**
* Sends OS-level native notification invoker
* @param title
* @param message
*/
const sendNotification = (title, message) => {
notifier.notify({
title,
message,
});
};
/**
* S3.ManagedUpload high-level wrapper.
*
* Forwards EventEmitter events to IPC Bus to renderer process.
* Sends notification if process fails or succeeds.
* Automatically replaces clipboard contents with URL to file in AWS S3.
*
* @param uploadEventEmitter
* @param file
*/
const handleUpload = (uploadEventEmitter, file) => {
sendWebContentsMessage('UPLOAD_START', file);
uploadEventEmitter.on('error', (error) => {
sendNotification('Upload Error!', 'Click icon for more details...');
sendWebContentsMessage('UPLOAD_ERROR', error);
});
uploadEventEmitter.on('progress', (data) => {
sendWebContentsMessage('UPLOAD_PROGRESS', data);
});
uploadEventEmitter.on('success', (data) => {
clipboard.writeText(data.Location);
sendNotification('File Uploaded!', 'Link has been copied to clipboard');
sendWebContentsMessage('UPLOAD_SUCCESS', data);
});
};
/**
* Function returns Promise resolved if supplied path is directory (contains files inside this
* directory) or rejected if it's file.
* @param file
*/
const checkForDirectory = (file) => new Promise((resolve, reject) => {
fs.stat(file, (err, stats) => {
if (err) {
throw new Error(err);
} else if (stats.isDirectory()) {
fs.readdir(file, (error, files) => {
if (err) {
throw new Error(error);
}
return resolve(files.map((node) => path.join(file, node)));
});
} else {
return reject();
}
});
});
/**
* Reads file and passes it's binary data for upload.
* @param file
*/
const readFileAndUpload = (file) => {
fs.readFile(file, (err, data) => {
if (err) throw new Error(err);
handleUpload(s3.uploadFile(file.split('/').pop(), data), file);
});
};
/**
* Callback function for handling drop-files events.
*
* Takes array of files (directories) as argument and processes sequentially for upload.
*
* Fails if S3Service has been not initialized yet.
* @param files
*/
const handleFiles = (files) => {
if (s3 !== null) {
files.forEach((file) => {
checkForDirectory(file)
.then((dirFiles) => handleFiles(dirFiles))
.catch(() => readFileAndUpload(file));
});
} else {
throw new Error('Client has been not initialized yet!');
}
};
/**
* MenuBar EventEmitter listener.
*
* Listens for window readiness and loads config from file.
* If config is present, instantiates S3Service basing on that information.
*/
mb.on('ready', () => {
const template = [{label: 'Actions', submenu: [{role: "paste"}, {role: "quit"}]}];
const menu = Menu.buildFromTemplate(template);
//Menu.setApplicationMenu(menu);
configService.loadConfig()
.then((config) => {
if (config.bucket !== null) {
s3 = new S3Service(config.accessKey, config.secretKey);
} else {
throw new Error('Not ready for uploading, please configure tool first.');
}
})
.catch(() => {
throw new Error('Error while loading configuration');
});
mb.tray.on('drop-files', (event, files) => handleFiles(files));
});
/**
* IPC EventEmitter listener.
* Receiver of events from renderer process.
*/
ipcMain.on('GET_BUCKETS', (event, arg) => {
s3 = new S3Service(arg.accessKey, arg.secretKey);
s3.getBuckets().then((data) => {
event.sender.send('GET_BUCKETS_REPLY', {
success: true,
data,
});
configService.updateConfig(arg);
}).catch((error) => {
event.sender.send('GET_BUCKETS_REPLY', {
success: false,
error,
});
});
});
ipcMain.on('UPDATE_CONFIG', (event, arg) => {
configService.updateConfig(arg);
});
ipcMain.on('DROP_FILES', (event, arg) => {
handleFiles(arg.files);
});
================================================
FILE: package.json
================================================
{
"name": "s3-uploader",
"version": "0.0.2",
"description": "AWS S3 Uploader ",
"main": "main.js",
"scripts": {
"start": "webpack -w & electron .",
"clean": "rm -fr node_modules && npm install",
"eslint": "eslint .",
"prod": "npm prune --production",
"package-osx": "npm run prod && electron-packager . --platform=darwin --arch=x64",
"package-win": "npm run prod && electron packager . --platform=win --arch=x64 --asar",
"package-linux": "npm run prod && electron-packager . --platform=linux --arch=x64 --asar"
},
"keywords": [
"Electron",
"S3",
"Amazon Web Services",
"React"
],
"author": "Rafal Wilinski",
"repository": {
"type": "git",
"url": "https://github.com/RafalWilinski/s3-uploader.git"
},
"license": "ISC",
"devDependencies": {
"babel-cli": "^6.10.1",
"babel-core": "^6.10.4",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.9.0",
"babel-preset-react": "^6.11.1",
"css-loader": "^0.23.1",
"devtron": "^1.2.1",
"electron-debug": "^1.0.1",
"electron-packager": "^7.3.0",
"eslint": "^3.0.1",
"eslint-config-airbnb-base": "^4.0.0",
"eslint-plugin-import": "^1.10.2",
"node-sass": "^3.8.0",
"sass-loader": "^4.0.0",
"spectron": "^3.3.0",
"style-loader": "^0.13.1",
"url-loader": "^0.5.7",
"file-loader":"^0.9.0",
"webpack": "^1.13.1"
},
"dependencies": {
"aws-sdk": "^2.4.4",
"electron-prebuilt": "^1.2.0",
"electron-packager": "^7.3.0",
"menubar": "^4.1.2",
"node-notifier": "^4.6.0",
"react": "^15.2.0",
"react-dom": "^15.2.0",
"request": "^2.72.0",
"file-type": "^3.9.0"
}
}
================================================
FILE: webpack.config.js
================================================
const webpack = require('webpack');
module.exports = {
entry: [
'./app/index.jsx',
],
output: {
filename: 'public/bundle.js',
},
target: 'electron',
module: {
loaders: [
{
test: /\.jsx?$/,
exclude: /(node_modules)/,
loader: 'babel',
query: {
presets: ['react', 'es2015'],
},
}, {
test: /\.scss?$/,
exclude: /(node_modules)/,
loader: 'style!css!sass',
}, {
test: /\.(jpg|png)$/,
loader: 'url?limit=25000',
include: /(app)/,
}
],
noParse: [
/aws\-sdk/,
]
},
watchOptions: {
poll: 100,
},
plugins: [
new webpack.ExternalsPlugin('commonjs', [
'desktop-capturer',
'electron-prebuilt',
'electron',
'ipc',
'ipc-renderer',
'native-image',
'remote',
'web-frame',
'clipboard',
'crash-reporter',
'screen',
'shell'
])
]
};