api/emulations/web_id.js

// Copyright 2018 MaidSafe.net limited.
//
// This SAFE Network Software is licensed to you under
// the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT> or
// the Modified BSD license <LICENSE-BSD or https://opensource.org/licenses/BSD-3-Clause>,
// at your option.
//
// This file may not be copied, modified, or distributed except according to those terms.
//
// Please review the Licences for the specific language governing permissions and limitations
// relating to use of the SAFE Network Software.

const consts = require('../../consts');
const { parse: parseUrl } = require('url');
const { EXPOSE_AS_EXPERIMENTAL_API } = require('../../helpers');

const POSTS_MD_TYPE_TAG = 30303;

// Helper for creating a WebID profile document RDF resource
const createWebIdProfileDoc = async (rdf, vocabs, profile, inboxXorUrl) => {
  // TODO: Webid URI validation: https://github.com/joshuef/webIdManager/issues/2
  const id = rdf.sym(profile.uri);
  rdf.setId(profile.uri);
  const hasMeAlready = profile.uri.includes('#me');
  const webIdWithHashTag = hasMeAlready ? rdf.sym(profile.uri) : rdf.sym(`${profile.uri}#me`);

  // TODO: we are overwritting the entire RDF when updating, we could make it
  // more efficient and only add the new triples when updating.
  rdf.add(id, vocabs.RDFS('type'), vocabs.FOAF('PersonalProfileDocument'));

  rdf.removeMany(id, vocabs.DCTERMS('title'), null);
  rdf.add(id, vocabs.DCTERMS('title'), rdf.literal(`${profile.name}'s profile document`));
  rdf.add(id, vocabs.FOAF('maker'), webIdWithHashTag);
  rdf.add(id, vocabs.FOAF('primaryTopic'), webIdWithHashTag);

  rdf.add(webIdWithHashTag, vocabs.RDFS('type'), vocabs.FOAF('Person'));
  rdf.removeMany(webIdWithHashTag, vocabs.FOAF('name'), null);
  rdf.add(webIdWithHashTag, vocabs.FOAF('name'), rdf.literal(profile.name));
  rdf.removeMany(webIdWithHashTag, vocabs.FOAF('nick'), null);
  rdf.add(webIdWithHashTag, vocabs.FOAF('nick'), rdf.literal(profile.nick));

  rdf.removeMany(webIdWithHashTag, vocabs.FOAF('image'), null);
  if (profile.image) { rdf.add(webIdWithHashTag, vocabs.FOAF('image'), rdf.sym(profile.image)); }

  rdf.removeMany(webIdWithHashTag, vocabs.FOAF('website'), null);
  if (profile.website) { rdf.add(webIdWithHashTag, vocabs.FOAF('website'), rdf.sym(profile.website)); }

  const ACTIVITYSTREAMS = rdf.namespace('https://www.w3.org/ns/activitystreams/');
  rdf.removeMany(webIdWithHashTag, ACTIVITYSTREAMS('inbox'), null);
  const inboxLink = inboxXorUrl || profile.inbox;
  if (inboxLink) { rdf.add(webIdWithHashTag, ACTIVITYSTREAMS('inbox'), rdf.sym(inboxLink)); }

  const location = await rdf.commit();
  return location;
};


/**
* Experimental WebID Emulation on top of a MutableData internally using RDF emulation
*
* Instantiate the WebID emulation layer wrapping a MutableData instance,
* while making use of the RDF emulation to manipulate the MD entries
*
* @param {MutableData} mData the MutableData to wrap around
*/
class WebID {
  constructor(mData) {
    this.mData = mData;
  }

  /**
   * Initialises WebID interface by emulating underlying {@link MutableData}
   * as RDF and sets common namespace prefixes on instance
   */
  init() {
    if (this.rdf) return; // it was already initialised

    this.rdf = this.mData.emulateAs('rdf');
    this.vocabs = {
      LDP: this.rdf.namespace('http://www.w3.org/ns/ldp#'),
      RDF: this.rdf.namespace('http://www.w3.org/2000/01/rdf-schema#'),
      RDFS: this.rdf.namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#'),
      FOAF: this.rdf.namespace('http://xmlns.com/foaf/0.1/'),
      OWL: this.rdf.namespace('http://www.w3.org/2002/07/owl#'),
      DCTERMS: this.rdf.namespace('http://purl.org/dc/terms/'),
      SAFETERMS: this.rdf.namespace('http://safenetwork.org/safevocab/')
    };
  }

  /**
  * Fetches committed WebId data from underlying MutableData and loads in graph store
  * @returns {Promise}
   * @example
   * // Assumes {@link MutableData} interface has been obtained
   * const asyncFn = async () => {
   *   try {
   *       const webid = await mData.emulateAs('WebId');
   *       await webid.fetchContent();
   *   } catch(err) {
   *       throw err;
   *   }
   * };
  */
  async fetchContent() {
    await this.init();
    await this.rdf.nowOrWhenFetched();
  }

