Skip to content
This repository has been archived by the owner on Nov 14, 2023. It is now read-only.

Commit

Permalink
Revamp NameZoneFile
Browse files Browse the repository at this point in the history
  • Loading branch information
vsund committed Jun 23, 2018
1 parent 3e671bd commit d6f097c
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 329 deletions.
247 changes: 130 additions & 117 deletions src/NameZoneFile.ts
Original file line number Diff line number Diff line change
@@ -1,143 +1,156 @@
import { BlockstackCoreClient } from 'blockstack-core-client.ts';
import { URL } from 'url';
import { JsonZoneFile, makeZoneFile, parseZoneFile } from 'zone-file';

import { DebugType, Logger } from './debug';
import { DidNotSatisfyJsonSchemaError, InvalidParameterError, InvalidProfileTokenError } from './error';
import { extractProfile } from './profile/jwt';
import { Person } from './profile/Person';
import { PersonJson } from './profile/schema/Person.json';
import { PersonLegacyJson } from './profile/schema/PersonLegacy.json';
import { ProfileJson } from './profile/schema/Profile.json';
import { ProfileTokenJson } from './profile/schema/ProfileToken.json';
import { config } from './config';
import { InvalidParameterError } from './error';

/**
* Creates an RFC1035-compliant zone file for a profile
*
* @param origin The zone file's origin, usually a Blockstack name
* @param tokenFileUrl The zone file's token file URL, usually pointing to a profile
* @returns An RFC1035-compliant zone file
* @throws InvalidParameterError When the given token file URL is invalid
* Class for a zone file that is used to store (routing) information about a Blockstack name
* (currently only able to store profile token URLs)
*/
export function makeProfileZoneFile(origin: string, tokenFileUrl: string): string {
// TODO: Validate origin
// TODO: Implement passing multiple URLs

try {
const url = new URL(tokenFileUrl);
} catch (error) {
throw new InvalidParameterError(
'tokenFileUrl',
'The given token file URL is no valid URL (due the `url` package)',
tokenFileUrl
);
}

const zoneFile: JsonZoneFile = {
$origin: origin,
$ttl: 3600,
uri: [
{
name: '_http._tcp',
priority: 10,
target: tokenFileUrl,
weight: 1
}
]
};
export class NameZoneFile {
// @TODO: fromJSON() and fromString() don't necessarily pick up all information of the zone file
// (everything other than $origin, $ttl and the first URI entry gets dropped)
// Idea: Introduce an attribute that holds the original data the zone file is based on

/**
* Creates a new [[NameZoneFile]] from JSON
*
* @param json The JSON to create from
* @throws [[InvalidParameterError]] when the given JSON has no `$origin` attribute
* @throws [[InvalidParameterError]] when the given JSON has no `uri` attribute
* @throws [[InvalidParameterError]] when the given JSON has an empty `uri` attribute
*/
public static fromJSON(json: JsonZoneFile): NameZoneFile {
if (json.$origin === undefined) {
throw new InvalidParameterError('json', 'Attribute `$origin` does not exist', json);
}

const zoneFileTemplate = '{$origin}\n{$ttl}\n{uri}\n';
if (json.uri === undefined) {
throw new InvalidParameterError('json', 'Attribute `uri` does not exist', json);
}

return makeZoneFile(zoneFile, zoneFileTemplate);
}
if (json.uri.length === 0) {
throw new InvalidParameterError('json', 'Attribute `uri` has no elements', json);
}

/**
* Extracts a token file URL from a given zone file
*
* @param zoneFileJson The zone file to extract the URL from
* @returns The token file URL from the zone file
* @throws InvalidParameterError When the zone file has no attribute `uri`
* @throws InvalidParameterError When the zone file's `uri` attribute is empty
*/
export function getTokenFileUrl(zoneFileJson: JsonZoneFile): string {
if (zoneFileJson.uri === undefined) {
throw new InvalidParameterError('zoneFileJson', 'Attribute "uri" does not exist', zoneFileJson);
return new NameZoneFile(json.$origin, json.uri[0].target);
}
if (zoneFileJson.uri.length === 0) {
throw new InvalidParameterError('zoneFileJson', 'Attribute "uri" has no elements', zoneFileJson);

/**
* Creates a new [[NameZoneFile]] from an RFC1035-compliant zone file
*
* @param str The string to create from (should be RFC1035-compliant)
*/
public static fromString(str: string): NameZoneFile {
return NameZoneFile.fromJSON(parseZoneFile(str));
}

let tokenFileUrl = zoneFileJson.uri[0].target;
/**
* Looks up the current zone file for a given Blockstack name (please note that this method blindly trusts the connected Blockstack Core node, so make sure it's a trusted one)
*
* @param name The Blockstack name to lookup the zone file for
* @param coreClient The [`BlockstackCoreClient`](https://github.com/ntzwrk/blockstack-core-client.ts) to use, defaults to the one set in ./config
* @returns A promise that resolves to the name's zone file on success and otherwise rejects with an [[Error]] when the profile token has no elements
*
* Please note that this function uses `BlockstackCoreClient.getZoneFile` and [[fromString]], and therefore can also reject with errors from there.
*/
public static async lookupByName(
name: string,
coreClient: BlockstackCoreClient = config.coreClient
): Promise<NameZoneFile> {
const response = await coreClient.getZoneFile(name);

// TODO: `blockstack-core-client.ts` should handle this (throw an error)
// Needs proper error responses from Blockstack Core first
if (response.zonefile === undefined) {
throw new Error('Could not find a zone file in response');
}

// TODO: This probably still works incorrectly with '://' in GET parameters
// (if it's allowed in the specification to pass these unencoded)
if (!tokenFileUrl.includes('://')) {
tokenFileUrl = `https://${tokenFileUrl}`;
return NameZoneFile.fromString(response.zonefile);
}

return tokenFileUrl;
}
/**
* The Blockstack name this zone file was created for
*/
public readonly name: string;

/**
* The profile token URL this zone file was created with
*/
public readonly profileTokenUrl: string;

/**
* Creates a new [[NameZoneFile]] from a Blockstack name and a profile token URL
*
* @param name The Blockstack name to create this zone file for (will be the zone file's `$origin`)
* @param profileTokenUrl The profile token URL to create this zone file with (will be the `target` of a `uri` element)
* @throws [[InvalidParameterError]] when the given name seems to be invalid (does not include a ".")
* @throws [[InvalidParameterError]] when the given token file URL is invalid
*/
constructor(name: string, profileTokenUrl: string) {
// TODO: This should be able to take multiple profile token URLs

if (!name.includes('.')) {
throw new InvalidParameterError(
'name',
'The given name is no valid Blockstack name (does not include a ".")',
name
);
}

/**
* Resolves a zone file to a profile JSON object
*
* @param zoneFile The zone file to resolve
* @param publicKeyOrAddress The public key or address who owns it
* @returns A promise containing `ProfileJson`.
* Resolves to a profile JSON object on success.
* Rejects with an `DidNotSatisfyJsonSchemaError`:
* 1) When the retrieved JSON seems to be a [[Person]] but does not satisfy the corresponding JSON schema.
* 2) When the retrieved JSON seems to be a [[PersonLegacy]] but does not satisfy the corresponding JSON schema.
* Rejects with an `InvalidProfileTokenError`:
* 1) When the profile token has no elements,
* 2) When the first element of the profile token has no "token" attribute.
*
* Please note that this function uses `fetch` and therefore can also reject with errors from there.
*/
export async function resolveZoneFileToProfile(zoneFile: string, publicKeyOrAddress: string): Promise<ProfileJson> {
const zoneFileJson: JsonZoneFile = parseZoneFile(zoneFile);
/*
* This small check is for zone files that have a profile token url without scheme.
* There was a small time period where `blockstack.js` created these zone files, so
* this check might be necessary until they are all revised.
* It probably doesn't work with checking for '://', since it's maybe allowed to use
* it in GET parameters(?).
*/
if (!profileTokenUrl.includes('://')) {
profileTokenUrl = `https://${profileTokenUrl}`;
}

if (zoneFileJson.$origin === undefined) {
let legacyProfileJson: PersonLegacyJson;
try {
legacyProfileJson = JSON.parse(zoneFile) as PersonLegacyJson;
const url = new URL(profileTokenUrl);
if (!url.hostname.includes('.')) {
throw new Error();
}
} catch (error) {
throw new DidNotSatisfyJsonSchemaError('PersonLegacy.json', zoneFile);
throw new InvalidParameterError(
'profileTokenUrl',
'The given profile token URL is no valid URL (says the `url` package)',
profileTokenUrl
);
}
return Person.fromLegacyFormat(legacyProfileJson).toJSON();
}

const tokenFileUrl = getTokenFileUrl(zoneFileJson);
const response = await (await fetch(tokenFileUrl)).text();

let profileTokenJson: ProfileTokenJson;
try {
profileTokenJson = JSON.parse(response) as ProfileTokenJson;
} catch (error) {
throw new InvalidProfileTokenError(response);
this.name = name;
this.profileTokenUrl = profileTokenUrl;
}

if (profileTokenJson.length === 0) {
throw new InvalidProfileTokenError(profileTokenJson, 'The profile token has no elements');
/**
* Returns a JSON object representing the zone file
*/
public toJSON(): JsonZoneFile {
return {
$origin: this.name,
$ttl: 3600,
uri: [
{
name: '_http._tcp',
priority: 10,
target: this.profileTokenUrl,
weight: 1
}
]
};
}
if (profileTokenJson[0].token === undefined) {
throw new InvalidProfileTokenError(
profileTokenJson,
'The first element of the profile token has no "token" attrbute'
);
}

return extractProfile(profileTokenJson[0].token as string, publicKeyOrAddress);
}

/**
* Resolves a zone file to a person JSON object
*
* @param zoneFile The zone file to resolve
* @param publicKeyOrAddress The public key or address who owns it
* @returns A promise containing `PersonJson`
*
* Please note that this function uses [[resolveZoneFileToProfile]] and therefore rejects with the same errors.
*/
export async function resolveZoneFileToPerson(zoneFile: string, publicKeyOrAddress: string): Promise<PersonJson> {
return (await resolveZoneFileToProfile(zoneFile, publicKeyOrAddress)) as PersonJson;
/**
* Returns a RFC1035-compliant zone file
*/
public toString(): string {
const zoneFileTemplate = '{$origin}\n{$ttl}\n{uri}\n';
return makeZoneFile(this.toJSON(), zoneFileTemplate);
}
}
62 changes: 10 additions & 52 deletions tests/integrationTests/NameZoneFile.spec.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,32 @@
import * as chai from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import * as fs from 'fs';
import { TokenSigner, JWT } from 'jsontokens';
import { JsonZoneFile, makeZoneFile, parseZoneFile } from 'zone-file';

import { correct, incorrect } from '../fun';

import { DidNotSatisfyJsonSchemaError, InvalidParameterError } from '../../src/error';
import { signProfileToken } from '../../src/profile/jwt';
import { PersonJson } from '../../src/profile/schema/Person.json';
import { ProfileJson } from '../../src/profile/schema/Profile.json';
import {
getTokenFileUrl,
makeProfileZoneFile,
resolveZoneFileToPerson,
resolveZoneFileToProfile
} from '../../src/ProfileZoneFile';
import { NameZoneFile } from '../../src/NameZoneFile';

chai.use(chaiAsPromised);

describe('zoneFile.ts', () => {
describe('resolveZoneFileToPerson', () => {
it(`makes ${correct()} persons`, async function() {
describe('NameZoneFile', () => {
describe('lookupByName', () => {
it(`looks up ${correct()} names`, async function() {
this.slow(3000);
this.timeout(6000);
this.retries(3);

const zoneFile =
'$ORIGIN vsund.id\n$TTL 3600\n_http._tcp URI 5 1 "https://gaia.blockstack.org/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json"\n';
const address = '15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f';
const zoneFile = await NameZoneFile.lookupByName('vsund.id');

const personJson: PersonJson = await resolveZoneFileToPerson(zoneFile, address);

chai.expect(personJson['@context']).to.equal('http://schema.org');
chai.expect(personJson['@type']).to.equal('Person');
chai.expect(personJson.name).to.equal('vsund');
});

it(`fails on ${incorrect()} inputs`, function() {
this.slow(3000);
this.timeout(6000);
this.retries(3);

chai.expect(resolveZoneFileToPerson('', '')).to.eventually.be.rejectedWith(DidNotSatisfyJsonSchemaError);
});
});

describe('resolveZoneFileToProfile', () => {
it(`makes ${correct()} profiles`, async function() {
this.slow(3000);
this.timeout(6000);
this.retries(3);

const zoneFile =
'$ORIGIN vsund.id\n$TTL 3600\n_http._tcp URI 5 1 "https://gaia.blockstack.org/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json"\n';
const address = '15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f';

const profileJson: ProfileJson = await resolveZoneFileToProfile(zoneFile, address);

chai.expect(profileJson['@context']).to.equal('http://schema.org');
chai.expect(profileJson['@type']).to.equal('Person');
chai.expect(profileJson.name).to.equal('vsund');
chai.expect(zoneFile.name).to.equal('vsund.id');
chai.expect(zoneFile.profileTokenUrl).to.contain('/hub/15DrW8LfoZecCzQZxKuKQkzMQrUjC1SC2f/0/profile.json');
});

it(`fails on ${incorrect()} inputs`, function() {
it(`fails on ${incorrect()} inputs`, async function() {
this.slow(3000);
this.timeout(6000);
this.retries(3);

chai.expect(resolveZoneFileToProfile('', '')).to.eventually.be.rejectedWith(DidNotSatisfyJsonSchemaError);
// TODO: Revisit this when Blockstack Core responds with proper error messages (`blockstack-core-client.ts` currently returns a plain error for not finding a zone file)
chai.expect(NameZoneFile.lookupByName('please-dont-buy-this-name.id')).to.eventually.be.rejectedWith(Error);
});
});
});
Loading

0 comments on commit d6f097c

Please sign in to comment.