mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-11-02 20:23:32 +08:00
feature (git): add git support
This commit is contained in:
@ -34,7 +34,7 @@ app.get('/cat', function(req, res){
|
|||||||
})
|
})
|
||||||
.catch(function(err){
|
.catch(function(err){
|
||||||
res.send({status: 'error', message: err.message || 'couldn\t read the file', trace: err})
|
res.send({status: 'error', message: err.message || 'couldn\t read the file', trace: err})
|
||||||
})
|
});
|
||||||
}else{
|
}else{
|
||||||
res.send({status: 'error', message: 'unknown path'})
|
res.send({status: 'error', message: 'unknown path'})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,16 +20,20 @@ app.post('/', function(req, res){
|
|||||||
type: req.body.type,
|
type: req.body.type,
|
||||||
payload: state
|
payload: state
|
||||||
};
|
};
|
||||||
res.cookie('auth', crypto.encrypt(persist), { maxAge: 365*24*60*60*1000, httpOnly: true });
|
const cookie = crypto.encrypt(persist);
|
||||||
res.send({status: 'ok', result: 'pong'});
|
if(Buffer.byteLength(cookie, 'utf-8') > 4096){
|
||||||
|
res.send({status: 'error', message: 'we can\'t authenticate you', })
|
||||||
|
}else{
|
||||||
|
res.cookie('auth', crypto.encrypt(persist), { maxAge: 365*24*60*60*1000, httpOnly: true });
|
||||||
|
res.send({status: 'ok', result: 'pong'});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err)
|
|
||||||
let message = function(err){
|
let message = function(err){
|
||||||
let t = 'could not establish a connection'
|
let t = err && err.message || 'could not establish a connection';
|
||||||
if(err.code){
|
if(err.code){
|
||||||
t += ' ('+err.code+')'
|
t += ' ('+err.code+')';
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
res.send({status: 'error', message: message(err), code: err.code});
|
res.send({status: 'error', message: message(err), code: err.code});
|
||||||
|
|||||||
263
server/model/backend/git.js
Normal file
263
server/model/backend/git.js
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
const gitclient = require("nodegit"),
|
||||||
|
toString = require('stream-to-string'),
|
||||||
|
crypto = require('crypto'),
|
||||||
|
fs = require('fs'),
|
||||||
|
Readable = require('stream').Readable,
|
||||||
|
Path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
test: function(params){
|
||||||
|
if(!params || !params.repo){ return Promise.reject({message: 'invalid authentication', code: 'INVALID_PARAMS'}) };
|
||||||
|
if(!params.commit) params.commit = "{action}({filename}): {path}"
|
||||||
|
if(!params.branch) params.branch = 'master';
|
||||||
|
if(!params.author_name) params.author_name = "Nuage";
|
||||||
|
if(!params.author_email) params.author_email = "https://nuage.kerjean.me";
|
||||||
|
if(!params.committer_name) params.committer_name = "Nuage";
|
||||||
|
if(!params.committer_email) params.committer_email = "https://nuage.kerjean.me";
|
||||||
|
|
||||||
|
if(params.password && params.password.length > 2700){
|
||||||
|
return Promise.reject({message: "Your password couldn\'t fit in a cookie :/", code: "COOKIE_ERROR"})
|
||||||
|
}
|
||||||
|
return git.clone(params);
|
||||||
|
},
|
||||||
|
cat: function(path, params){
|
||||||
|
return file.cat(Path.join(path_repo(params), path));
|
||||||
|
},
|
||||||
|
ls: function(path, params){
|
||||||
|
return file.ls(Path.join(path_repo(params), path))
|
||||||
|
.then((files) => files.filter((file) => (file.name === '.git' && file.type === 'directory') ? false: true))
|
||||||
|
},
|
||||||
|
write: function(path, content, params){
|
||||||
|
return file.write(Path.join(path_repo(params), path), content)
|
||||||
|
.then(() => git.save(params, path, "write"));
|
||||||
|
},
|
||||||
|
rm: function(path, params){
|
||||||
|
return file.rm(Path.join(path_repo(params), path))
|
||||||
|
.then(() => git.save(params, path, "delete"));
|
||||||
|
},
|
||||||
|
mv: function(from, to, params){
|
||||||
|
return file.mv(Path.join(path_repo(params), from), Path.join(path_repo(params), to))
|
||||||
|
.then(() => git.save(params, to, 'move'));
|
||||||
|
},
|
||||||
|
mkdir: function(path, params){
|
||||||
|
return file.mkdir(Path.join(path_repo(params), path));
|
||||||
|
},
|
||||||
|
touch: function(path, params){
|
||||||
|
var stream = new Readable(); stream.push(''); stream.push(null);
|
||||||
|
return file.write(Path.join(path_repo(params), path), stream)
|
||||||
|
.then(() => git.save(params, path, 'create'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function path_repo(obj){
|
||||||
|
let hash = crypto.createHash('md5').update('git_');
|
||||||
|
for(let key in obj){
|
||||||
|
if(typeof obj[key] === 'string'){
|
||||||
|
hash.update(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/tmp/"+hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = {};
|
||||||
|
file.write = function (path, stream){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
let writer = fs.createWriteStream(path, { flags : 'w' });
|
||||||
|
stream.pipe(writer);
|
||||||
|
writer.on('close', function(){
|
||||||
|
done('ok');
|
||||||
|
});
|
||||||
|
writer.on('error', function(error){
|
||||||
|
err(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
file.mkdir = function(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.mkdir(path, function(error){
|
||||||
|
if(error){ return err(error); }
|
||||||
|
return done("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
file.mv = function(from, to){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.rename(from, to, function(error){
|
||||||
|
if(error){ return err(error); }
|
||||||
|
return done("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
file.ls = function(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.readdir(path, (error, files) => {
|
||||||
|
if(error){ return err(error); }
|
||||||
|
Promise.all(files.map((file) => {
|
||||||
|
return stats(Path.join(path, file)).then((stat) => {
|
||||||
|
stat.name = file;
|
||||||
|
return Promise.resolve(stat);
|
||||||
|
});
|
||||||
|
})).then((files) => {
|
||||||
|
done(files.map((file) => {
|
||||||
|
return {
|
||||||
|
size: file.size,
|
||||||
|
time: new Date(file.mtime).getTime(),
|
||||||
|
name: file.name,
|
||||||
|
type: file.isFile()? 'file' : 'directory'
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}).catch((error) => err(error));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function stats(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.stat(path, function(error, res){
|
||||||
|
if(error) return err(error);
|
||||||
|
return done(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.rm = function(path){
|
||||||
|
return rm(path);
|
||||||
|
|
||||||
|
function rm(path){
|
||||||
|
return stat(path).then((_stat) => {
|
||||||
|
if(_stat.isDirectory()){
|
||||||
|
return ls(path)
|
||||||
|
.then((files) => Promise.all(files.map(file => rm(Path.join(path, file)))))
|
||||||
|
.then(() => removeEmptyFolder(path));
|
||||||
|
}else{
|
||||||
|
return removeFileOrLink(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmptyFolder(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.rmdir(path, function(error){
|
||||||
|
if(error){ return err(error); }
|
||||||
|
return done("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeFileOrLink(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.unlink(path, function(error){
|
||||||
|
if(error){ return err(error); }
|
||||||
|
return done("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ls(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.readdir(path, function (error, files) {
|
||||||
|
if(error) return err(error)
|
||||||
|
return done(files)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function stat(path){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
fs.stat(path, function (error, _stat) {
|
||||||
|
if(error){ return err(error); }
|
||||||
|
return done(_stat);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.cat = function(path){
|
||||||
|
return Promise.resolve(fs.createReadStream(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const git = {};
|
||||||
|
git.clone = function(params, alreadyExist = false){
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
gitclient.Clone(params.repo, path_repo(params), {fetchOpts: { callbacks: { credentials: git_creds.bind(null, params) }}})
|
||||||
|
.then((repo) => pull(repo, params.branch))
|
||||||
|
.then(() => done(params))
|
||||||
|
.catch((error) => {
|
||||||
|
if(error.errno === -4){
|
||||||
|
return gitclient.Repository.open(path_repo(params))
|
||||||
|
.then((repo) => {
|
||||||
|
return pull(repo, params.branch)
|
||||||
|
.then(() => _refresh(repo, params.branch, params))
|
||||||
|
})
|
||||||
|
.then(() => done(params))
|
||||||
|
.catch((error) => {
|
||||||
|
err({code: error && error.errno? "GIT_ERR"+error.errno : "GIT_ERR" , message: error && error.message || "can\'t clone the repo" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return err({code: error && error.errno? "GIT_ERR"+error.errno : "GIT_ERR" , message: error && error.message || "can\'t clone the repo" });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
function pull(repo, branch){
|
||||||
|
return repo.getBranchCommit("origin/"+params.branch)
|
||||||
|
.then((commit) => {
|
||||||
|
return repo.createBranch(params.branch, commit)
|
||||||
|
.catch(() => Promise.resolve())
|
||||||
|
})
|
||||||
|
.then(() => repo.checkoutBranch(params.branch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _refresh(repo, branch, params){
|
||||||
|
return repo.fetchAll({callbacks: { credentials: git_creds.bind(null, params) }})
|
||||||
|
.then(() => repo.mergeBranches(branch, "origin/"+branch, gitclient.Signature.default(repo), 2))
|
||||||
|
.catch(err => {
|
||||||
|
if(err.errno === -13){
|
||||||
|
return git.save(params, '', 'merge')
|
||||||
|
.then(() => _refresh(repo, branch, params))
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
git.save = function(params, path = '', type = ''){
|
||||||
|
let data = {repo: null, commit: null, index: null, oid: null}
|
||||||
|
const author = gitclient.Signature.now(params.author_name, params.author_email);
|
||||||
|
const committer = gitclient.Signature.now(params.committer_name, params.committer_email);
|
||||||
|
const message = params.commit
|
||||||
|
.replace("{action}", type)
|
||||||
|
.replace("{dirname}", Path.dirname(path))
|
||||||
|
.replace("{filename}", Path.basename(path))
|
||||||
|
.replace("{path}", path || '');
|
||||||
|
|
||||||
|
return new Promise((done, err) => {
|
||||||
|
gitclient.Repository.open(path_repo(params))
|
||||||
|
.then((repo) => {
|
||||||
|
data.repo = repo;
|
||||||
|
return repo.getBranchCommit(params.branch)
|
||||||
|
})
|
||||||
|
.then((commit) => {
|
||||||
|
data.commit = commit;
|
||||||
|
return commit.repo.refreshIndex();
|
||||||
|
})
|
||||||
|
.then((index) => {
|
||||||
|
data.index = index;
|
||||||
|
return index.addAll();
|
||||||
|
})
|
||||||
|
.then(() => data.index.write())
|
||||||
|
.then(() => data.index.writeTree())
|
||||||
|
.then((oid) => data.repo.createCommit("HEAD", author, committer, message, oid, [data.commit]))
|
||||||
|
.then((commit) => data.repo.getRemote("origin"))
|
||||||
|
.then((remote) => remote.push(["refs/heads/"+params.branch+":refs/heads/"+params.branch], { callbacks: { credentials: git_creds.bind(null, params) }}))
|
||||||
|
.then((ok) => done(ok))
|
||||||
|
.catch((error) => {
|
||||||
|
err(error)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function git_creds(params, url, username){
|
||||||
|
const user = username? username : params.username;
|
||||||
|
if(/http[s]?\:\/\//.test(url)){
|
||||||
|
return gitclient.Cred.userpassPlaintextNew(username, params.password);
|
||||||
|
}else{
|
||||||
|
return gitclient.Cred.sshKeyMemoryNew(username, "", params.password, params.passphrase || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -4,7 +4,8 @@ var backend = {
|
|||||||
webdav: require('./backend/webdav'),
|
webdav: require('./backend/webdav'),
|
||||||
dropbox: require('./backend/dropbox'),
|
dropbox: require('./backend/dropbox'),
|
||||||
gdrive: require('./backend/gdrive'),
|
gdrive: require('./backend/gdrive'),
|
||||||
s3: require('./backend/s3')
|
s3: require('./backend/s3'),
|
||||||
|
git: require('./backend/git')
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.cat = function(path, params, res){
|
exports.cat = function(path, params, res){
|
||||||
|
|||||||
@ -4,7 +4,8 @@ var backend = {
|
|||||||
webdav: require('./backend/webdav'),
|
webdav: require('./backend/webdav'),
|
||||||
dropbox: require('./backend/dropbox'),
|
dropbox: require('./backend/dropbox'),
|
||||||
gdrive: require('./backend/gdrive'),
|
gdrive: require('./backend/gdrive'),
|
||||||
s3: require('./backend/s3')
|
s3: require('./backend/s3'),
|
||||||
|
git: require('./backend/git')
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.test = function(params){
|
exports.test = function(params){
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export class ConnectPage extends React.Component {
|
|||||||
advanced_sftp: false, // state of checkbox in the UI
|
advanced_sftp: false, // state of checkbox in the UI
|
||||||
advanced_webdav: false,
|
advanced_webdav: false,
|
||||||
advanced_s3: false,
|
advanced_s3: false,
|
||||||
|
advanced_git: false,
|
||||||
credentials: {},
|
credentials: {},
|
||||||
password: password.get() || null,
|
password: password.get() || null,
|
||||||
marginTop: this._marginTop()
|
marginTop: this._marginTop()
|
||||||
@ -79,6 +80,9 @@ export class ConnectPage extends React.Component {
|
|||||||
if(this.state.credentials['s3'] && this.state.credentials['s3']['path']){
|
if(this.state.credentials['s3'] && this.state.credentials['s3']['path']){
|
||||||
this.setState({advanced_s3: true})
|
this.setState({advanced_s3: true})
|
||||||
}
|
}
|
||||||
|
if(this.state.credentials['git'] && (this.state.credentials['git']['username'] || this.state.credentials['git']['commit'] || this.state.credentials['git']['branch'] || this.state.credentials['git']['passphrase'] || this.state.credentials['git']['author_name'] || this.state.credentials['git']['author_email'] || this.state.credentials['git']['committer_name'] || this.state.credentials['git']['committer_email'])){
|
||||||
|
this.setState({advanced_git: true})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,16 +193,17 @@ export class ConnectPage extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div style={{background: '#f89e6b'}}>
|
<div style={{background: '#f89e6b'}}>
|
||||||
<ForkMe repo="https://github.com/mickael-kerjean/nuage" />
|
<ForkMe repo="https://github.com/mickael-kerjean/nuage" />
|
||||||
<Container maxWidth="500px">
|
<Container maxWidth="565px">
|
||||||
<NgIf cond={this.state.loading === true}>
|
<NgIf cond={this.state.loading === true}>
|
||||||
<Loader/>
|
<Loader/>
|
||||||
</NgIf>
|
</NgIf>
|
||||||
<NgIf cond={this.state.loading === false}>
|
<NgIf cond={this.state.loading === false}>
|
||||||
<Card style={{marginTop: this.state.marginTop+'px', whiteSpace: '', borderRadius: '3px', boxShadow: 'none'}}>
|
<Card style={{marginTop: this.state.marginTop+'px', whiteSpace: '', borderRadius: '3px', boxShadow: 'none'}}>
|
||||||
<div style={{display: 'flex', margin: '-10px -11px 20px', padding: '0px 0px 6px 0'}} className={('ontouchstart' in window) ? 'scroll-x' : ''}>
|
<div style={{display: 'flex', margin: '-10px -11px 20px', padding: '0px 0px 6px 0'}} className={window.innerWidth < 600 ? 'scroll-x' : ''}>
|
||||||
<Button theme={this.state.type === 'webdav'? 'primary' : null} style={{...style.top, borderBottomLeftRadius: 0}} onClick={this.onChange.bind(this, 'webdav')}>WebDav</Button>
|
<Button theme={this.state.type === 'webdav'? 'primary' : null} style={{...style.top, borderBottomLeftRadius: 0}} onClick={this.onChange.bind(this, 'webdav')}>WebDav</Button>
|
||||||
<Button theme={this.state.type === 'ftp'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'ftp')}>FTP</Button>
|
<Button theme={this.state.type === 'ftp'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'ftp')}>FTP</Button>
|
||||||
<Button theme={this.state.type === 'sftp'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'sftp')}>SFTP</Button>
|
<Button theme={this.state.type === 'sftp'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'sftp')}>SFTP</Button>
|
||||||
|
<Button theme={this.state.type === 'git'? 'primary' : null} style={{...style.top, borderBottomRightRadius: 0}} onClick={this.onChange.bind(this, 'git')}>Git</Button>
|
||||||
<Button theme={this.state.type === 's3'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 's3')}>S3</Button>
|
<Button theme={this.state.type === 's3'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 's3')}>S3</Button>
|
||||||
<Button theme={this.state.type === 'dropbox'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'dropbox')}>Dropbox</Button>
|
<Button theme={this.state.type === 'dropbox'? 'primary' : null} style={style.top} onClick={this.onChange.bind(this, 'dropbox')}>Dropbox</Button>
|
||||||
<Button theme={this.state.type === 'gdrive'? 'primary' : null} style={{...style.top, borderBottomRightRadius: 0}} onClick={this.onChange.bind(this, 'gdrive')}>Drive</Button>
|
<Button theme={this.state.type === 'gdrive'? 'primary' : null} style={{...style.top, borderBottomRightRadius: 0}} onClick={this.onChange.bind(this, 'gdrive')}>Drive</Button>
|
||||||
@ -247,6 +252,25 @@ export class ConnectPage extends React.Component {
|
|||||||
</NgIf>
|
</NgIf>
|
||||||
<Button style={{marginTop: '15px', color: 'white'}} theme="emphasis">CONNECT</Button>
|
<Button style={{marginTop: '15px', color: 'white'}} theme="emphasis">CONNECT</Button>
|
||||||
</NgIf>
|
</NgIf>
|
||||||
|
<NgIf cond={this.state.type === 'git'}>
|
||||||
|
<Input type="text" name="repo" placeholder="Repository*" defaultValue={this.getDefault('git', 'repo')} autoComplete="off" />
|
||||||
|
<Textarea type="password" name="password" placeholder="Password" defaultValue={this.getDefault('git', 'password')} autoComplete="off" />
|
||||||
|
<Input type="hidden" name="type" value="git"/>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<input checked={this.state.advanced_git} onChange={e => { this.setState({advanced_git: JSON.parse(e.target.checked)})}} type="checkbox" autoComplete="off"/> Advanced
|
||||||
|
</label>
|
||||||
|
<NgIf cond={this.state.advanced_git === true} style={{marginTop: '2px'}}>
|
||||||
|
<Input type="text" name="username" placeholder="Username" defaultValue={this.getDefault('git', 'username')} autoComplete="off" />
|
||||||
|
<Input type="text" name="passphrase" placeholder="Passphrase" defaultValue={this.getDefault('git', 'passphrase')} autoComplete="off" />
|
||||||
|
<Input type="text" name="commit" placeholder="Commit Format: default to '{action}({filename}): {path}'" defaultValue={this.getDefault('git', 'format')} autoComplete="off" />
|
||||||
|
<Input type="text" name="branch" placeholder="Branch: default to 'master'" defaultValue={this.getDefault('git', 'branch')} autoComplete="off" />
|
||||||
|
<Input type="text" name="author_email" placeholder="Author email" defaultValue={this.getDefault('git', 'author_email')} autoComplete="off" />
|
||||||
|
<Input type="text" name="author_name" placeholder="Author name" defaultValue={this.getDefault('git', 'author_name')} autoComplete="off" />
|
||||||
|
<Input type="text" name="committer_email" placeholder="Committer email" defaultValue={this.getDefault('git', 'committer_email')} autoComplete="off" />
|
||||||
|
<Input type="text" name="committer_name" placeholder="Committer name" defaultValue={this.getDefault('git', 'committer_name')} autoComplete="off" />
|
||||||
|
</NgIf>
|
||||||
|
<Button style={{marginTop: '15px', color: 'white'}} theme="emphasis">CONNECT</Button>
|
||||||
|
</NgIf>
|
||||||
<NgIf cond={this.state.type === 's3'}>
|
<NgIf cond={this.state.type === 's3'}>
|
||||||
<Input type="text" name="access_key_id" placeholder="Access Key ID*" defaultValue={this.getDefault('s3', 'access_key_id')} autoComplete="off" />
|
<Input type="text" name="access_key_id" placeholder="Access Key ID*" defaultValue={this.getDefault('s3', 'access_key_id')} autoComplete="off" />
|
||||||
<Input type="password" name="secret_access_key" placeholder="Secret Access Key*" defaultValue={this.getDefault('s3', 'secret_access_key')} autoComplete="off" />
|
<Input type="password" name="secret_access_key" placeholder="Secret Access Key*" defaultValue={this.getDefault('s3', 'secret_access_key')} autoComplete="off" />
|
||||||
|
|||||||
Reference in New Issue
Block a user