api/web.js

const errConst = require('../error_const');
const consts = require('../consts');
const makeError = require('../native/_error.js');
const { parse: parseUrl } = require('url');

const PUBLIC_NAMES_CONTAINER = '_publicNames';
const PUBLIC_CONTAINER = '_public';

const cleanRdfValue = (value) => {
  let cleanValue = value;

  if (Array.isArray(value) && value.length === 1) {
    cleanValue = value[0];
  }

  if (cleanValue['@value']) {
    cleanValue = cleanValue['@value'];
  }

  return cleanValue;
};

// make this usefullll..... so, purposefully pull out useful, but ignore RDF....
const flattenWebId = (theData, rootTerm) => {
  const newObject = {
    '@id': rootTerm
  };

  theData.forEach((graph) => {
    const graphId = graph['@id'];
    if (graphId === rootTerm) {
      newObject['@type'] = cleanRdfValue(graph['@type']);
      return;
    }

    // split.pop maybe unneeded...
    const strippedGraphID = graphId.replace(rootTerm, '').split('/').pop();

    newObject[strippedGraphID] = {};

    Object.keys(graph).forEach((key) => {
      const cleanKey = key.split('/').pop();
      const cleanValue = cleanRdfValue(graph[key]);
      newObject[strippedGraphID][cleanKey] = cleanValue;
    });
  });
  return newObject;
};

/**
* Manage Web RDF Data
*/
class WebInterface {
  /**
  * @private
  * @param {SAFEApp} app
  */
  constructor(app) {
    this.app = app;
  }

  /**
   * Retrieve vocab for RDF/SAFE Implementation of DNS (publicNames/subDomains/services)
   * @param  {RDF} rdf RDF object to utilise for namespace func
   * @return {Object}  object containing keys with RDF namespace values.
   * @example
   * const rdf = mData.emulateAs('RDF');
   * const vocabs = app.web.getVocabs(rdf);
   */
  getVocabs(rdf) { // eslint-disable-line class-methods-use-this
    return {
      LDP: rdf.namespace('http://www.w3.org/ns/ldp#'),
      RDF: rdf.namespace('http://www.w3.org/2000/01/rdf-schema#'),
      RDFS: rdf.namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#'),
      FOAF: rdf.namespace('http://xmlns.com/foaf/0.1/'),
      OWL: rdf.namespace('http://www.w3.org/2002/07/owl#'),
      DCTERMS: rdf.namespace('http://purl.org/dc/terms/'),
      SAFETERMS: rdf.namespace('http://safenetwork.org/safevocab/')
    };
  }

