Skip to content

Commit

Permalink
Always use LINK refs to support delete
Browse files Browse the repository at this point in the history
Store plaintext data size in Ref and allow ChunkSize to be requested in
Header

This may allow for better sharing from some kinds of data, truncates to
a MaxChunkSize based on GRPC messages size.

Storing size in individual refs allosw for chunk-wise random access down
the line

Signed-off-by: Silas Davis <[email protected]>
  • Loading branch information
Silas Davis committed Dec 10, 2020
1 parent 66b6e20 commit 54d27c2
Show file tree
Hide file tree
Showing 33 changed files with 452 additions and 303 deletions.
304 changes: 157 additions & 147 deletions api/api.pb.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion chunking.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"io"
)

func CopyChunked(dest func(chunk []byte) error, src func() ([]byte, error), chunkSize int) error {
func CopyChunked(dest func(chunk []byte) error, src func() ([]byte, error), chunkSize int64) error {
// The internal Buffer will ensure we write in chunks
_, err := io.CopyBuffer(NewPusher(dest), NewPuller(src), make([]byte, chunkSize))
if err != io.EOF {
Expand Down
12 changes: 6 additions & 6 deletions cmd/hoarctl/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ func (client *Client) Decrypt(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

dec, err := client.encryption.Decrypt(context.Background())
if err != nil {
fatalf("Error starting client: %v", err)
}

err = hoard.NewStreamer().WithChunkSize(*chunk).WithInput(os.Stdin).
err = hoard.NewStreamer().WithChunkSize(int64(*chunk)).WithInput(os.Stdin).
WithSend(
func(data []byte) error {
return dec.Send(&api.ReferenceAndCiphertext{
Expand Down Expand Up @@ -58,7 +58,7 @@ func (client *Client) Encrypt(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

enc, err := client.encryption.Encrypt(context.Background())
if err != nil {
Expand All @@ -77,7 +77,7 @@ func (client *Client) Encrypt(cmd *cli.Cmd) {
}

err = hoard.NewStreamer().
WithChunkSize(*chunk).
WithChunkSize(int64(*chunk)).
WithInput(os.Stdin).
WithSend(
func(data []byte) error {
Expand Down Expand Up @@ -108,7 +108,7 @@ func (client *Client) Ref(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

enc, err := client.encryption.Encrypt(context.Background())
if err != nil {
Expand All @@ -123,7 +123,7 @@ func (client *Client) Ref(cmd *cli.Cmd) {
var refs reference.Refs

err = hoard.NewStreamer().
WithChunkSize(*chunk).
WithChunkSize(int64(*chunk)).
WithInput(os.Stdin).
WithSend(func(data []byte) error {
return enc.Send(&api.Plaintext{Body: data})
Expand Down
4 changes: 2 additions & 2 deletions cmd/hoarctl/grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (client *Client) PutSeal(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

spec := &grant.Spec{Plaintext: &grant.PlaintextSpec{}}
if *key != "" {
Expand All @@ -47,7 +47,7 @@ func (client *Client) PutSeal(cmd *cli.Cmd) {
fatalf("Error sending head: %v", err)
}

err = hoard.NewStreamer().WithChunkSize(*chunk).WithInput(os.Stdin).WithSend(func(data []byte) error {
err = hoard.NewStreamer().WithChunkSize(int64(*chunk)).WithInput(os.Stdin).WithSend(func(data []byte) error {
return putseal.Send(&api.PlaintextAndGrantSpec{
Plaintext: &api.Plaintext{
Body: data,
Expand Down
1 change: 0 additions & 1 deletion cmd/hoarctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ const (
fileOpt string = "File to read"

chunkSize = 64 * 1024 // 64 Kb
grpcLimit = 4 * 1024 * 1024
)

// Client scopes the available hoard clients
Expand Down
8 changes: 5 additions & 3 deletions cmd/hoarctl/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"os"

"github.com/monax/hoard/v8"

cli "github.com/jawher/mow.cli"
"github.com/monax/hoard/v8/grant"
"github.com/monax/hoard/v8/reference"
Expand All @@ -24,10 +26,10 @@ func addIntOpt(cmd *cli.Cmd, arg, desc string, def int) *int {
return opt
}

func validateChunkSize(cs int) {
if cs == 0 {
func validateChunkSize(chunkSize int64) {
if chunkSize == 0 {
fatalf("Chunk size cannot be 0")
} else if cs > grpcLimit {
} else if chunkSize > hoard.MaxChunkSize {
fatalf("Chunk size cannot be greater than 4Mb")
}
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/hoarctl/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func (client *Client) Insert(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

// If given address use it
push, err := client.storage.Push(context.Background())
Expand All @@ -81,7 +81,7 @@ func (client *Client) Insert(cmd *cli.Cmd) {

var addresses []*api.Address

err = hoard.NewStreamer().WithChunkSize(*chunk).
err = hoard.NewStreamer().WithChunkSize(int64(*chunk)).
WithInput(os.Stdin).
WithSend(func(data []byte) error {
return push.Send(&api.Ciphertext{EncryptedData: data})
Expand Down Expand Up @@ -111,7 +111,7 @@ func (client *Client) Put(cmd *cli.Cmd) {
chunk := addIntOpt(cmd, "chunk", chunkOpt, chunkSize)

cmd.Action = func() {
validateChunkSize(*chunk)
validateChunkSize(int64(*chunk))

put, err := client.cleartext.Put(context.Background())
if err != nil {
Expand All @@ -124,7 +124,7 @@ func (client *Client) Put(cmd *cli.Cmd) {
}

refs := reference.Refs{}
err = hoard.NewStreamer().WithChunkSize(*chunk).
err = hoard.NewStreamer().WithChunkSize(int64(*chunk)).
WithInput(os.Stdin).
WithSend(func(data []byte) error {
return put.Send(&api.Plaintext{Body: data})
Expand Down
2 changes: 1 addition & 1 deletion cmd/hoard/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func Config(cmd *cli.Cmd) {
fatalf("Error fetching default config for %v: %v", arg, err)
}
conf.Storage = store
conf.ChunkSize = *chunkSizeOpt
conf.ChunkSize = int64(*chunkSizeOpt)
if len(*secretsOpt) > 0 {
conf.Secrets = &config.Secrets{
Symmetric: make([]*config.SymmetricSecret, len(*secretsOpt)),
Expand Down
4 changes: 2 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ var DefaultHoardConfig = NewHoardConfig(DefaultListenAddress, DefaultChunkSize,
type HoardConfig struct {
ListenAddress string
// Chunk size for data upload / download
ChunkSize int
ChunkSize int64
Storage *Storage
Logging *Logging
Secrets *Secrets
}

func NewHoardConfig(listenAddress string, chunkSize int, storageConfig *Storage, loggingConfig *Logging) *HoardConfig {
func NewHoardConfig(listenAddress string, chunkSize int64, storageConfig *Storage, loggingConfig *Logging) *HoardConfig {
return &HoardConfig{
ListenAddress: listenAddress,
ChunkSize: chunkSize,
Expand Down
12 changes: 10 additions & 2 deletions grant/grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import (
"github.com/monax/hoard/v8/reference"
)

const defaultGrantVersion = 2
// We use the Grant version as a kind of global version for the core protobuf types.
// The version is a simple counter and does not 'encode' a breaking change
// (the intention is by using the version number we support all non deprecated previous versions, mearning no change is 'breaking')
// Version history:
// 0: deprecated and removed
// 1: deprecated and removed
// 2: encrypted references array for streaming, non-derived keys, reference with version
// 3: reference Version -> Type, introduce LINK references, store plaintext data Size in reference
const LatestGrantVersion = 3

// Seal this reference into a Grant as specified by Spec
func Seal(secret config.SecretsManager, refs reference.Refs, spec *Spec) (*Grant, error) {
grt := &Grant{Spec: spec, Version: defaultGrantVersion}
grt := &Grant{Spec: spec, Version: LatestGrantVersion}

if s := spec.GetPlaintext(); s != nil {
grt.EncryptedReferences = PlaintextGrant(refs)
Expand Down
2 changes: 1 addition & 1 deletion grant/grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func testReferences() reference.Refs {
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
}
return reference.Refs{reference.New(address, secretKey, nil)}
return reference.Refs{reference.New(address, secretKey, nil, 1024)}
}

func deriveSecret(t *testing.T, data []byte) []byte {
Expand Down
4 changes: 2 additions & 2 deletions hoard.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (hrd *Hoard) Put(data, salt []byte) (*reference.Ref, error) {
if err != nil {
return nil, err
}
return reference.New(address, blob.SecretKey, salt), nil
return reference.New(address, blob.SecretKey, salt, int64(len(data))), nil
}

func (hrd *Hoard) Delete(address []byte) error {
Expand All @@ -113,7 +113,7 @@ func (hrd *Hoard) Encrypt(data, salt []byte) (*reference.Ref, []byte, error) {
return nil, nil, err
}
address := hrd.store.Address(blob.EncryptedData)
return reference.New(address, blob.SecretKey, salt), blob.EncryptedData, nil
return reference.New(address, blob.SecretKey, salt, int64(len(data))), blob.EncryptedData, nil
}

// Decrypt data using reference
Expand Down
2 changes: 1 addition & 1 deletion hoard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestDeterministicEncryptedStore(t *testing.T) {
bunsOut, err := hrd.Get(ref)
assert.Equal(t, bunsIn, bunsOut)

_, err = hrd.Get(reference.New(ref.Address, pad("wrong secret", 32), nil))
_, err = hrd.Get(reference.New(ref.Address, pad("wrong secret", 32), nil, 1024))
assert.Error(t, err)

statInfo, err := hrd.Store().Stat(ref.Address)
Expand Down
68 changes: 34 additions & 34 deletions js/proto/api_grpc_pb.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,32 @@ import * as reference_pb from "./reference_pb";
import * as stores_pb from "./stores_pb";

interface IGrantService extends grpc.ServiceDefinition<grpc.UntypedServiceImplementation> {
putSeal: IGrantService_IPutSeal;
unsealGet: IGrantService_IUnsealGet;
seal: IGrantService_ISeal;
unseal: IGrantService_IUnseal;
reseal: IGrantService_IReseal;
putSeal: IGrantService_IPutSeal;
unsealGet: IGrantService_IUnsealGet;
unsealDelete: IGrantService_IUnsealDelete;
}

interface IGrantService_IPutSeal extends grpc.MethodDefinition<api_pb.PlaintextAndGrantSpec, grant_pb.Grant> {
path: "/api.Grant/PutSeal";
requestStream: true;
responseStream: false;
requestSerialize: grpc.serialize<api_pb.PlaintextAndGrantSpec>;
requestDeserialize: grpc.deserialize<api_pb.PlaintextAndGrantSpec>;
responseSerialize: grpc.serialize<grant_pb.Grant>;
responseDeserialize: grpc.deserialize<grant_pb.Grant>;
}
interface IGrantService_IUnsealGet extends grpc.MethodDefinition<grant_pb.Grant, api_pb.Plaintext> {
path: "/api.Grant/UnsealGet";
requestStream: false;
responseStream: true;
requestSerialize: grpc.serialize<grant_pb.Grant>;
requestDeserialize: grpc.deserialize<grant_pb.Grant>;
responseSerialize: grpc.serialize<api_pb.Plaintext>;
responseDeserialize: grpc.deserialize<api_pb.Plaintext>;
}
interface IGrantService_ISeal extends grpc.MethodDefinition<api_pb.ReferenceAndGrantSpec, grant_pb.Grant> {
path: "/api.Grant/Seal";
requestStream: true;
Expand Down Expand Up @@ -47,24 +65,6 @@ interface IGrantService_IReseal extends grpc.MethodDefinition<api_pb.GrantAndGra
responseSerialize: grpc.serialize<grant_pb.Grant>;
responseDeserialize: grpc.deserialize<grant_pb.Grant>;
}
interface IGrantService_IPutSeal extends grpc.MethodDefinition<api_pb.PlaintextAndGrantSpec, grant_pb.Grant> {
path: "/api.Grant/PutSeal";
requestStream: true;
responseStream: false;
requestSerialize: grpc.serialize<api_pb.PlaintextAndGrantSpec>;
requestDeserialize: grpc.deserialize<api_pb.PlaintextAndGrantSpec>;
responseSerialize: grpc.serialize<grant_pb.Grant>;
responseDeserialize: grpc.deserialize<grant_pb.Grant>;
}
interface IGrantService_IUnsealGet extends grpc.MethodDefinition<grant_pb.Grant, api_pb.Plaintext> {
path: "/api.Grant/UnsealGet";
requestStream: false;
responseStream: true;
requestSerialize: grpc.serialize<grant_pb.Grant>;
requestDeserialize: grpc.deserialize<grant_pb.Grant>;
responseSerialize: grpc.serialize<api_pb.Plaintext>;
responseDeserialize: grpc.deserialize<api_pb.Plaintext>;
}
interface IGrantService_IUnsealDelete extends grpc.MethodDefinition<grant_pb.Grant, api_pb.Address> {
path: "/api.Grant/UnsealDelete";
requestStream: false;
Expand All @@ -78,15 +78,21 @@ interface IGrantService_IUnsealDelete extends grpc.MethodDefinition<grant_pb.Gra
export const GrantService: IGrantService;

export interface IGrantServer {
putSeal: handleClientStreamingCall<api_pb.PlaintextAndGrantSpec, grant_pb.Grant>;
unsealGet: grpc.handleServerStreamingCall<grant_pb.Grant, api_pb.Plaintext>;
seal: handleClientStreamingCall<api_pb.ReferenceAndGrantSpec, grant_pb.Grant>;
unseal: grpc.handleServerStreamingCall<grant_pb.Grant, reference_pb.Ref>;
reseal: grpc.handleUnaryCall<api_pb.GrantAndGrantSpec, grant_pb.Grant>;
putSeal: handleClientStreamingCall<api_pb.PlaintextAndGrantSpec, grant_pb.Grant>;
unsealGet: grpc.handleServerStreamingCall<grant_pb.Grant, api_pb.Plaintext>;
unsealDelete: grpc.handleServerStreamingCall<grant_pb.Grant, api_pb.Address>;
}

export interface IGrantClient {
putSeal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
unsealGet(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
unsealGet(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
seal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
seal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
seal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
Expand All @@ -96,18 +102,18 @@ export interface IGrantClient {
reseal(request: api_pb.GrantAndGrantSpec, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
reseal(request: api_pb.GrantAndGrantSpec, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
reseal(request: api_pb.GrantAndGrantSpec, metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
putSeal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
putSeal(metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
unsealGet(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
unsealGet(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
unsealDelete(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Address>;
unsealDelete(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Address>;
}

export class GrantClient extends grpc.Client implements IGrantClient {
constructor(address: string, credentials: grpc.ChannelCredentials, options?: Partial<grpc.ClientOptions>);
public putSeal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public unsealGet(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
public unsealGet(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
public seal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
public seal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
public seal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.ReferenceAndGrantSpec>;
Expand All @@ -117,12 +123,6 @@ export class GrantClient extends grpc.Client implements IGrantClient {
public reseal(request: api_pb.GrantAndGrantSpec, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
public reseal(request: api_pb.GrantAndGrantSpec, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
public reseal(request: api_pb.GrantAndGrantSpec, metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientUnaryCall;
public putSeal(callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public putSeal(metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: grant_pb.Grant) => void): grpc.ClientWritableStream<api_pb.PlaintextAndGrantSpec>;
public unsealGet(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
public unsealGet(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Plaintext>;
public unsealDelete(request: grant_pb.Grant, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Address>;
public unsealDelete(request: grant_pb.Grant, metadata?: grpc.Metadata, options?: Partial<grpc.CallOptions>): grpc.ClientReadableStream<api_pb.Address>;
}
Expand Down
Loading

0 comments on commit 54d27c2

Please sign in to comment.