diff --git a/examples/hpclient/text-client.js b/examples/hpclient/text-client.js new file mode 100644 index 00000000..8749e2c1 --- /dev/null +++ b/examples/hpclient/text-client.js @@ -0,0 +1,150 @@ +// +// HotPocket client example code adopted from: +// https://github.com/codetsunami/hotpocket/blob/master/hp_client.js +// + +const fs = require('fs') +const ws_api = require('ws'); +const sodium = require('libsodium-wrappers') +const readline = require('readline') + +// sodium has a trigger when it's ready, we will wait and execute from there +sodium.ready.then(main).catch((e) => { console.log(e) }) + + +function main() { + + var keys = sodium.crypto_sign_keypair() + + + // check for client keys + if (!fs.existsSync('.hp_client_keys')) { + keys.privateKey = sodium.to_hex(keys.privateKey) + keys.publicKey = sodium.to_hex(keys.publicKey) + fs.writeFileSync('.hp_client_keys', JSON.stringify(keys)) + } else { + keys = JSON.parse(fs.readFileSync('.hp_client_keys')) + keys.privateKey = Uint8Array.from(Buffer.from(keys.privateKey, 'hex')) + keys.publicKey = Uint8Array.from(Buffer.from(keys.publicKey, 'hex')) + } + + + var server = 'wss://localhost:8080' + + if (process.argv.length == 3) server = 'wss://localhost:' + process.argv[2] + + if (process.argv.length == 4) server = 'wss://' + process.argv[2] + ':' + process.argv[3] + + var ws = new ws_api(server, { + rejectUnauthorized: false + }) + + /* anatomy of a public challenge + { + version: '0.1', + type: 'public_challenge', + challenge: '' + } + */ + + + // if the console ctrl + c's us we should close ws gracefully + process.once('SIGINT', function (code) { + console.log('SIGINT received...'); + ws.close() + }); + + function create_input_container(inp) { + let inp_container = { + nonce: (new Date()).getTime().toString(), + input: Buffer.from(inp).toString('hex'), + max_ledger_seqno: 9999999 + } + let inp_container_bytes = JSON.stringify(inp_container); + let sig_bytes = sodium.crypto_sign_detached(inp_container_bytes, keys.privateKey); + + let signed_inp_container = { + type: "contract_input", + content: inp_container_bytes.toString('hex'), + sig: Buffer.from(sig_bytes).toString('hex') + } + + return JSON.stringify(signed_inp_container); + } + + function create_status_request() { + let statreq = { type: 'stat' } + return JSON.stringify(statreq); + } + + function handle_public_challange(m) { + let pkhex = 'ed' + Buffer.from(keys.publicKey).toString('hex'); + console.log('My public key is: ' + pkhex); + + // sign the challenge and send back the response + var sigbytes = sodium.crypto_sign_detached(m.challenge, keys.privateKey); + var response = { + type: 'challenge_resp', + challenge: m.challenge, + sig: Buffer.from(sigbytes).toString('hex'), + pubkey: pkhex + } + + ws.send(JSON.stringify(response)) + + // start listening for stdin + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + console.log("Ready to accept inputs.") + + // Capture user input from the console. + var input_pump = () => { + rl.question('', (inp) => { + + let msgtosend = ""; + + if (inp == "stat") + msgtosend = create_status_request(); + else + msgtosend = create_input_container(inp); + + ws.send(msgtosend) + + input_pump() + }) + } + input_pump(); + } + + ws.on('message', (data) => { + + try { + m = JSON.parse(data) + } catch (e) { + console.log("Exception: " + data); + return + } + + if (m.type == 'public_challenge') { + handle_public_challange(m); + } + else if (m.type == 'contract_output') { + console.log(Buffer.from(m.content, 'hex').toString()); + } + else if (m.type == 'request_status_result') { + if (m.status != "accepted") + console.log("Input status: " + m.status); + } + else { + console.log(m); + } + + }); + + ws.on('close', () => { + console.log('Server disconnected.'); + }); +} diff --git a/examples/todo_contract/.gitignore b/examples/todo_contract/.gitignore new file mode 100644 index 00000000..8d4a6c08 --- /dev/null +++ b/examples/todo_contract/.gitignore @@ -0,0 +1,2 @@ +bin +obj \ No newline at end of file diff --git a/examples/todo_contract/DbModel.cs b/examples/todo_contract/DbModel.cs new file mode 100644 index 00000000..74921604 --- /dev/null +++ b/examples/todo_contract/DbModel.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace ToDoContract +{ + public class DataContext : DbContext + { + public DbSet ToDoEntries { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite("Data Source=state/todo.db"); + } + + public class ToDoEntry + { + public int Id { get; set; } + public string Content { get; set; } + public string CreatedBy { get; set; } + } +} \ No newline at end of file diff --git a/examples/todo_contract/HPHelper.cs b/examples/todo_contract/HPHelper.cs new file mode 100644 index 00000000..b925f4eb --- /dev/null +++ b/examples/todo_contract/HPHelper.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Mono.Unix.Native; +using Newtonsoft.Json; + +namespace HotPocket +{ + public static class HotPocketHelper + { + const int FD_READ_BUFFER_LEN = 1024; + + public static async Task GetContractArgsAsync() + { + using (var s = new StreamReader(Console.OpenStandardInput())) + { + var input = await s.ReadToEndAsync(); + var contractArgs = JsonConvert.DeserializeObject(input); + return contractArgs; + } + } + + public static string ReadStringFromFD(int fd) + { + return Encoding.UTF8.GetString(ReadBytesFromFD(fd)); + } + + public static void WriteStringToFD(int fd, string str) + { + WriteBytesToFD(fd, Encoding.UTF8.GetBytes(str)); + } + + public static unsafe byte[] ReadBytesFromFD(int fd) + { + // Keep reading the fd bytes and fill the memory stream until no more bytes. + using (var ms = new MemoryStream()) + { + int readbytes = 0; + + do + { + var buffer = new byte[FD_READ_BUFFER_LEN]; + + fixed (byte* p = buffer) + { + IntPtr ptr = (IntPtr)p; + readbytes = (int)Syscall.read(fd, ptr, FD_READ_BUFFER_LEN); + } + + ms.Write(buffer, 0, readbytes); + + } while (readbytes == FD_READ_BUFFER_LEN); + + return ms.ToArray(); + } + } + + public static unsafe void WriteBytesToFD(int fd, byte[] buffer) + { + fixed (byte* p = buffer) + { + IntPtr ptr = (IntPtr)p; + Syscall.write(fd, ptr, (ulong)buffer.Length); + } + } + } +} \ No newline at end of file diff --git a/examples/todo_contract/HPModel.cs b/examples/todo_contract/HPModel.cs new file mode 100644 index 00000000..2a514806 --- /dev/null +++ b/examples/todo_contract/HPModel.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace HotPocket +{ + public class ContractArgs + { + [JsonProperty("version")] + public string Version { get; set; } + + [JsonProperty("pubkey")] + public string PubKey { get; set; } + + [JsonProperty("ts")] + public string Timestamp { get; set; } + + [JsonProperty("hpfd")] + public IOPipe HotPocketPipe { get; set; } + + [JsonProperty("nplfd")] + public IOPipe NplPipe { get; set; } + + [JsonProperty("usrfd")] + public Dictionary UserPipes { get; set; } + + [JsonProperty("unl")] + public string[] Unl { get; set; } + } + + [JsonConverter(typeof(IOPipeJsonConverter))] + public class IOPipe + { + public int ReadFD { get; set; } + public int WriteFD { get; set; } + } + + public class IOPipeJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(IOPipe); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + var array = JArray.Load(reader); + var pipe = (existingValue as IOPipe ?? new IOPipe()); + pipe.ReadFD = (int)array.ElementAtOrDefault(0); + pipe.WriteFD = (int)array.ElementAtOrDefault(1); + return pipe; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var pipe = (IOPipe)value; + serializer.Serialize(writer, new[] { pipe.ReadFD, pipe.WriteFD }); + } + } +} \ No newline at end of file diff --git a/examples/todo_contract/Migrations/20200114133142_InitialCreate.Designer.cs b/examples/todo_contract/Migrations/20200114133142_InitialCreate.Designer.cs new file mode 100644 index 00000000..f639f5fe --- /dev/null +++ b/examples/todo_contract/Migrations/20200114133142_InitialCreate.Designer.cs @@ -0,0 +1,39 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ToDoContract; + +namespace ToDoContract.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20200114133142_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.0"); + + modelBuilder.Entity("ToDoContract.ToDoEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ToDoEntries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/todo_contract/Migrations/20200114133142_InitialCreate.cs b/examples/todo_contract/Migrations/20200114133142_InitialCreate.cs new file mode 100644 index 00000000..3f32939f --- /dev/null +++ b/examples/todo_contract/Migrations/20200114133142_InitialCreate.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ToDoContract.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ToDoEntries", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Content = table.Column(nullable: true), + CreatedBy = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ToDoEntries", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ToDoEntries"); + } + } +} diff --git a/examples/todo_contract/Migrations/DataContextModelSnapshot.cs b/examples/todo_contract/Migrations/DataContextModelSnapshot.cs new file mode 100644 index 00000000..09f9130f --- /dev/null +++ b/examples/todo_contract/Migrations/DataContextModelSnapshot.cs @@ -0,0 +1,37 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ToDoContract; + +namespace ToDoContract.Migrations +{ + [DbContext(typeof(DataContext))] + partial class DataContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.0"); + + modelBuilder.Entity("ToDoContract.ToDoEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ToDoEntries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/examples/todo_contract/Program.cs b/examples/todo_contract/Program.cs new file mode 100644 index 00000000..d2042089 --- /dev/null +++ b/examples/todo_contract/Program.cs @@ -0,0 +1,136 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using HotPocket; +using System.Linq; + +namespace ToDoContract +{ + /* + * This is a simple multi-user ToDo list contract which uses sqlite database as storage. + * In order to run this .Net Core should be installed on the system. If using docker, + * mcr.microsoft.com/dotnet/core/sdk:3.1 docker image must be used. + * + * Produce deployable output with: dotnet publish -c Release + * + * User inputs can be submitted in the following format. + * Insert a new ToDo record: add + * Retrieve all records owned by user: get all + * Retrieve record by ID: get <id> + * Delete all records owned by user: delete all + * Delete record by ID: delete <id> + */ + public class Program + { + static async Task Main(string[] args) + { + Console.WriteLine("Starting .Net ToDo contract"); + + using (var dataContext = new DataContext()) + { + dataContext.Database.Migrate(); + } + + ContractArgs contractArgs = await HotPocketHelper.GetContractArgsAsync(); + + foreach (var user in contractArgs.UserPipes) + { + var pubkey = user.Key; + var pipe = user.Value; + + var input = HotPocketHelper.ReadStringFromFD(pipe.ReadFD); + if (string.IsNullOrEmpty(input)) + continue; + + var output = await HandleUserInputAsync(pubkey, input); + HotPocketHelper.WriteStringToFD(pipe.WriteFD, output); + } + } + + static async Task<string> HandleUserInputAsync(string userId, string input) + { + var parts = input.Trim().Split(' ', 2); + if (parts.Length < 2) + return "Invalid input format"; + + var command = parts[0].ToLower(); + var param = parts[1]; + + using (var dataContext = new DataContext()) + { + if (command == "add") // add new record. + { + var entry = new ToDoEntry + { + Content = param, + CreatedBy = userId + }; + dataContext.ToDoEntries.Add(entry); + + await dataContext.SaveChangesAsync(); + + return "Added entry with id " + entry.Id; + } + else if (command == "get" || command == "delete") + { + if (param == "all") // get/delete all records. + { + // Get all entries belonging to this user. + var entries = await dataContext.ToDoEntries.Where(e => e.CreatedBy == userId).OrderBy(e => e.Id).ToListAsync(); + + if (command == "get") + { + return JsonConvert.SerializeObject(entries.Select(e => $"ID-{e.Id}: {e.Content}")); + } + else + { + // Delete all records for this user. + dataContext.RemoveRange(entries); + await dataContext.SaveChangesAsync(); + return $"{entries.Count} record(s) deleted"; + + } + } + else // get/delete by ID. + { + int id = 0; + if (int.TryParse(param, out id)) + { + var entry = await dataContext.ToDoEntries.FirstOrDefaultAsync(e => e.Id == id); + if (entry == null) + { + return $"Record id {id} does not exist"; + } + else if (entry.CreatedBy != userId) + { + return $"You do not have permission for record id {id}"; + } + else + { + if (command == "get") + { + return entry.Content; + } + else + { + dataContext.Remove(entry); + await dataContext.SaveChangesAsync(); + return $"Record id {id} deleted"; + } + } + } + else + { + return "Invalid record id"; + } + } + } + else + { + return "Invalid command"; + } + } + } + } +} diff --git a/examples/todo_contract/ToDoContract.csproj b/examples/todo_contract/ToDoContract.csproj new file mode 100644 index 00000000..955a561e --- /dev/null +++ b/examples/todo_contract/ToDoContract.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>netcoreapp3.1</TargetFramework> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.0"> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.0" /> + <PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + </ItemGroup> + +</Project> diff --git a/src/jsonschema/usrmsg_helpers.cpp b/src/jsonschema/usrmsg_helpers.cpp index ef1403a5..42273f09 100644 --- a/src/jsonschema/usrmsg_helpers.cpp +++ b/src/jsonschema/usrmsg_helpers.cpp @@ -146,9 +146,9 @@ void create_request_status_result(std::string &msg, std::string_view status, std .append(reason) .append(SEP_COMMA) .append(FLD_ORIGIN) - .append(":{") + .append("\":{\"") .append(FLD_TYPE) - .append(SEP_COMMA) + .append(SEP_COLON) .append(origin_type) .append("\"") .append(origin_extra_data) diff --git a/test/local-cluster/cluster-create.sh b/test/local-cluster/cluster-create.sh index 19839dbf..dd5df1b3 100755 --- a/test/local-cluster/cluster-create.sh +++ b/test/local-cluster/cluster-create.sh @@ -67,6 +67,7 @@ do mkdir ./node$n/bin cp ../../../examples/echo_contract/contract.js ./node$n/bin/contract.js cp ../bin/appbill ./node$n/bin/ + # cp -r ../../../examples/todo_contract/bin/Release/netcoreapp3.1/publish/* ./node$n/bin/ done # Function to generate JSON array string while skiping a given index.