  /**
   * Add entry to _publicNames container, linking to a specific RDF/MD
   * object for subName discovery/service resolution
   * @param  {String} publicName public name string, valid URL
   * @param  {NameAndTag} subNamesRdfLocation MutableData name/typeTag object
   * @throws {INVALID_RDF_LOCATION|INVALID_URL}
   * @return {Promise} resolves upon commit of data to _publicNames
   * @example
   * const asyncFn = async () => {
   *     try {
   *         const networkResource = await app.fetch('safe://hyfktce85xaif4kdamgnd16uho3w5z7peeb5zeho836uoi48tgkgbc55bco:30303');
   *         const nameAndTag = await networkResource.content.getNameAndTag();
   *         const publicName = 'safedev';
   *         const subNamesRdfLocation = nameAndTag;
   *         await app.web.addPublicNameToDirectory(publicName, subNamesRdfLocation);
   *     } catch(err) {
   *         throw err;
   *     }
   * };
   */
  async addPublicNameToDirectory(publicName, subNamesRdfLocation) {
    if (typeof subNamesRdfLocation !== 'object' ||
        !subNamesRdfLocation.name || !subNamesRdfLocation.typeTag) {
      throw makeError(errConst.INVALID_RDF_LOCATION.code, errConst.INVALID_RDF_LOCATION.msg);
    }

    if (typeof publicName !== 'string') throw makeError(errConst.INVALID_URL.code, errConst.INVALID_URL.msg);

    const app = this.app;

    const publicNamesContainer = await app.auth.getContainer(PUBLIC_NAMES_CONTAINER);
    const publicNamesRdf = publicNamesContainer.emulateAs('rdf');

    // Here we do basic container setup for RDF entries.
    // Doesn't matter if already existing, will just write same entries.
    const graphName = `safe://${PUBLIC_NAMES_CONTAINER}`; // TODO: this graph name is not a valid URI on the SAFE network
    const id = publicNamesRdf.sym(graphName);
    const graphNameWithHashTag = publicNamesRdf.sym(`${graphName}#it`);
    const newResourceName = publicNamesRdf.sym(`${graphName}#${publicName}`);

    publicNamesRdf.setId(graphName);
    try {
      const toDecrypt = true;
      await publicNamesRdf.nowOrWhenFetched(null, toDecrypt);
    } catch (e) {
      // ignore no ID set in case nothing has been added yet
      if (e.code !== errConst.MISSING_RDF_ID.code) {
        throw new Error({ code: e.code, message: e.message });
      }
    }

    const vocabs = this.getVocabs(publicNamesRdf);
    publicNamesRdf.add(id, vocabs.RDFS('type'), vocabs.LDP('DirectContainer'));
    publicNamesRdf.add(id, vocabs.LDP('membershipResource'), graphNameWithHashTag);
    publicNamesRdf.add(id, vocabs.LDP('hasMemberRelation'), vocabs.SAFETERMS('hasPublicName'));
    publicNamesRdf.add(id, vocabs.DCTERMS('title'), publicNamesRdf.literal(`${PUBLIC_NAMES_CONTAINER} default container`));
    publicNamesRdf.add(id, vocabs.DCTERMS('description'), publicNamesRdf.literal('Container to keep track of public names owned by the account'));
    publicNamesRdf.add(id, vocabs.LDP('contains'), newResourceName);

    publicNamesRdf.add(graphNameWithHashTag, vocabs.RDFS('type'), vocabs.SAFETERMS('PublicNames'));
    publicNamesRdf.add(graphNameWithHashTag, vocabs.DCTERMS('title'), publicNamesRdf.literal('Public names owned by an account'));
    publicNamesRdf.add(graphNameWithHashTag, vocabs.SAFETERMS('hasPublicName'), newResourceName);

    // and adding the actual name.
    publicNamesRdf.add(newResourceName, vocabs.RDFS('type'), vocabs.SAFETERMS('PublicName'));
    publicNamesRdf.add(newResourceName, vocabs.DCTERMS('title'), publicNamesRdf.literal(`'${publicName}' public name`));
    publicNamesRdf.add(newResourceName, vocabs.SAFETERMS('xorName'), publicNamesRdf.literal(subNamesRdfLocation.name.toString()));
    publicNamesRdf.add(newResourceName, vocabs.SAFETERMS('typeTag'), publicNamesRdf.literal(subNamesRdfLocation.typeTag.toString()));

    const encryptThis = true;
    await publicNamesRdf.commit(encryptThis);
  }