  /**
   * Creates WebId as RDF data and commits to underlying MutableData
   * @param {Object} profile
   * @param {String} nick profile.nick
   * @returns {Promise}
   * @example
   * // Assumes {@link MutableData} interface has been obtained
   * const profile = {
   *     nick: "safedev",
   *     name: "SAFENetwork Developer",
   *     uri: "safe://id.safedev"
   * };
   * const asyncFn = async () => {
   *   try {
   *       const webid = await mData.emulateAs('WebId');
   *       await webid.create(profile, profile.nick);
   *   } catch(err) {
   *       throw err;
   *   }
   * };
   */
  async create(profile, displayName) {
    await this.init();
    const app = this.mData.app;

    // TODO: support for URIs containing a path, e.g. safe://mywebid.gabriel/card
    // We may need to create an NFS container to reference it?
    const parsedUrl = parseUrl(profile.uri);
    const hostParts = parsedUrl.hostname.split('.');
    const publicName = hostParts.pop(); // last one is 'publicName'
    const subName = hostParts.join('.'); // all others are 'subNames'

    // Create inbox container for posts
    const postsMd = await app.mutableData.newRandomPublic(POSTS_MD_TYPE_TAG);
    const perms = await app.mutableData.newPermissions();
    const appKey = await app.crypto.getAppPubSignKey();
    const pmSet = ['Insert', 'Update', 'Delete', 'ManagePermissions'];
    await perms.insertPermissionSet(appKey, pmSet);
    await perms.insertPermissionSet(consts.pubConsts.USER_ANYONE, ['Insert']);
    await postsMd.put(perms);
    const { xorUrl } = await postsMd.getNameAndTag();

    const webIdLocation =
      await createWebIdProfileDoc(this.rdf, this.vocabs, profile, xorUrl);

    await app.web.addWebIdToDirectory(profile.uri, displayName || profile.nick);

    const subdomainsRdfLocation =
      await app.web.linkServiceToSubname(subName, publicName, webIdLocation);

    await app.web.addPublicNameToDirectory(publicName, subdomainsRdfLocation);
  }

  /**
   * Updates WebId as RDF data and commits to underlying MutableData
   * @param {Object} profile
   * @returns {Promise}
   * @example
   * // Assumes {@link MutableData} interface has been obtained
   * const profile = {
   *     nick: "safedev",
   *     name: "SAFENetwork Developer",
   *     uri: "safe://id.safedev"
   * };
   * const asyncFn = async () => {
   *   try {
   *       const webid = await mData.emulateAs('WebId');
   *       await webid.create(profile, profile.nick);
   *       let updatedProfile = Object.assign({}, profile, { name: "Alexander Fleming" });
   *       await webid.update(profile);
   *   } catch(err) {
   *       throw err;
   *   }
   * };
   */
  async update(profile) {
    await this.init();
    let inboxXorUrl;
    if (!profile.inbox) {
      // For backward compatibility let's check if the link to posts
      // is in the old format which is a named graph. If so, let's
      // convert it to be an 'inbox' XOR-URL link
      try {
        const postsGraph = `${profile.uri}/posts`;
        await this.rdf.nowOrWhenFetched(postsGraph);
        const postsSym = this.rdf.sym(postsGraph);
        const typeTagMatch = this.rdf.statementsMatching(postsSym, this.rdf.vocabs.SAFETERMS('typeTag'), null);
        const typeTag = typeTagMatch[0] && parseInt(typeTagMatch[0].object.value, 10);
        const xorNameMatch = this.rdf.statementsMatching(postsSym, this.rdf.vocabs.SAFETERMS('xorName'), null);
        const xorName = xorNameMatch[0] && xorNameMatch[0].object.value.split(',');
        // let's now get rid of the old format graph for posts
        await this.rdf.removeMany(this.rdf.sym(postsGraph), null, null);
        if (xorName && typeTag) {
          const postsMd = await this.mData.app.mutableData.newPublic(xorName, typeTag);
          const { xorUrl } = await postsMd.getNameAndTag();
          inboxXorUrl = xorUrl;
        }
      } catch (err) {
        // if the old link to posts is not found or invalid we just ignore it
      }
    }

    await createWebIdProfileDoc(this.rdf, this.vocabs, profile, inboxXorUrl);
  }

  /**
   * Serialises WebId RDF data
   * @param {Object} profile
   * @returns {Promise<String>} RDF document according to mime type
   * @example
   * // Assumes {@link MutableData} interface has been obtained
   * const mimeType = "text/turtle";
   * const profile = {
   *     nick: "safedev",
   *     name: "SAFENetwork Developer",
   *     uri: "safe://id.safedev"
   * };
   * const asyncFn = async () => {
   *   try {
   *       const webid = await mData.emulateAs('WebId');
   *       await webid.create(profile, profile.nick);
   *       const serialised = await webid.serialise(mimeType);
   *   } catch(err) {
   *       throw err;
   *   }
   * };
   */
  async serialise(mimeType) {
    await this.init();
    const serialised = await this.rdf.serialise(mimeType);
    return serialised;
  }
}

class WebIdEmulationFactory {
  /**
  * @private
  * Instantiate the WebID emulation layer wrapping a MutableData instance,
  * hiding the whole WebID emulation class behind the experimental API flag
  *
  * @param {MutableData} mData the MutableData to wrap around
  */
  constructor(mData) {
    /* eslint-disable camelcase, prefer-arrow-callback */
    return EXPOSE_AS_EXPERIMENTAL_API.call(mData.app, function WebID_Emulation() {
      return new WebID(mData);
    });
  }
}

module.exports = WebIdEmulationFactory;