const Utils = require("./Utils");
const UrlJoin = require("url-join");
const { FrameClient } = require("./FrameClient");
const {LogMessage} = require("./LogMessage");
class UserProfileClient {
Log(message, error=false) {
LogMessage(this, message, error);
}
/**
* Methods used to access and modify information about the user
*
* <h4 id="PromptsAndAccessLevels">A note about access level and prompts: </h4>
*
* Note: This section only applies to applications working within Eluvio Core
*
* Users can choose whether or not their info is shared to applications. A user
* may choose to allow open access to their profile, no access to their profile, or
* they may choose to be prompted to give access when an application requests it. The
* user's access level can be determined using the <a href="#AccessLevel">AccessLevel</a>
* method.
*
* By default, users will be prompted to give access. For methods that access the user's private information,
* Eluvio Core will intercept the request and prompt the user for permission before proceeding. In
* these cases, the normal FrameClient timeout period will be ignored, and the response will come
* only after the user accepts or rejects the request.
*
* Access and modification of user metadata is namespaced to the requesting application when using the
* FrameClient. Public user metadata can be accessed using the PublicUserMetadata method.
*
* If the user refuses to give permission, an error will be thrown. Otherwise, the request will proceed
* as normal.
*
* <h4>Usage</h4>
*
* Access the UserProfileClient from ElvClient or FrameClient via client.userProfileClient
*
* @example
let client = ElvClient.FromConfiguration({configuration: ClientConfiguration});
let wallet = client.GenerateWallet();
let signer = wallet.AddAccount({
accountName: "Alice",
privateKey: "0x0000000000000000000000000000000000000000000000000000000000000000"
});
client.SetSigner({signer});
await client.userProfileClient.UserMetadata()
let frameClient = new FrameClient();
await client.userProfileClient.UserMetadata()
*
*/
constructor({client, debug}) {
this.client = client;
this.debug = debug;
this.userWalletAddresses = {};
this.walletAddress = undefined;
this.walletAddressRetrieved = false;
}
async CreateWallet() {
if(this.creatingWallet) {
while(this.creatingWallet) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
this.creatingWallet = true;
try {
// Check if wallet contract exists
if(!this.walletAddress || Utils.EqualAddress(this.walletAddress, Utils.nullAddress)) {
this.Log(`Creating user wallet for user ${this.client.signer.address}`);
// Don't attempt to create a user wallet if user has no funds
const balance = await this.client.GetBalance({address: this.client.signer.address});
if(balance < 0.05) {
return undefined;
}
const walletCreationEvent = await this.client.CallContractMethodAndWait({
contractAddress: Utils.HashToAddress(this.client.contentSpaceId),
methodName: "createAccessWallet",
methodArgs: []
});
const abi = await this.client.ContractAbi({contractAddress: this.client.contentSpaceAddress});
this.walletAddress = this.client.ExtractValueFromEvent({
abi,
event: walletCreationEvent,
eventName: "CreateAccessWallet",
eventValue: "wallet"
});
this.userWalletAddresses[Utils.FormatAddress(this.client.signer.address)] = this.walletAddress;
}
// Check if wallet object is created
const libraryId = this.client.contentSpaceLibraryId;
const objectId = Utils.AddressToObjectId(this.walletAddress);
try {
await this.client.ContentObject({libraryId, objectId});
} catch(error) {
if(error.status === 404) {
this.Log(`Creating wallet object for user ${this.client.signer.address}`);
const createResponse = await this.client.CreateContentObject({libraryId, objectId});
await this.client.FinalizeContentObject({
libraryId,
objectId,
writeToken: createResponse.write_token,
commitMessage: "Create user wallet object"
});
}
}
} catch(error) {
// eslint-disable-next-line no-console
console.error("Failed to create wallet contract:");
// eslint-disable-next-line no-console
console.error(error);
} finally {
this.creatingWallet = false;
}
}
/**
* Get the contract address of the current user's BaseAccessWallet contract
*
* @return {Promise<string>} - The contract address of the current user's wallet contract
*/
async WalletAddress(autoCreate=true) {
if(this.walletAddress || this.walletAddressRetrieved) { return this.walletAddress; }
if(!this.walletAddressPromise) {
this.walletAddressPromise = this.client.CallContractMethod({
contractAddress: Utils.HashToAddress(this.client.contentSpaceId),
methodName: "userWallets",
methodArgs: [this.client.signer.address]
});
}
const walletAddress = await this.walletAddressPromise;
if(!Utils.EqualAddress(walletAddress, Utils.nullAddress)) {
this.walletAddress = walletAddress;
}
if(!this.walletAddress && autoCreate) {
await this.CreateWallet();
}
this.walletAddressRetrieved = true;
return this.walletAddress;
}
/**
* Get the user wallet address for the specified user, if it exists
*
* @namedParams
* @param {string} address - The address of the user
*
* @return {Promise<string>} - The wallet address of the specified user, if it exists
*/
async UserWalletAddress({address}) {
if(Utils.EqualAddress(address, this.client.signer.address)) {
return await this.WalletAddress();
}
if(!this.userWalletAddresses[address]) {
this.Log(`Retrieving user wallet address for user ${address}`);
const walletAddress =
await this.client.CallContractMethod({
contractAddress: Utils.HashToAddress(this.client.contentSpaceId),
methodName: "userWallets",
methodArgs: [address]
});
if(!Utils.EqualAddress(walletAddress, Utils.nullAddress)) {
this.userWalletAddresses[address] = walletAddress;
}
}
return this.userWalletAddresses[address];
}
/**
* Retrieve the user wallet object information (library ID and object ID)
*
* The user's wallet can be modified in the same way as any other object, using
* EditContentObject to get a write token, modification methods to change it,
* and FinalizeContentObject to finalize the draft
*
* @return {Promise<{Object}>} - An object containing the libraryId and objectId for the wallet object.
*/
async UserWalletObjectInfo({address}={}) {
const walletAddress = address ?
await this.UserWalletAddress({address}) :
await this.WalletAddress();
return {
libraryId: this.client.contentSpaceLibraryId,
objectId: walletAddress ? Utils.AddressToObjectId(walletAddress) : ""
};
}
/**
* Access the specified user's public profile metadata
*
* @namedParams
* @param {string=} address - The address of the user
* @param {string=} metadataSubtree - Subtree of the metadata to retrieve
* @param {Object=} queryParams={} - Additional query params for the call
* @param {Array<string>=} select - Limit the returned metadata to the specified attributes
* - Note: Selection is relative to "metadataSubtree". For example, metadataSubtree="public" and select=["name", "description"] would select "public/name" and "public/description"
* @param {boolean=} resolveLinks=false - If specified, links in the metadata will be resolved
* @param {boolean=} resolveIncludeSource=false - If specified, resolved links will include the hash of the link at the root of the metadata
Example:
{
"resolved-link": {
".": {
"source": "hq__HPXNia6UtXyuUr6G3Lih8PyUhvYYHuyLTt3i7qSfYgYBB7sF1suR7ky7YRXsUARUrTB1Um1x5a"
},
"public": {
"name": "My Linked Object",
}
...
}
}
* @param {boolean=} resolveIgnoreErrors=false - If specified, link errors within the requested metadata will not cause the entire response to result in an error
* @param {number=} linkDepthLimit=1 - Limit link resolution to the specified depth. Default link depth is 1 (only links directly in the object's metadata will be resolved)
*
* @return {Promise<Object|string>}
*/
async PublicUserMetadata({
address,
metadataSubtree="/",
queryParams={},
select=[],
resolveLinks=false,
resolveIncludeSource=false,
resolveIgnoreErrors=false,
linkDepthLimit=1
}) {
if(!address) { return; }
const walletAddress = await this.UserWalletAddress({address});
if(!walletAddress) { return; }
metadataSubtree = UrlJoin("public", metadataSubtree || "/");
const { libraryId, objectId } = await this.UserWalletObjectInfo({address});
if(!objectId) { return; }
return await this.client.ContentObjectMetadata({
libraryId,
objectId,
queryParams,
select,
metadataSubtree,
resolveLinks,
resolveIncludeSource,
resolveIgnoreErrors,
linkDepthLimit
});
}
/**
* Access the current user's metadata
*
* Note: Subject to user's access level
*
* @see <a href="#PromptsAndAccessLevels">Prompts and access levels</a>
*
* @namedParams
* @param {string=} metadataSubtree - Subtree of the metadata to retrieve
* @param {Object=} queryParams={} - Additional query params for the call
* @param {Array<string>=} select - Limit the returned metadata to the specified attributes
* - Note: Selection is relative to "metadataSubtree". For example, metadataSubtree="public" and select=["name", "description"] would select "public/name" and "public/description"
* @param {boolean=} resolveLinks=false - If specified, links in the metadata will be resolved
* @param {boolean=} resolveIncludeSource=false - If specified, resolved links will include the hash of the link at the root of the metadata
Example:
{
"resolved-link": {
".": {
"source": "hq__HPXNia6UtXyuUr6G3Lih8PyUhvYYHuyLTt3i7qSfYgYBB7sF1suR7ky7YRXsUARUrTB1Um1x5a"
},
"public": {
"name": "My Linked Object",
}
...
}
}
* @param {boolean=} resolveIgnoreErrors=false - If specified, link errors within the requested metadata will not cause the entire response to result in an error
* @param {number=} linkDepthLimit=1 - Limit link resolution to the specified depth. Default link depth is 1 (only links directly in the object's metadata will be resolved)
*
* @return {Promise<Object|string>} - The user's profile metadata - returns undefined if no metadata set or subtree doesn't exist
*/
async UserMetadata({
metadataSubtree="/",
queryParams={},
select=[],
resolveLinks=false,
resolveIncludeSource=false,
resolveIgnoreErrors=false,
linkDepthLimit=1
}={}) {
this.Log(`Accessing private user metadata at ${metadataSubtree}`);
const { libraryId, objectId } = await this.UserWalletObjectInfo();
return await this.client.ContentObjectMetadata({
libraryId,
objectId,
metadataSubtree,
queryParams,
select,
resolveLinks,
resolveIncludeSource,
resolveIgnoreErrors,
linkDepthLimit
});
}
/**
* Merge the current user's profile metadata
*
* @namedParams
* @param {Object} metadata - New metadata
* @param {string=} metadataSubtree - Subtree to merge into - modifies root metadata if not specified
*/
async MergeUserMetadata({metadataSubtree="/", metadata={}}) {
this.Log(`Merging user metadata at ${metadataSubtree}`);
const { libraryId, objectId } = await this.UserWalletObjectInfo();
const editRequest = await this.client.EditContentObject({libraryId, objectId});
await this.client.MergeMetadata({libraryId, objectId, writeToken: editRequest.write_token, metadataSubtree, metadata});
await this.client.FinalizeContentObject({libraryId, objectId, writeToken: editRequest.write_token, commitMessage: "Merge user metadata"});
}
/**
* Replace the current user's profile metadata
*
* @namedParams
* @param {Object} metadata - New metadata
* @param {string=} metadataSubtree - Subtree to replace - modifies root metadata if not specified
*/
async ReplaceUserMetadata({metadataSubtree="/", metadata={}}) {
this.Log(`Replacing user metadata at ${metadataSubtree}`);
const { libraryId, objectId } = await this.UserWalletObjectInfo();
const editRequest = await this.client.EditContentObject({libraryId, objectId});
await this.client.ReplaceMetadata({libraryId, objectId, writeToken: editRequest.write_token, metadataSubtree, metadata});
await this.client.FinalizeContentObject({libraryId, objectId, writeToken: editRequest.write_token, commitMessage: "Replace user metadata"});
}
/**
* Delete the specified subtree from the users profile metadata
*
* @namedParams
* @param {string=} metadataSubtree - Subtree to delete - deletes all metadata if not specified
*/
async DeleteUserMetadata({metadataSubtree="/"}) {
this.Log(`Deleting user metadata at ${metadataSubtree}`);
const { libraryId, objectId } = await this.UserWalletObjectInfo();
const editRequest = await this.client.EditContentObject({libraryId, objectId});
await this.client.DeleteMetadata({libraryId, objectId, writeToken: editRequest.write_token, metadataSubtree});
await this.client.FinalizeContentObject({libraryId, objectId, writeToken: editRequest.write_token, commitMessage: "Delete user metadata"});
}
/**
* Return the permissions the current user allows for apps to access their profile.
*
* "private" - No access allowed
* "prompt" - (default) - When access is requested by an app, the user will be prompted to give permission
* "public - Public - Any access allowed
*
* @return {Promise<string>} - Access setting
*/
async AccessLevel() {
return (await this.UserMetadata({metadataSubtree: "access_level"})) || "prompt";
}
/**
* Set the current user's access level.
*
* Note: This method is not accessible to applications. Eluvio core will drop the request.
*
* @namedParams
* @param level
*/
async SetAccessLevel({level}) {
level = level.toLowerCase();
if(!["private", "prompt", "public"].includes(level)) {
throw new Error("Invalid access level: " + level);
}
await this.ReplaceUserMetadata({metadataSubtree: "access_level", metadata: level});
}
/**
* Return the ID of the tenant admin group set for current user
*
* @return {Promise<string>} - Tenant ID
*/
async TenantId() {
if(!this.tenantId) {
const {objectId} = await this.UserWalletObjectInfo();
this.tenantId = await this.client.TenantId({ objectId });
}
return this.tenantId;
}
/**
* Set the current user's tenant admin group ID
*
* Note: This method is not accessible to applications. Eluvio core will drop the request.
*
* @namedParams
* @param {string} id - The tenant ID in hash format
* @param {string} address - The group address to use in the hash if id is not provided
*/
async SetTenantId({ id, address }) {
if(id && (!id.startsWith("iten") || !Utils.ValidHash(id))) {
throw Error(`Invalid tenant ID: ${id}`);
}
if(address) {
if(!Utils.ValidAddress(address)) {
throw Error(`Invalid address: ${address}`);
}
id = `iten${Utils.AddressToHash(address)}`;
}
const {objectId} = await this.UserWalletObjectInfo();
const tenantInfo = await this.client.SetTenantId({ objectId, tenantId: id });
this.tenantContractId = tenantInfo.tenantContractId;
this.tenantId = tenantInfo.tenantId;
}
/**
* Return the ID of the tenant contract this user belongs to, if set.
*
* @return {Promise<string>} - Tenant Contract ID
*/
async TenantContractId() {
if(!this.tenantContractId) {
const {objectId} = await this.UserWalletObjectInfo();
this.tenantContractId = await this.client.TenantContractId({ objectId });
}
return this.tenantContractId;
}
/**
* Set the current user's tenant contract.
*
* Note: This method is not accessible to applications. Eluvio core will drop the request.
*
* @namedParams
* @param {string} tenantContractId - The tenant contract ID in hash format
* @param {string} address - The tenant address to use in the hash if id is not provided
*/
async SetTenantContractId({tenantContractId}) {
const {objectId} = await this.UserWalletObjectInfo();
const tenantInfo = await this.client.SetTenantContractId({ objectId,tenantContractId });
this.tenantContractId = tenantInfo.tenantContractId;
this.tenantId = tenantInfo.tenantId;
}
async ResetTenantId(){
const {objectId} = await this.UserWalletObjectInfo();
await this.client.ResetTenantId({objectId});
this.tenantId = this.client.TenantId({objectId});
this.tenantContractId = this.client.TenantContractId({objectId});
}
/**
* Get the URL of the current user's profile image
*
* Note: Part hash of profile image will be appended to the URL as a query parameter to invalidate
* browser caching when the image is updated
*
* @namedParams
* @param {string=} address - The address of the user. If not specified, the address of the current user will be used.
* @param {number=} height - If specified, the image will be scaled to the specified maximum height
*
* @see <a href="Utils.html#.ResizeImage">Utils#ResizeImage</a>
*
* @return {Promise<string | undefined>} - URL of the user's profile image. Will be undefined if no profile image is set.
*/
async UserProfileImage({address, height}={}) {
let walletAddress;
if(address) {
walletAddress = await this.UserWalletAddress({address});
} else {
address = this.client.signer.address;
walletAddress = this.walletAddress;
}
if(!walletAddress) { return; }
const { libraryId, objectId } = await this.UserWalletObjectInfo({address});
return this.client.ContentObjectImageUrl({libraryId, objectId, height, imagePath: "public/profile_image"});
}
/**
* Set a new profile image for the current user
*
* @namedParams
* @param {blob} image - The new profile image for the current user
*/
async SetUserProfileImage({image}) {
this.Log(`Setting profile image for user ${this.client.signer.address}`);
const size = image.length || image.byteLength || image.size;
if(size > 5000000) {
throw Error("Maximum profile image size is 5MB");
}
const { libraryId, objectId } = await this.UserWalletObjectInfo();
const editRequest = await this.client.EditContentObject({libraryId, objectId});
await this.client.SetContentObjectImage({
libraryId,
objectId,
writeToken: editRequest.write_token,
image,
imageName: "profile_image",
imagePath: "public/profile_image"
});
await this.client.FinalizeContentObject({libraryId, objectId, writeToken: editRequest.write_token, commitMessage: "Set user profile image"});
}
/**
* Get the accumulated tags for the current user
*
* Note: Subject to user's access level
*
* @see <a href="#PromptsAndAccessLevels">Prompts and access levels</a>
*
* @return {Promise<Object>} - User tags
*/
async CollectedTags() {
return await this.UserMetadata({metadataSubtree: "collected_data"}) || {};
}
// Ensure recording tags never causes action to fail
async RecordTags({libraryId, objectId, versionHash}) {
try {
await this.__RecordTags({libraryId, objectId, versionHash});
} catch(error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
async __RecordTags({libraryId, objectId, versionHash}) {
const accessType = await this.client.AccessType({id: objectId});
if(accessType !== "object") { return; }
if(!versionHash && !libraryId) {
libraryId = await this.client.ContentObjectLibraryId({objectId});
}
if(!versionHash) {
versionHash = (await this.client.ContentObject({libraryId, objectId})).hash;
}
// If this object has already been seen, don't re-record tags
const seen = await this.UserMetadata({metadataSubtree: UrlJoin("accessed_content", versionHash)});
if(seen) { return; }
const walletObjectInfo = await this.UserWalletObjectInfo();
const userLibraryId = walletObjectInfo.libraryId;
const userObjectId = walletObjectInfo.objectId;
// Mark content as seen
const editRequest = await this.client.EditContentObject({libraryId: userLibraryId, objectId: userObjectId});
await this.client.ReplaceMetadata({
libraryId: userLibraryId,
objectId: userObjectId,
writeToken: editRequest.write_token,
metadataSubtree: UrlJoin("accessed_content", versionHash),
metadata: Date.now()
});
const contentTags = await this.client.ContentObjectMetadata({
libraryId,
objectId,
versionHash,
metadataSubtree: "video_tags"
});
if(contentTags && contentTags.length > 0) {
let userTags = await this.CollectedTags();
const formattedTags = this.__FormatVideoTags(contentTags);
Object.keys(formattedTags).forEach(tag => {
if(userTags[tag]) {
// User has seen this tag before
userTags[tag].occurrences += 1;
userTags[tag].aggregate += formattedTags[tag];
} else {
// New tag
userTags[tag] = {
occurrences: 1,
aggregate: formattedTags[tag]
};
}
});
// Update user tags
await this.client.ReplaceMetadata({
libraryId: userLibraryId,
objectId: userObjectId,
writeToken: editRequest.write_token,
metadataSubtree: "collected_data",
metadata: userTags
});
}
await this.client.FinalizeContentObject({
libraryId: userLibraryId,
objectId: userObjectId,
writeToken: editRequest.write_token,
commitMessage: "Record user tags",
awaitCommitConfirmation: false
});
}
/*
Format video tags into an easier format and average scores
Example content tags:
[
{
"tags": [
{
"score": 0.3,
"tag": "cherry"
},
{
"score": 0.8,
"tag": "chocolate"
},
{
"score": 0.6,
"tag": "boat"
}
],
"time_in": "00:00:00.000",
"time_out": "00:03:00.000"
},
...
]
*/
__FormatVideoTags(videoTags) {
let collectedTags = {};
videoTags.forEach(videoTag => {
const tags = videoTag["tags"];
tags.forEach(tag => {
if(collectedTags[tag.tag]) {
collectedTags[tag.tag].occurrences += 1;
collectedTags[tag.tag].aggregate += tag.score;
} else {
collectedTags[tag.tag] = {
occurrences: 1,
aggregate: tag.score
};
}
});
});
let formattedTags = {};
Object.keys(collectedTags).forEach(tag => {
formattedTags[tag] = collectedTags[tag].aggregate / collectedTags[tag].occurrences;
});
return formattedTags;
}
// List of methods that may require a prompt - these should have an unlimited timeout period
PromptedMethods() {
return FrameClient.PromptedMethods();
}
// List of methods for accessing user metadata - these should be namespaced when used by an app
MetadataMethods() {
return FrameClient.MetadataMethods();
}
// Whitelist of methods allowed to be called using the frame API
FrameAllowedMethods() {
const forbiddenMethods = [
"constructor",
"FrameAllowedMethods",
"Log",
"MetadataMethods",
"PromptedMethods",
"RecordTags",
"SetAccessLevel",
"SetTenantId",
"SetUserProfileImage",
"__IsLibraryCreated",
"__TouchLibrary",
"__FormatVideoTags",
"__RecordTags"
];
return Object.getOwnPropertyNames(Object.getPrototypeOf(this))
.filter(method => !forbiddenMethods.includes(method));
}
}
module.exports = UserProfileClient;