  /**
   * Links a service/resource to a publicName, with a provided subName
   * @param {String} subName
   * @param {String} publicName
   * @param {NameAndTag} serviceLocation
   *
   * @throws {INVALID_SUBNAME|INVALID_PUBNAME|INVALID_RDF_LOCATION|ERR_DATA_GIVEN_ALREADY_EXISTS}
   * @return {Promise<Object>} Resolves to an object with xorname and
   * typeTag of the publicName RDF location
   */
  async linkServiceToSubname(subName, publicName, serviceLocation) {
    if (typeof subName !== 'string') throw makeError(errConst.INVALID_SUBNAME.code, errConst.INVALID_SUBNAME.msg);
    if (typeof publicName !== 'string') throw makeError(errConst.INVALID_PUBNAME.code, errConst.INVALID_PUBNAME.msg);

    if (typeof serviceLocation !== 'object' ||
        !serviceLocation.name || !serviceLocation.typeTag) {
      throw makeError(errConst.INVALID_RDF_LOCATION.code, errConst.INVALID_RDF_LOCATION.msg);
    }

    const app = this.app;
    const subNamesContLocation = await app.crypto.sha3Hash(publicName);
    const subNamesContainer =
      await app.mutableData.newPublic(subNamesContLocation, consts.TAG_TYPE_DNS);

    let makeContainerStructure = false;
    const subNamesRdf = subNamesContainer.emulateAs('rdf');

    try {
      await subNamesContainer.quickSetup();
    } catch (err) {
      // If the subNames container already exists we are then ok
      if (err.code !== errConst.ERR_DATA_GIVEN_ALREADY_EXISTS.code) {
        throw err;
      }
      // We need to only add a service rather than populating it
      // with the whole LDP Container structure.
      // TODO: This first version we assume that it contains the
      // LDP Container definitions if the container exists, but
      // this is not good enough in the future
      makeContainerStructure = true;
      await subNamesRdf.nowOrWhenFetched();
    }

    const vocabs = this.getVocabs(subNamesRdf);
    const fullUri = `safe://${publicName}`;

    const id = subNamesRdf.sym(fullUri);
    subNamesRdf.setId(fullUri);
    const uriWithHashTag = subNamesRdf.sym(`${fullUri}#it`);
    const serviceResource = subNamesRdf.sym(`safe://${subName}.${publicName}`);

    if (makeContainerStructure) {
      // Add the triples which define the LDP Container first.
      subNamesRdf.add(id, vocabs.RDFS('type'), vocabs.LDP('DirectContainer'));
      subNamesRdf.add(id, vocabs.LDP('membershipResource'), uriWithHashTag);
      subNamesRdf.add(id, vocabs.LDP('hasMemberRelation'), vocabs.SAFETERMS('hasService'));
      subNamesRdf.add(id, vocabs.DCTERMS('title'), subNamesRdf.literal(`Services Container for subName: '${publicName}'`));
      subNamesRdf.add(id, vocabs.DCTERMS('description'), subNamesRdf.literal('List of public services exposed by a particular subName'));

      subNamesRdf.add(uriWithHashTag, vocabs.RDFS('type'), vocabs.SAFETERMS('Services'));
      subNamesRdf.add(uriWithHashTag, vocabs.DCTERMS('title'), subNamesRdf.literal(`Services available for subName: '${publicName}'`));
    }

    // Now add the triples specific for the new service
    subNamesRdf.add(id, vocabs.LDP('contains'), serviceResource);

    subNamesRdf.add(uriWithHashTag, vocabs.SAFETERMS('hasService'), serviceResource);

    subNamesRdf.add(serviceResource, vocabs.RDFS('type'), vocabs.SAFETERMS('Service'));
    subNamesRdf.add(serviceResource, vocabs.DCTERMS('title'), subNamesRdf.literal(`'${subName}' service`));
    subNamesRdf.add(serviceResource, vocabs.SAFETERMS('xorName'), subNamesRdf.literal(serviceLocation.name.toString()));
    subNamesRdf.add(serviceResource, vocabs.SAFETERMS('typeTag'), subNamesRdf.literal(serviceLocation.typeTag.toString()));

    const location = await subNamesRdf.commit();

    return location;
  }

  /**
   * Return an Array of publicNames
   * @return {Promise<Array>} Returns array of PublicNames
   */
  async getPublicNames() {
    const pubNamesCntr = await this.app.auth.getContainer(PUBLIC_NAMES_CONTAINER);

    const entries = await pubNamesCntr.getEntries();
    const entriesList = await entries.listEntries();

    const publicNamesArray = [];
    await Promise.all(entriesList.map(async (entry) => {
      const key = await pubNamesCntr.decrypt(entry.key);
      const keyString = await key.toString();

      if (!keyString.startsWith(`safe://${PUBLIC_NAMES_CONTAINER}`)) return;

      publicNamesArray.push(keyString);
    }));

    return publicNamesArray;
  }

