const Path = require('path')
const Hoek = require('@hapi/hoek')
const ObjectId = require('bson-objectid')
const GpgPromised = require('gpg-promised')
const Debug = require('debug')
const debug = Debug('gpgfs.Bucket')
const Utils = require('./utils')
const GpgFsFile = require('./file')
const IStorage = require('./interface-storage')
/** Class representing gpgfs Bucket */
class Bucket {
/** @hideconstructor */
constructor({id, name, root}){
this.id = new ObjectId(id)
this.name = name
this.root = root
this.opened = false
this.index = null
this.metadata = null
this._fileCache = {}
debug('new -', name || id)
this.readKeychain = null
this.metaKeychain = null
this.keyFingerprints = {
read: null,
meta: null
}
this.keyPublics = {
read: null,
meta: null
}
}
/**
* Open and read metadata
* @method */
async open(){
if(this.opened){ return }
this.index = null
this.metadata = null
this._fileCache = {}
await Promise.all([
this.getIndex(),
this.getMetadata()
])
/*this.readKeychain = null
this.metaKeychain = null*/
await this.loadReadKeys()
this.name = this.metadata.bucketName
this.opened = true
debug('loaded ', this.name)
}
async release(){
debug('releasing', this.id.toString())
for(let id in this._fileCache){
await this._fileCache[id].release()
delete this._fileCache[id].bucket
this._fileCache[id].bucket = null
delete this._fileCache[id]
}
delete this.index
delete this.metadata
delete this._fileCache
this.index = null
this.metadata = null
this._fileCache = {}
}
async releaseFile(file){
delete this._fileCache[file.id.toString()]
this._fileCache[file.id.toString()] = undefined
}
/**
* Check if all metadata exists on disk
* @method
* @returns {boolean} */
async exists(){
const existance = await Promise.all([
this.root.fileExists( this.indexPath ),
this.root.fileExists( this.metadataPath )
])
let e = existance[0] && existance[1]
debug('exists', e)
return e
}
/**
* Create bucket if it does not exist
* @method
*/
async create(){
debug('create -', this.id)
await this.root.cacheWhoami()
//if(this.exists()){ throw new Error('bucket exists') }
if(await this.root.fileExists( this.readKeyPath )){ throw new Error('bucket read key exists') }
if(await this.root.fileExists( this.metaReadKeyPath )){ throw new Error('bucket meta read key exists') }
if(await this.root.fileExists( this.indexPath )){ throw new Error('bucket index exists') }
if(await this.root.fileExists( this.metadataPath )){ throw new Error('bucket metadata exists') }
await this.root.touchDir(this.path)
await this.root.touchDir(this.path + '/keys')
await this.root.touchDir(this.path + '/objects')
await this.root.touchDir(this.path + '/object-meta')
await this.root.touchDir(this.path + '/object-lastchange')
const nowTime = (new Date()).toISOString()
const whoami = (await this.root.keychain.whoami())[0]
//create keys
debug('create bucket keys')
await this.createReadKeys()
//import and trust in root keychain
debug('import bucket keys', this.keyFingerprints)
const [[importedReadId], [importedMetaId]] = await Promise.all([
this.root.keychain.importKey(this.keyPublics.read),
this.root.keychain.importKey(this.keyPublics.meta)
])
debug('trust keys', importedReadId, importedMetaId)
await Promise.all([
this.root.keychain.trustKey( importedReadId, '3' ),
this.root.keychain.trustKey( importedMetaId, '3' )
])
await this.setMetadata({
owner: whoami,
bucketId: {
id: this.id.toHexString(),
type: 'bucket_meta'
},
bucketKeys: {
publics: this.keyPublics,
fingerprints: this.keyFingerprints
},
created: nowTime,
bucketName: this.name,
cleartext: false,
meta: [whoami, this.keyFingerprints.meta, this.keyFingerprints.read],
readers: [whoami, this.keyFingerprints.read],
writers: [whoami]
})
await this.setIndex({
created: nowTime
})
this.readKeychain = null
this.metaKeychain = null
}
/**
* Get a file instance
* @method
* @param {string} name File path
* @returns {File}
*/
async file(name){
let file = await this.getFile(name)
if(!file){
file = new GpgFsFile({
bucket: this,
filePath: name
})
}
return file
}
async getFileFromCache(id){
return this._fileCache[id]
}
async getFile(path){
await this.getIndex()
if(!(this.index && this.index.objects)){
return null
}
path = Path.join('/', path)
let fileId = null
for(const obj of this.index.objects){
if(obj.path == path){
fileId = obj.objectId.id
break;
}
}
if(!fileId){ return null }
//! get file from cache
let file = this._fileCache[fileId]
if(!file){
file = new GpgFsFile({
bucket: this,
id: fileId,
filePath: path
})
await file.open()
this._fileCache[file.id] = file
}
return file
}
get path(){ return '/buckets/bucket-'+this.id.toHexString() }
get indexPath(){return this.path+'/index' }
get metadataPath(){return this.path+'/metadata' }
get readKeyPath(){return this.path+'/keys/read-key' }
get metaReadKeyPath(){return this.path+'/keys/meta-read-key' }
async getReciepents(){
await this.root.cacheWhoami()
let toList = [ this.root.whoami ]
if(this.metadata){
if(this.metadata.meta && this.metadata.meta.length > 0){
toList = toList.concat(this.metadata.meta)
}
if(this.metadata.readers && this.metadata.readers.length > 0){
toList = toList.concat(this.metadata.readers)
}
if(this.metadata.writers && this.metadata.writers.length > 0){
toList = toList.concat(this.metadata.writers)
}
}
return Utils.uniqueArray(toList)
}
async getMetaKeyReciepents(){
let toList = [ Hoek.reach(this, 'metadata.owner', {default: this.root.whoami}) ]
if(this.metadata){
if(this.metadata.meta && this.metadata.meta.length > 0){
toList = toList.concat(this.metadata.meta)
}
if(this.metadata.readers && this.metadata.readers.length > 0){
toList = toList.concat(this.metadata.readers)
}
if(this.metadata.writers && this.metadata.writers.length > 0){
toList = toList.concat(this.metadata.writers)
}
}
return Utils.uniqueArray(toList)
}
async getReadKeyReciepents(){
let toList = [ Hoek.reach(this, 'metadata.owner', {default: this.root.whoami}) ]
if(this.metadata){
if(this.metadata.readers && this.metadata.readers.length > 0){
toList = toList.concat(this.metadata.readers)
}
if(this.metadata.writers && this.metadata.writers.length > 0){
toList = toList.concat(this.metadata.writers)
}
}
return Utils.uniqueArray(toList)
}
async getObjectIds(){
const objectPaths = (await this.readDir('/objects'))
.map(item=>{
return item.replace('object-','')
})
debug('found ids', objectPaths)
return objectPaths
}
async initReadKeys(){
if(this.readKeychain !== null){ throw 'refuse to overwrite existing read key' }
if(this.metaKeychain !== null){ throw 'refuse to overwrite existing metadata key' }
this.readKeychain = new GpgPromised.KeyChain()
this.metaKeychain = new GpgPromised.KeyChain()
await Promise.all([
await this.readKeychain.open(),
await this.metaKeychain.open()
])
}
async createReadKeys(){
await this.initReadKeys()
await Promise.all([
this.readKeychain.generateKey({
//expire: '2y',
unattend: true,
name: `readers ${this.id}`,
email: `bucket-readers-${this.id}@gpgfs.xyz`
}),
this.metaKeychain.generateKey({
//expire: '2y',
unattend: true,
name: `metareaders ${this.id}`,
email: `bucket-metareaders-${this.id}@gpgfs.xyz`
})
])
this.keyFingerprints.read = (await this.readKeychain.listSecretKeys())[0].fpr.user_id
this.keyFingerprints.meta = (await this.metaKeychain.listSecretKeys())[0].fpr.user_id
this.keyPublics.read = await this.readKeychain.exportPublicKey(`bucket-readers-${this.id}@gpgfs.xyz`)
this.keyPublics.meta = await this.metaKeychain.exportPublicKey(`bucket-metareaders-${this.id}@gpgfs.xyz`)
await this.saveReadKeys()
}
async saveReadKeys(){
//! store read & metadata keys
debug('saving bucket keys')
const saveSecretText = async (keychain, path, to) =>{
const who = await keychain.whoami()
const key = await keychain.exportSecretKey(who[0])
await this.root.writeFile(
path,
key,
{ to, encrypt: true }
)
}
await Promise.all([
saveSecretText(
this.readKeychain,
this.readKeyPath,
await this.getReadKeyReciepents()
),
saveSecretText(
this.metaKeychain,
this.metaReadKeyPath,
await this.getMetaKeyReciepents()
)
])
}
async loadReadKeys(){
debug('loading bucket keys')
await this.initReadKeys()
let fromList = [ Hoek.reach(this, 'metadata.owner', {default: this.root.whoami}) ]
const loadSecretKey = async (keychain, path) =>{
const key = await this.root.readFile(path, true, null, null, {
trust: 'direct',
from: fromList
})
const [keyId] = await keychain.importKey(key)
await keychain.trustKey(keyId, '5')
const lookups = (this.metadata.writers||[]).map(async writer => {
debug('\twriter\t',writer)
const k = await keychain.lookupKey(writer)
debug(k)
await keychain.recvKey( k.keyid )
await keychain.signKey(writer)
})
await Promise.all(lookups)
return await keychain.whoami()
}
const whose = await Promise.all([
loadSecretKey( this.readKeychain, this.readKeyPath ),
loadSecretKey( this.metaKeychain, this.metaReadKeyPath )
])
this.keyFingerprints.read = (await this.readKeychain.listSecretKeys())[0].fpr.user_id
this.keyPublics.read = await this.readKeychain.exportPublicKey(whose[0][0])
this.keyFingerprints.meta = (await this.metaKeychain.listSecretKeys())[0].fpr.user_id
this.keyPublics.meta = await this.metaKeychain.exportPublicKey(whose[0][1])
// Assert loaded key fingerprints and publics match listed in project json
// import & trust read keys if not already in key ring
//import and trust in root keychain
debug('import bucket keys', this.keyFingerprints)
const [[importedReadId], [importedMetaId]] = await Promise.all([
this.root.keychain.importKey(this.keyPublics.read),
this.root.keychain.importKey(this.keyPublics.meta)
])
debug('trust keys', importedReadId, importedMetaId)
await Promise.all([
this.root.keychain.trustKey( importedReadId, '3' ),
this.root.keychain.trustKey( importedMetaId, '3' )
])
}
async unloadReadKeys(){
//! @todo
throw 'not implemented'
}
/**
* Bucket metadata
* @method
* @returns {gpgfs_model.bucket_meta} See [`gpgfs_model.bucket_meta`]{@link https://github.com/datapartyjs/gpgfs-model/blob/master/src/types/bucket_meta.js}
*/
async getMetadata(){
this.metadata = await this.root.readFile( this.metadataPath, true, 'bucket_meta', null, {from: Hoek.reach(this, 'metadata.owner')})
return this.metadata
}
async setMetadata(value={}){
const nowTime = (new Date()).toISOString()
let newMetadata = Object.assign({lastchanged: nowTime}, this.metadata, value)
await this.root.writeFile( this.metadataPath,
newMetadata,
{
model: 'bucket_meta',
encrypt: true,
trust: 'direct',
to: await this.getReciepents()
}
)
if(!this.metadata){
debug('creating metadata')
this.metadata = newMetadata
}
else {
debug('replacing metadata')
this.metadata = newMetadata
}
}
/**
* Bucket index
* @method
* @returns {gpgfs_model.bucket_index} See [`gpgfs_model.bucket_index`]{@link https://github.com/datapartyjs/gpgfs-model/blob/master/src/types/bucket_index.js}
*/
async getIndex(){
if(this.index !== null){ return this.index }
this.index = await this.root.readFile( this.indexPath, true, 'bucket_index', null, {from: Hoek.reach(this, 'metadata.writers')})
return this.index
}
async setIndex(value){
const nowTime = (new Date()).toISOString()
let newIndex = Object.assign({
lastchanged: nowTime,
bucketId: {
id: this.id.toHexString(),
type: 'bucket_meta'
},
objects: [],
dirs: []
}, value)
await this.root.writeFile( this.indexPath,
newIndex,
{
model: 'bucket_index',
encrypt: true,
trust: 'direct',
to: await this.getReciepents()
}
)
if(!this.index){
debug('creating index')
this.index = newIndex
}
else {
debug('replacing index')
this.index = newIndex
}
}
async mkdir(path){
debug('mkdir path -', path)
await this.getIndex()
path = Path.normalize( Path.join('/', path ))
debug('\t', 'normalize -', path)
if(this.index.dirs.indexOf(path) < 0){
this.index.dirs.push(path)
await this.setIndex({...this.index})
}
}
async rmdir(path){
debug('rmdir path -', path)
await this.getIndex()
path = Path.normalize( Path.join('/', path ))
debug('\t', 'normalize -', path)
const idx = this.index.dirs.indexOf(path)
if(idx > -1){
this.index.dirs.splice(idx, 1)
await this.setIndex({...this.index})
}
}
readDir(path){
// Given a path, determine the objects and dirs local to that path
debug('readDir ', path)
const bucketPath = Path.normalize( Path.join('/', path) )
debug('readDir ', this.name, bucketPath)
const results = {
files: {},
dirs: []
}
//! Search objects
for(const obj of this.index.objects){
//debug('check file ', obj.path, ' startsWith(', bucketPath, ')')
if(obj.path.startsWith(bucketPath)){
const filePathToks = obj.path.replace(bucketPath, '').split('/')
if(filePathToks[0] == ''){ filePathToks.shift() }
const localPath = filePathToks[0]
if(filePathToks.length > 1){
//! add intermediate directory to list
results.dirs.push( localPath )
} else {
results.files[ localPath ] = obj
}
}
}
for(let i = 0; i < Hoek.reach(this, 'index.dirs.length', {default: 0}); i++){
const dirPath = this.index.dirs[ i ]
if(dirPath.startsWith(bucketPath)){
const filePathToks = dirPath.replace(bucketPath, '').split('/')
if(filePathToks[0] == ''){ filePathToks.shift() }
const localPath = filePathToks[0]
results.dirs.push( localPath )
}
}
results.dirs = Utils.uniqueArray( results.dirs )
return results
}
/** @method
* @param {File} file
*/
async indexFile(file){
let indexes = []
await this.getIndex()
for(const idx in Hoek.reach(this, 'index.objects')){
let obj = this.index.objects[ idx ]
if(obj.path == file.filePath || obj.objectId.id == file.id){
indexes.push(idx)
}
}
debug('found ', indexes.length, ' index entries matching path =', file.filePath)
if(indexes.length > 1){ throw new Error('duplicate file path in index') }
const idx = indexes[0]
let oldIndex = Hoek.reach(this, 'index.objects.'+idx)
let newIndex = {
created: file.metadata.created,
objectId: file.metadata.objectId,
path: file.metadata.path,
size: file.lastchange.size,
lastchanged: file.lastchange.lastchanged
}
debug('newIndex', newIndex)
const parents = Utils.parentPaths( newIndex.path )
if(parents && parents.length > 0){
debug('creating parent dirs', parents)
const mkParentDirs = parents.map(this.mkdir.bind(this))
await Promise.all( mkParentDirs )
}
if(!oldIndex){
await this.setIndex({
...this.index,
objects: [].concat(this.index.objects, [newIndex])
})
}
else {
this.index.objects[ idx ] = newIndex
await this.setIndex({...this.index })
}
}
async unindexFile(file){
debug('unindex')
let indexes = []
await this.getIndex()
for(const idx in Hoek.reach(this, 'index.objects')){
let obj = this.index.objects[ idx ]
if(obj.path == file.filePath || obj.objectId.id == file.id){
indexes.push(idx)
}
}
debug('found ', indexes.length, ' index entries matching path =', file.filePath)
if(indexes.length > 1){ throw new Error('duplicate file path in index') }
const idx = indexes[0]
let oldIndex = Hoek.reach(this, 'index.objects.'+idx)
if(oldIndex){
debug('removing from index', oldIndex)
this.index.objects.splice( idx, 1 );
await this.setIndex({...this.index })
}
}
async isWriter(id=null){
//! @todo
return true
}
async isOwner(id=null){
//! @todo
return true
}
async isReader(id=null){
//! @todo
return true
}
async isWritable({path, file}={}){
const writer = await this.isWriter()
const fsWritable = this.root.storage.mode == IStorage.MODE_WRITE
return writer && fsWritable
}
/**
* Allow access for list of actors to type level of access
* @param {Object} options
* @param {('meta'|'readers'|'writers')} options.type Access type
* @param {string[]} options.list List of emails or fingerprints
*/
async addActor({type, list}){
debug('addActor ', type, list)
if(!await this.isOwner()){ throw new Error('must be owner to manipulate bucket metadata') }
if(['meta', 'readers', 'writers'].indexOf(type) < 0){
throw new Error('addActor type invalid ['+type+']')
}
const emailFprs = await this.root.keychain.resolveEmails(list)
const fullList = [].concat( list, emailFprs )
if(!this.metadata[type]){ this.metadata[type] = [] }
this.metadata[type] = Utils.uniqueArray( this.metadata[type].concat( fullList ) )
await this.setMetadata()
}
}
module.exports = Bucket