The following example demonstrates using Nakama as an asset management system, providing clients with temporary access to secure cloud hosted (Amazon S3) assets.
This is extremely beneficial in a number of scenarios. For example, you could choose to host game assets in a private Amazon S3 bucket and then allow your clients to download those assets securely at runtime.
There are several benefits to using an asset management system, such as:
Assets remain private and are provided in an on-demand time-limited manner
The ability to provide clients with different assets (e.g. beta access assets)
Reduces round trips to your cloud storage provider for things such as listing assets
Allows for user-generated content and validation of assets
There are 3 major parts to this system:
Asset manifests which will be categorized and stored using Nakama Storage Engine
An asset upload RPC which will be responsible for taking binary data and uploading it to the cloud, as well as storing the corresponding asset manifest
An asset download RPC which will generate a pre-signed temporary download URL to a requested asset
This system is client-agnostic, however at the end of this guide you will see how you can use this to implement a secure asset delivery system, providing runtime access to Unity Addressable assets directly from a private S3 bucket.
Using the Nakama Storage Engine you can store asset manifests which hold information on the particular asset that has been uploaded, including:
name
category
owner
md5 checksum
created timestamp
Storing asset manifests in Nakama allows the client to retrieve individual records or retrieve lists of records (e.g. by category) without requiring multiple round trips to the cloud storage provider which, vitally, could incur costs.
You can define the asset manifest and other relevant messages using standard structs, or alternatively you can use a specific messaging format like Protocol Buffers as shown below:
If you’re using Protocol Buffers you will also need to use the protoc tool to generate the appropriate files from the .proto file for your programming language. For more information please read the Protocol Buffers Basics: Go documentation.
Before implementing the asset upload/download RPCs you will need to initialize the Protocol Buffer marshal and S3 service object and uploader.
You can pass environment variables to Nakama through the local.yml configuration file under the runtime.env section. For further information see the configuration documentation.
funcInitModule(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,initializerruntime.Initializer)error{initStart:=time.Now()env:=ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)// Ensure AWS environment vars have been configured
awsAccessKeyID,ok:=env["AWS_ACCESS_KEY_ID"]if!ok{returnerrors.New("missing AWS_ACCESS_KEY_ID in runtime env")}awsSecretAccessKey,ok:=env["AWS_SECRET_ACCESS_KEY"]if!ok{returnerrors.New("missing AWS_SECRET_ACCESS_KEY in runtime env")}awsRegion,ok:=env["AWS_REGION"]if!ok{returnerrors.New("missing AWS_REGION in runtime env")}awsBucket,ok:=env["AWS_BUCKET"]if!ok{returnerrors.New("missing AWS_BUCKET in runtime env")}// Create protobuf marshalers
marshaler:=&jsonpb.Marshaler{EnumsAsInts:true,}unmarshaler:=&jsonpb.Unmarshaler{AllowUnknownFields:false,}// Create an S3 service object and uploader
sess:=session.Must(session.NewSession(&aws.Config{Region:aws.String(awsRegion),Credentials:credentials.NewStaticCredentials(awsAccessKeyID,awsSecretAccessKey,""),}))svc:=s3.New(sess)uploader:=s3manager.NewUploader(sess)// Register RPCs here...
logger.Info("Plugin loaded in '%d' msec.",time.Since(initStart).Milliseconds())returnnil}
With the asset manifest defined, you can implement an RPC function that will take binary data encoded into a base64 string, create and store an asset manifest and upload the binary file to a private Amazon S3 bucket.
funcUploadAssetToS3(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,uploader*s3manager.Uploader,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check if s3 storage client is configured or return error
ifuploader==nil{return"",errors.New("s3 uploader not configured")}iflen(bucketName)==0{return"",errors.New("no s3 bucket name provided")}// Check user that's making the request
userID,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{return"",errors.New("user ID not found")}// Unmarshal the upload request input
request:=&api.RpcAssetUploadRequest{}logger.Info("got payload: %q",payload)iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling RpcAssetUploadRequest")return"",errors.New("error unmarshalling RpcAssetUploadRequest")}// Check that the input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}ifrequest.Name==""{return"",errors.New("asset Name required")}iflen(request.Data)==0{return"",errors.New("asset Data required")}// Upload the asset to s3 bucket
result,err:=uploader.Upload(&s3manager.UploadInput{Bucket:aws.String(bucketName),Key:aws.String(request.Name),Body:bytes.NewReader(request.Data),})iferr!=nil{logger.WithField("error",err).Error("failed to upload file")return"",errors.New("failed to upload file")}// Prepare the asset manifest
md5:=strings.ReplaceAll(*result.ETag,"\"","")manifest:=&api.AssetManifest{Category:request.Category,Name:request.Name,Owner:userID,Md5:md5,CreatedAt:time.Now().Unix(),}buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,manifest);err!=nil{logger.WithField("error",err).Error("error encoding asset manifest")return"",errors.New("error encoding asset manifest")}// Write the asset manifest to nakama storage
if_,err:=nk.StorageWrite(ctx,[]*runtime.StorageWrite{{Collection:fmt.Sprintf("asset_%s",request.Category),Key:request.Name,UserID:"",// Will be owned by the system user
Value:buf.String(),PermissionRead:2,// Clients can read directly
PermissionWrite:0,// Only server can write
}});err!=nil{logger.WithField("error",err).Error("error writing asset manifest")return"",errors.New("error writing asset manifest")}// Prepare response to the client
response:=&api.RpcAssetUploadResponse{Manifest:manifest,}buf=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,response);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}returnbuf.String(),nil}}
You must then register this RPC in the InitModule function.
The asset download RPC will check for an existing asset manifest for the requested file and upon finding one it will talk to Amazon S3 and generate a pre-signed temporary download URL for the asset which is then passed back to the client.
funcGetAssetDownloadUri(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,svc*s3.S3,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check user that's making the request
_,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{// Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
// We're choosing not to allow this here, downloads are expected to be done by a user.
return"",errors.New("user ID not found")}// Unmarshal the input
request:=&api.RpcAssetDownloadRequest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling request")return"",errors.New("error unmarshalling request")}// Check input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}ifrequest.Name==""{return"",errors.New("asset Name required")}// Look up the asset in the storage manifest
objects,err:=nk.StorageRead(ctx,[]*runtime.StorageRead{{Collection:fmt.Sprintf("asset_%s",request.Category),Key:request.Name,UserID:"",// Owned by the system user
}})iferr!=nil{logger.WithField("error",err).Error("error reading asset manifest")return"",errors.New("error reading asset manifest")}// Check if the object exists
iflen(objects)==0{return"",errors.New("asset not found")}// Get the asset manifest
manifest:=&api.AssetManifest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(objects[0].Value)),manifest);err!=nil{logger.WithField("error",err).Error("error unmarshaling manifest")return"",errors.New("error unmarshaling manifest")}// Prepare a short-lived asset download link
assetDownload,err:=getAssetDownloadUri(request.Name,svc,bucketName)iferr!=nil{logger.WithField("error",err).Error(err.Error())return"",err}// Prepare response to client
buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,assetDownload);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}logger.Info("Responding with:\n%q",buf.String())returnbuf.String(),nil}}funcgetAssetDownloadUri(keystring,svc*s3.S3,bucketNamestring)(*api.RpcAssetDownloadResponse,error){// Prepare a short-lived asset download link
req,_:=svc.GetObjectRequest(&s3.GetObjectInput{Bucket:aws.String(bucketName),Key:aws.String(key),})uri,err:=req.Presign(24*time.Hour)iferr!=nil{returnnil,errors.New("error signing request")}response:=&api.RpcAssetDownloadResponse{Name:key,DownloadUri:uri,}returnresponse,nil}
You must then register this RPC in the InitModule function.
It can also be useful to get a list of asset download URLs by simply providing the category of assets you wish to download. Below we create a new RPC to generate a list of pre-signed download URLs for assets of a specified category.
funcGetAllAssetDownloadUris(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,svc*s3.S3,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check user that's making the request
_,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{// Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
// We're choosing not to allow this here, downloads are expected to be done by a user.
return"",errors.New("user ID not found")}// Unmarshal the input
request:=&api.RpcAssetDownloadRequest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling request")return"",errors.New("error unmarshalling request")}// Check input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}// Look up the asset in the storage manifest
objects,_,err:=nk.StorageList(ctx,"",fmt.Sprintf("asset_%s",request.Category),100,"")iferr!=nil{logger.WithField("error",err).Error("error reading asset manifest")return"",errors.New("error reading asset manifest")}// Check if any objects exist
iflen(objects)==0{return"",errors.New("no assets found")}// Create a response object
response:=&api.RpcAllAssetsDownloadResponse{}// Loop through all assets and get a download uri
for_,object:=rangeobjects{assetDownload,err:=getAssetDownloadUri(object.Key,svc,bucketName)iferr!=nil{logger.WithField("error",err).Error(err.Error())return"",err}response.Assets=append(response.Assets,assetDownload)}// Marshal the response
buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,response);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}returnbuf.String(),nil}}
You must then register this RPC in the InitModule function.
Example: Integrating the Asset Management system with Unity Addressables
#
The asset management system above can be integrated with a remote asset delivery pipeline such as Unity’s Addressables feature.
With Unity Addressables you can package up your assets, host them remotely and then load those assets at runtime.
Unity Addressables works out of the box with public cloud storage like Amazon S3, Google Cloud File Storage or Digital Ocean Object Storage. Unfortunately it does not support private cloud storage out of the box. However, we can integrate our Nakama Asset Management system in just a few small steps.
First, you’ll need to setup your Addressable assets configuration to Build and Load from the Remote path as shown below. Ensure you also tick the Build Remote Catalog option.
You can configure the exact remote load path of your Addressable assets from the Addressables Profiles window. However, you can enter any address you like here as long as it begins with http(s) as it will be replaced at runtime with code you add later.
With the Addressables system configured, build your Addressables asset bundle by clicking the Build button in the Addressables Groups window. If you’re building your assets for the first time then choose New Build otherwise choose Update Previous Build (This ensures the same catalog file is used which then allows clients to download the latest assets without needing to update their client). You can then upload these assets to your Nakama server using the asset_upload RPC mentioned earlier in this guide. How you do this is up to you, one suggestion would be to write an editor script that you can execute to upload your assets. An example of this is shown below.
Then, create a script called AddressablesManager that, on start, retrieves the relevant (for the current platform) Unity Asset Catalog (hash/json) as well as the Asset Bundle from Nakama.
Next, assign a new transform function to the Addressable system’s ResourceManager.InternalIdTransformFunc in the Start method that will take the internal asset Id and convert it to our pre-signed asset download URLs.
privateasyncvoidStart(){// ...Addressables.ResourceManager.InternalIdTransformFunc+=TransformFunc;}privatestringTransformFunc(IResourceLocationlocation){// If we're trying to load a remote asset over http(s):// - grab the file name from the url path// - look up the pre-signed url and return that if available; otherwise just return the same location.InternalIdvarregex=newRegex("https?.+/([^?]+)");varmatch=regex.Match(location.InternalId);if(match.Success){varkey=match.Groups[1].Value;if(assetPresignedUris.ContainsKey(key)){returnassetPresignedUris[key];}}returnlocation.InternalId;}
Then finally, instantiate your addressable assets as you normally would.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
privateasyncvoidStart(){// ...Addressables.InitializeAsync().Completed+=OnInitializeAsyncCompleted;}privatevoidOnInitializeAsyncCompleted(AsyncOperationHandle<IResourceLocator>obj){// e.g.// addressablePrefabReference.InstantiateAsync().Completed += go =>// {// Instantiate(go.Result);// };}
You should now be able to build and run your application and see your privately hosted addressable asset loaded.