  /**
   * Adds a WebID to the '_public' container, using
   * @param  {String}  webIdUri name/typetag object from SAFE MD.
   * @param  {String}  displayName   optional displayName which will be used when listing webIds.
   * @throws {INVALID_URL|MISSING_RDF_ID}
   */
  async addWebIdToDirectory(webIdUri, displayName) {
    if (typeof webIdUri !== 'string') {
      throw makeError(errConst.INVALID_URL.code, errConst.INVALID_URL.msg);
    }

    const directory = await this.app.auth.getContainer(PUBLIC_CONTAINER);
    const directoryRDF = directory.emulateAs('rdf');
    const vocabs = this.getVocabs(directoryRDF);
    const graphName = `safe://${PUBLIC_CONTAINER}/webId`; // TODO: this graph name is not a valid URI on the SAFE network
    const id = directoryRDF.sym(graphName);
    directoryRDF.setId(graphName);

    try {
      await directoryRDF.nowOrWhenFetched();
    } catch (e) {
      // ignore no ID set in case nothing has been added yet
      if (e.code !== errConst.MISSING_RDF_ID.code) {
        throw new Error({ code: e.code, message: e.message });
      }
    }

    directoryRDF.add(id, vocabs.DCTERMS('title'), directoryRDF.literal(`${PUBLIC_CONTAINER} default container`));
    directoryRDF.add(id, vocabs.DCTERMS('description'), directoryRDF.literal('Container to keep track of public data for the account'));

    const hostname = parseUrl(webIdUri).hostname;
    const newResourceName = directoryRDF.sym(`${graphName}/${hostname}`);

    directoryRDF.add(newResourceName, vocabs.DCTERMS('identifier'), vocabs.FOAF(`safe://${PUBLIC_CONTAINER}/webId/${hostname}`));
    directoryRDF.add(newResourceName, vocabs.RDFS('type'), vocabs.FOAF('PersonalProfileDocument'));
    directoryRDF.add(newResourceName, vocabs.DCTERMS('title'), directoryRDF.literal(`${displayName || ''}`));
    directoryRDF.add(newResourceName, vocabs.SAFETERMS('uri'), directoryRDF.literal(webIdUri));
    directoryRDF.add(newResourceName, vocabs.SAFETERMS('typeTag'), directoryRDF.literal(webIdUri));

    await directoryRDF.commit();
  }

  /**
   * Retrieve all webIds. Currently as array of JSON objects...
   *
   * @return {Promise<Array>} Resolves to array of webIds objects.
   */
  async getWebIds() {
    const directory = await this.app.auth.getContainer(PUBLIC_CONTAINER);
    const entries = await directory.getEntries();
    const entriesList = await entries.listEntries();

    const webIds = await entriesList.filter((entry) => {
      const key = entry.key.toString();
      const value = entry.value.buf.toString();

      return key.includes('/webId/') && value.length;
    }).map(async (entry) => {
      const value = entry.value.buf.toString();
      // parsing to get something to work with as RDF is not that helpful...
      // perhaps sparql is needed to get it all...
      // perhaps this is what we should be doing.. parsing out to being helpful?
      // probably can be simplified via jsonLD compact encoding etc.
      const json = JSON.parse(value);
      const uri = json['http://safenetwork.org/safevocab/uri'][0]['@value'];

      try {
        const response = await this.app.webFetch(uri, { accept: 'application/ld+json' });
        const data = JSON.parse(response.body);
        const initialId = data[0]['@id'];
        const flatVersion = flattenWebId(data, initialId);
        return flatVersion;
      } catch (e) {
        // WebID not found at specified URI
        return null;
      }
    });

    const list = await Promise.all(webIds);
    return list.filter((entry) => (entry !== null));
  }
}

module.exports = WebInterface;