This repository has been archived by the owner on Nov 14, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
291 additions
and
329 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.