aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpukkamustard <pukkamustard@posteo.net>2020-11-28 12:01:10 +0100
committerpukkamustard <pukkamustard@posteo.net>2020-11-28 15:55:06 +0100
commit7f69176d585d7702ca3a7e5a67f2920a52906356 (patch)
tree9099b0dbc6be4304adbec3420033a53e391cec1e
parentab0a4eafa62c9ceafd55c06d344952cc7588b874 (diff)
examples/ipfs.org: A demo on how to use IPFS with ERIS
-rw-r--r--README.org16
-rw-r--r--doc/eris.adoc2
-rw-r--r--eris.scm10
-rw-r--r--eris/read-capability.scm10
-rw-r--r--examples/ipfs.org519
-rw-r--r--examples/ipfs.scm142
-rw-r--r--public/index.html64
7 files changed, 580 insertions, 183 deletions
diff --git a/README.org b/README.org
index af3625e..9e996ec 100644
--- a/README.org
+++ b/README.org
@@ -8,9 +8,9 @@ ERIS is an encoding for arbitrary content into uniformly sized encrypted blocks
This repository contains a [[./doc/eris.adoc][specification]] and a reference Guile implementation.
-This work has been done as part of the [[https://openengiadina.net][openEngiadina]] project see the [[https://wiki.openengiadina.net/data_model_and_data_storage.html][notes]] in the wiki for more background.
+ERIS is considered to be experimental see the [[./ROADMAP][ROADMAP]].
-This is considered to be experimental see the [[./ROADMAP][ROADMAP]].
+This work has been done as part of the [[https://openengiadina.net][openEngiadina]] project and has been supported by the [[https:/nlnet][NLNet Foundation]] trough the [[https://nlnet.nl/discovery/][NGI0 Discovery Fund]].
* Demo
@@ -18,6 +18,8 @@ ERIS describes a scheme for splitting up content into uniformly sized encrypted
ERIS does not define storage and transport layer. It relies on a block storage that allows block to be stored and referenced via the hash code of the block itself (content-addressing).
+See the [[./examples/ipfs.org][IPFS example]] for how IPFS can be used as storage and transport layer.
+
** Encoding
To encode some content with ERIS we use ~eris-encode~:
@@ -115,7 +117,7 @@ By default ~eris-encode~ returns an alist of references and blocks. Under the ho
The string "Hello world!" is encoded in a single block.
-See the [[./examples/ipfs.scm][IPFS example]] for how blocks can be reduced to IPFS (stored in IPFS).
+See the [[./examples/ipfs.org][IPFS example]] for more details and on how IPFS can be used as a storage/transport layer.
** Block size and convergence secret
@@ -129,8 +131,8 @@ The functions in the module ~(eris)~ (~eris-encode~, ~eris-encode->urn~ and ~eri
This lower level interface provides an SRFI-171 transducer for encoding content from any stream of data (e.g. a network socket).
-* TODO ERIS Cache
-
-ERIS Cache is an RDF vocabulary for describing ERIS encoded caches of content using the [[https://www.w3.org/TR/prov-o/][PROV]] ontology.
+* License
-See the bare-bones [[./doc/eris-cache.org][write-up]] for more information.
+- [[./LICENSES/GPL-3.0-or-later.txt][GPL-3.0-or-later]] :: Guile Implementation
+- [[./LICENSES/CC-BY-SA-4.0.txt][CC-BY-SA-4.0]] :: Specification
+- [[./LICENSES/CC0-1.0.txt][CC0-1.0]] :: Test vectors
diff --git a/doc/eris.adoc b/doc/eris.adoc
index c754487..6c1ef4a 100644
--- a/doc/eris.adoc
+++ b/doc/eris.adoc
@@ -379,7 +379,7 @@ Transport mechanisms include:
- HTTP: A simple HTTP endpoint can be used to dereference blocks
- Sneakernet: Blocks can be transported on a physical medium such as a USB stick
-More interesting transport and storage layers use the fact that blocks are content-addressed. For example the peer-to-peer network https://ipfs.io/[IPFS] can be used to store and transport blocks (see the https://gitlab.com/openengiadina/eris/-/blob/main/examples/ipfs.scm[example] using the reference Guile implementation). The major advantages over using IPFS directly include:
+More interesting transport and storage layers use the fact that blocks are content-addressed. For example the peer-to-peer network https://ipfs.io/[IPFS] can be used to store and transport blocks (see the https://gitlab.com/openengiadina/eris/-/blob/main/examples/ipfs.org[example] using the reference Guile implementation). The major advantages over using IPFS directly include:
- Content is encrypted and not readable to IPFS peers without the read capability.
- Identifier of blocks and encoded content is not tied to the IPFS network. Applications can transparently use IPFS or any other storage/transport mechanism.
diff --git a/eris.scm b/eris.scm
index e3b1fcd..49e8d0c 100644
--- a/eris.scm
+++ b/eris.scm
@@ -38,7 +38,10 @@ single pair consisting of the reduced blocks and the read capability"
(() (reducer))
;; completion - cons the blocks and read-capability
- ((result) (reducer (reducer result (cons read-capability blocks))))
+ ((result)
+ (reducer (reducer result (cons read-capability
+ ;; call the completion arity on the block reducer
+ (block-reducer blocks)))))
;; on input
((result input)
@@ -102,9 +105,10 @@ single pair consisting of the reduced blocks and the read capability"
(eris-decode->bytevector (read-capability->string read-capability)
(lambda (ref) (assoc-ref blocks ref))))
-(define (eris-decode->bytevector urn block-ref)
+(define (eris-decode->bytevector read-capability block-ref)
+ "Decode ERIS encoded content into a bytevector."
(eris-transduce
(tmap identity)
(rbytevector)
#:block-ref block-ref
- #:read-capability (string->read-capability urn)))
+ #:read-capability (->read-capability read-capability)))
diff --git a/eris/read-capability.scm b/eris/read-capability.scm
index 2a8d708..7a2199c 100644
--- a/eris/read-capability.scm
+++ b/eris/read-capability.scm
@@ -26,7 +26,9 @@
bytevector->read-capability
read-capability->string
- string->read-capability))
+ string->read-capability
+
+ ->read-capability))
(define-record-type <read-capability>
(make-read-capability block-size level root-reference root-key)
@@ -82,3 +84,9 @@
(bytevector->read-capability
(base32-decode (string-drop urn 11)))))
+(define (->read-capability encoded)
+ "Attempt to decode read capability from string or bytevector."
+ (cond
+ ((read-capability? encoded) encoded)
+ ((string? encoded) (string->read-capability encoded))
+ ((bytevector? encoded) (bytevector->read-capability encoded))))
diff --git a/examples/ipfs.org b/examples/ipfs.org
new file mode 100644
index 0000000..d195b7d
--- /dev/null
+++ b/examples/ipfs.org
@@ -0,0 +1,519 @@
+#+TITLE: IPFS as Storage and Transport Layer
+#+PROPERTY: header-args:scheme :session *eris-repl* :eval never-export
+
+This is a short demo on how IPFS can be used as Storage and Transport Layer for ERIS encoded content.
+
+* Overview
+
+We will use the [[https://docs.ipfs.io/reference/http/api/][IPFS HTTP API]] to store blocks of ERIS encoded content.
+
+Usually when adding a file or directory to IPFS it is encoded into uniformly sized blocks (with additional metadata) using the [[https://docs.ipfs.io/concepts/file-systems/#unix-file-system-unixfs][IPFS UnixFS]] encoding. The individual blocks are then stored on the IPFS storage and made available via peer-to-peer networking.
+
+Access to the individual blocks is possible with the two API endpoints [[https://docs.ipfs.io/reference/http/api/#api-v0-block-put][/api/v0/block/put]] and [[https://docs.ipfs.io/reference/http/api/#api-v0-block-get][/api/v0/block/get]]. These endpoints give access to /raw/ content-addressed blocks without any metadata. This is where we will plug in and store blocks of ERIS encoded content.
+
+** Babel
+
+This is demo can be re-run using Emacs [[https://orgmode.org/worg/org-contrib/babel/intro.html][org-mode babel]]. You should start the IPFS daemon with ~ipfs daemon~.
+
+* IPFS ceremony
+
+A little bit of ceremony is required to interact with IPFS (dealing with CIDs and the HTTP API).
+
+** CID
+
+[[https://github.com/multiformats/cid][CID]] is the IPFS format for references to content. CIDs are self-describing and encode the hash used. This has the disadvantage that a single piece of content has multiple (many!) identifiers. But we use it to our advantage: Blake2b-256 as required for ERIS is also supported.
+
+
+We need to be able to encode and decode raw Blake2b-256 hashes to CIDs.
+
+We use the [[https://github.com/multiformats/multicodec/blob/master/table.csv][multicodec content-type]] 0x55 for raw data:
+
+#+BEGIN_SRC scheme
+(define multicodec-raw-code #x55)
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+The multicodec code for Blake2b-256 is 0xb220:
+
+#+BEGIN_SRC scheme
+(define multicodec-blake2b-256-code #xb220)
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+
+*** Encoding
+
+See [[https://github.com/multiformats/cid#how-does-it-work][the CID specification]].
+
+First we encode a binary cid:
+
+#+BEGIN_SRC scheme
+(use-modules (rnrs io ports)
+ (rnrs bytevectors))
+
+(define (blake2b-256->binary-cid hash)
+ (call-with-values
+ (lambda () (open-bytevector-output-port))
+ (lambda (port get-bytevector)
+ ;; CID version
+ (put-u8 port 1)
+ ;; multicoded content-type
+ (put-u8 port multicodec-raw-code)
+ ;; set multihash to blake2b-256. This is the manually encoded varint of 0xb220
+ (put-u8 port 160) (put-u8 port 228) (put-u8 port 2)
+ ;; set hash lenght
+ (put-u8 port 32)
+ ;; and finally the hash itself
+ (put-bytevector port hash)
+
+ ;; finalize and get the bytevector
+ (get-bytevector))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+We use base32 for encoding the binary cid as string:
+
+#+BEGIN_SRC scheme
+(use-modules (eris utils base32))
+
+(define (binary-cid->cid bcid)
+ ;; 'b' is the multibsae code for base32
+ (string-append "b"
+ ;; the IPFS daemon uses lower-case, so to be consistent we also.
+ (string-downcase
+ ;; base32 encode the binary cid
+ (base32-encode bcid))))
+
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+And to make it quick, a little shortcut:
+
+#+BEGIN_SRC scheme
+(define blake2b-256->cid
+ (compose binary-cid->cid blake2b-256->binary-cid))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+Let's try encoding a simple hash:
+
+#+BEGIN_SRC scheme :exports both
+(use-modules (sodium generichash))
+
+(blake2b-256->cid
+ (crypto-generichash (string->utf8 "Hello world!")))
+#+END_SRC
+
+#+RESULTS:
+: bafk2bzacea73ycjnxe2qov7cvnhx52lzfp6nf5jcblnfus6gqreh6ygganbws
+
+We can check this with the [[https://cid.ipfs.io/#bafk2bzacea73ycjnxe2qov7cvnhx52lzfp6nf5jcblnfus6gqreh6ygganbws][CID inspector tool]]. Looks good!
+
+*** Decoding
+
+Decoding is a bit trickier as we need to decode [[https://github.com/multiformats/unsigned-varint][unsigned-varint]]. Fortunately, for storing blocks on IPFS we don't need to do this. This section is completely unnecessary. Feel free to skip to the next section.
+
+#+BEGIN_SRC scheme
+;; SRFI-60 Integers as Bits
+(use-modules (srfi srfi-60))
+
+(define (get-varint port)
+ "Get a unsigned varint from the port (see https://github.com/multiformats/unsigned-varint)."
+ (let loop ((byte (get-u8 port))
+ (i 0)
+ (var 0))
+ (let ((new-var (bitwise-ior
+ var
+ (arithmetic-shift (bit-field byte 0 7)
+ (* 7 i)))))
+ (if (bit-set? 7 byte)
+ (loop (get-u8 port) (1+ i) new-var)
+ new-var))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+To extract the blake2b-256 hash from a binary CID:
+
+#+BEGIN_SRC scheme
+(define (binary-cid->blake2b-256 cid)
+ "Extract blake2b-256 hash code from a CIDv1"
+ (let ((cid-port (open-bytevector-input-port cid)))
+ ;; require CIDv1
+ (when (eqv? (get-varint cid-port) 1)
+ ;; require multicodec to be raw
+ (when (eqv? (get-varint cid-port) multicodec-raw-code)
+ ;; require multihash to be blake2b-256
+ (when (eqv? (get-varint cid-port) multicodec-blake2b-256-code)
+ ;; require the digest-lenght to be 32
+ (when (eqv? (get-varint cid-port) 32)
+ (get-bytevector-n cid-port 32)))))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+Note that this is a hacky thing that only decodes CIDs of blake2b-256 hash codes. A more general CID decoder would be nicer.
+
+One more little step to decode a string CID to a binary CID:
+
+#+BEGIN_SRC scheme
+(define (cid->binary-cid cid)
+ (when (equal? (string-take cid 1) "b")
+ (base32-decode
+ (string-upcase
+ (string-drop cid 1)))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+And a little shortcut:
+
+#+BEGIN_SRC scheme
+(define cid->blake2b-256
+ (compose binary-cid->blake2b-256 cid->binary-cid))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+A quick check:
+
+#+BEGIN_SRC scheme :exports both
+(bytevector=?
+ (cid->blake2b-256
+ (blake2b-256->cid
+ (crypto-generichash (string->utf8 "Hello world!"))))
+ (crypto-generichash (string->utf8 "Hello world!")))
+#+END_SRC
+
+#+RESULTS:
+: #t
+
+Woo-hoo! Wasn't that some fun procrastination? But now, back to storing block on IPFS.
+
+** Storing blocks
+
+A bit of HTTP magic to encode a multipart body:
+
+#+BEGIN_SRC scheme
+(define %multipart-boundary
+ "woobledubledooodidubabidubdipubspoop")
+
+(define (encode-multipart-body bv)
+ (call-with-values
+ (lambda () (open-bytevector-output-port))
+ (lambda (port get-bytevector)
+ (put-string port
+ (string-append
+ "--" %multipart-boundary "\n"))
+ (put-string port
+ "Content-Type: text/plain\nContent-Disposition: form-data; name=data\n\n")
+ (put-bytevector port bv)
+ (put-string port
+ (string-append "\n--" %multipart-boundary "--\n"))
+ (get-bytevector))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+Using this we can create a function that makes a request to the IPFS daemon to store a block:
+
+#+BEGIN_SRC scheme
+(use-modules (ice-9 receive)
+ (web client)
+ (web response)
+ (json))
+
+(define (ipfs-block-put bv)
+ "Store a block on IPFS and return the CID of the block"
+ (receive (response body)
+ (http-post
+ (string-append "http://localhost:5001/api/v0/block/put"
+ "?format=raw&mhtype=blake2b-256")
+ #:headers `((Content-Type . ,(string-append
+ "multipart/form-data; boundary=\""
+ %multipart-boundary "\"")))
+
+ #:decode-body? #f
+ #:streaming? #t
+ #:body (encode-multipart-body bv))
+ (when (eqv? (response-code response) 200)
+ (assoc-ref (json->scm body) "Key"))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+Let's try it out:
+
+#+BEGIN_SRC scheme :exports both
+(ipfs-block-put (string->utf8 "Hello world!"))
+#+END_SRC
+
+#+RESULTS:
+: bafk2bzacea73ycjnxe2qov7cvnhx52lzfp6nf5jcblnfus6gqreh6ygganbws
+
+Looks good! Note that this is the same CID as we computed above with ~blake2b-256->cid~.
+
+** Getting blocks
+
+To get a block from IPFS we use the ~/api/v0/block/get~ endpoint:
+
+#+BEGIN_SRC scheme
+(define (ipfs-block-get cid)
+ "Get a block from IPFS via the HTTP API"
+ (receive (response body)
+ (http-post
+ (string-append "http://localhost:5001/api/v0/block/get"
+ "?arg=" cid)
+ #:decode-body? #f)
+ body))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+#+BEGIN_SRC scheme :exports both
+(utf8->string
+ (ipfs-block-get "bafk2bzacea73ycjnxe2qov7cvnhx52lzfp6nf5jcblnfus6gqreh6ygganbws"))
+#+END_SRC
+
+#+RESULTS:
+: Hello world!
+
+We are ready to hook up the ERIS encoding to the IPFS machinery.
+
+* ERIS over IPFS
+
+Before we encode and decode, a quick dive into how the ~guile-eris~ API can be linked with IPFS.
+
+** Reducer
+
+To store the blocks we need a reducer. A reducer is the same thing one supplies to ~fold~ when processing a list. For example a simple reducer that computes the sum of elements in a list:
+
+#+BEGIN_SRC scheme :exports both
+(define (sum-reducer element total)
+ (+ element total))
+
+(use-modules (srfi srfi-1))
+
+(fold sum-reducer 0 '(1 2 3))
+#+END_SRC
+
+#+RESULTS:
+: 6
+
+We need a special kind of reducer, a SRFI-171 reducer. The only thing that changes is that the reducer is a 3-ary procedure which produces the identity when called with 0 arguments (for ~fold~ we manually supplied the identity - ~0~), can do some post-processing to the reduced value when called with one argument and reduces an input to the result so far when called with two arguments.
+
+The additional arities are useful for setting up a reducer and cleaning up after finishing. For example when called with zero arguments the reducer might open up a connection to a database and return a connection handle. When storing elements the reducer is called with the connection handle and the element to store. After all elements are reduced, the reducer is called with just the connection handle. The reducer can then close the database connection gracefully.
+
+Our IPFS block reducer is simpler in the sense that it does not do any connection setup. More efficient implementations would probably set up a single HTTP connection to the IPFS daemon that can be reused.
+
+THE IPFS block reducer looks like this:
+
+#+BEGIN_SRC scheme
+(define ipfs-block-reducer
+ (case-lambda
+
+ ;; initialization. Nothing to do here. In an improved implementation we
+ ;; might create a single HTTP connection and reuse it for all blocks.
+ (() '())
+
+ ;; Completion. Again, nothing to do.
+ ((_) 'done)
+
+ ;; store a block
+ ((_ ref-block)
+ ;; ref-block is a pair consisting of the reference to the block and the block itself.
+ (ipfs-block-put (cdr ref-block)))))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+The ERIS encoder will use the reducer to store blocks as soon as they are ready - before the complete encoding process is finished. This allows content that is larger than the available memory to be encoded (see the section on [[http://purl.org/eris#_streaming][Streaming]] in the specification).
+
+** Encoding
+
+We can tell ~eris-encode~ to use this reducer with the ~#:block-reducer~ keyword argument:
+
+#+BEGIN_SRC scheme :exports both
+(use-modules (eris))
+
+(define read-capability
+ (receive (read-capability blocks)
+ (eris-encode "Hello world!" #:block-reducer ipfs-block-reducer)
+ read-capability))
+
+read-capability
+#+END_SRC
+
+#+RESULTS:
+: #<<read-capability> block-size: 1024 level: 0 root-reference: #vu8(63 254 3 75 10 5 103 7 208 238 26 103 0 126 217 124 236 105 205 75 136 116 101 176 189 63 118 210 40 169 217 105) root-key: #vu8(19 230 50 100 252 2 228 177 6 63 67 102 128 217 107 51 19 246 52 165 17 175 247 177 68 0 59 165 100 99 44 219)>
+
+This returns the raw /read capability/ which is required to decode the content.
+
+The read capability can also be encoded as an URN:
+
+#+BEGIN_SRC scheme :exports both
+(use-modules (eris read-capability))
+
+(read-capability->string read-capability)
+#+END_SRC
+
+#+RESULTS:
+: urn:erisx2:AAAD77QDJMFAKZYH2DXBUZYAP3MXZ3DJZVFYQ5DFWC6T65WSFCU5S2IT4YZGJ7AC4SYQMP2DM2ANS2ZTCP3DJJIRV733CRAAHOSWIYZM3M
+
+Note that we do not care about the second returned value (~blocks~) as the ~ipfs-block-reducer~ does not return anything interesting.
+
+** Decoding
+
+To decode using IPFS as block storage we need to specify a function that can dereference blocks from the Blake2b-256 hash code. As IPFS speaks CID we need to translate from raw hash code to CID:
+
+#+BEGIN_SRC scheme
+(define (ipfs-block-ref ref)
+ (ipfs-block-get (blake2b-256->cid ref)))
+#+END_SRC
+
+#+RESULTS:
+: #<unspecified>
+
+#+BEGIN_SRC scheme :exports both
+(utf8->string
+ (eris-decode->bytevector
+ "urn:erisx2:AAAD77QDJMFAKZYH2DXBUZYAP3MXZ3DJZVFYQ5DFWC6T65WSFCU5S2IT4YZGJ7AC4SYQMP2DM2ANS2ZTCP3DJJIRV733CRAAHOSWIYZM3M"
+ ipfs-block-ref))
+
+#+END_SRC
+
+#+RESULTS:
+: Hello world!
+
+Hello indeed!
+
+Note that this snippet can be run on any machine running an IPFS daemon and it should be able to decode the content (if the block is still made available by some reachable IPFS node in the network). IPFS takes care of transporting the blocks over network.
+
+** Inspecting blocks
+
+The short string encoded above is encoded in a single block (see the [[http://purl.org/eris][specification]] for details on the encoding).
+
+We can directly get this block from IPFS:
+
+#+BEGIN_SRC scheme :exports both
+(define block
+ (ipfs-block-get
+ (blake2b-256->cid (read-capability-root-reference read-capability))))
+
+(bytevector-length block)
+#+END_SRC
+
+#+RESULTS:
+: 1024
+
+By default ~eris-encode~ encodes content using block size 1 KiB (1024 bytes). ERIS can also use a block size of 32 KiB, which is more efficient when encoding large pieces of content.
+
+The content of the block is encrypted using a key that is encoded in the read-capability. We can manually decode the content as follows:
+
+#+BEGIN_SRC scheme :exports both
+(use-modules (sodium stream)
+ (sodium padding))
+
+(utf8->string
+ (sodium-unpad
+ (crypto-stream-chacha20-ietf-xor block
+ ;; the nonce which is just 12 bytes of zeros
+ (make-bytevector 12 0)
+ ;; the key is encoded in the read-capability
+ (read-capability-root-key read-capability))
+ 1024))
+#+END_SRC
+
+#+RESULTS:
+: Hello world!
+
+For content that does not fit into a single block (larger than 1 KiB) the block referenced in the read capability contains references to further blocks and respective keys to decode the blocks - content is encoded using a tree.
+
+** Larger content
+
+Much larger content can be encoded with ERIS and stored/transported using IPFS.
+
+For example we can encode the first 1 MiB of the ChaCha20 stream:
+
+#+BEGIN_SRC scheme :exports both
+(define larger-content
+ (crypto-stream-chacha20-ietf
+ ;; 1 MiB
+ (* 1024 1024)
+ ;; using a null nonce
+ (make-bytevector 12 0)
+ ;; and a nul key
+ (make-bytevector 32 0)))
+
+(bytevector-length larger-content)
+#+END_SRC
+
+
+#+RESULTS:
+: 1048576
+
+We encode this 1 MiB chunk of data using a block size of 32KiB:
+
+#+BEGIN_SRC scheme :exports both
+(define larger-content-read-capability
+ (receive (read-capability blocks)
+ (eris-encode larger-content
+ #:block-reducer ipfs-block-reducer
+ #:block-size eris-block-size-large)
+ read-capability))
+
+(read-capability->string larger-content-read-capability)
+#+END_SRC
+
+#+RESULTS:
+: urn:erisx2:AEA4LRWEEW73XFM27JFU6NG5FB6OV3TCXCOYMYEHAGYVCDXKI5QJFWINJJD6UMEGXQ4ZX6VQIQDQM2JVDG7FISMECDHVNV6NQKRQ4Y4AHE
+
+This is almost the limit of what are current IPFS bindings can do. For every block we store we open a new HTTP connection (and don't properly close it). The ~large-content~ is encoded with 34 blocks. There is much room to improve the IPFS bindings (e.g. use one single HTTP/2 connection).
+
+Let's verify that we can decode the content as well:
+
+#+BEGIN_SRC scheme :exports both
+(bytevector=?
+ larger-content
+ (eris-decode->bytevector
+ "urn:erisx2:AEA4LRWEEW73XFM27JFU6NG5FB6OV3TCXCOYMYEHAGYVCDXKI5QJFWINJJD6UMEGXQ4ZX6VQIQDQM2JVDG7FISMECDHVNV6NQKRQ4Y4AHE"
+ ipfs-block-ref))
+#+END_SRC
+
+#+RESULTS:
+: #t
+
+Woo-hoo!
+
+* Conclusion
+
+We have seen how the IPFS can be used to store and transport ERIS encoded content by using the IPFS Blocks API from the ~guile-eris~ encoding API
+
+I hope this may serve as a demo of the ~guile-eris~ API and spark your interest in experimenting with ERIS.
+
+Some ideas:
+
+- Use a key-value store as storage layer (e.g. LMDB).
+- Use the [[https://github.com/named-data/NFD][Named Data Networking Forwarding Daemon]] for storage and transport.
+- Encode an archive using ERIS (see [[https://git.ngyro.com/disarchive/][disarchive]])
+- Distribute Guix substitutes using ERIS and IPFS (see [[https://issues.guix.gnu.org/33899][Distributing substitutes over IPFS]])
+
+Happy hacking!
diff --git a/examples/ipfs.scm b/examples/ipfs.scm
deleted file mode 100644
index b2caacd..0000000
--- a/examples/ipfs.scm
+++ /dev/null
@@ -1,142 +0,0 @@
-; SPDX-FileCopyrightText: 2020 pukkamustard <pukkamustard@posteo.net>
-;
-; SPDX-License-Identifier: GPL-3.0-or-later
-
-(define-module (eris block-storage ipfs)
- #:use-module (web client)
- #:use-module (web response)
- #:use-module (web uri)
- #:use-module (eris cas)
- #:use-module (json)
- #:use-module (base32)
- #:use-module (srfi srfi-8)
- #:use-module (srfi srfi-60)
- #:use-module (srfi srfi-158)
- #:use-module (rnrs bytevectors)
- #:use-module (rnrs io ports)
- #:export (ipfs-block-get
- ipfs-block-put
- make-ipfs-cas))
-
-;; Use IPFS as a content-addressable storage for blocks
-;;
-;; IPFS supports blake2b-256 as hash function and direct access to blocks via the Block API -> It can be used as a content-addressable storage for ERIS.
-;;
-;; Note that this does not use the IPFS object format. Accessing content via the usual IPFS object APIs will not work.
-;;
-
-(define multicodec-raw-code #x55)
-(define multicodec-blake2b-256-code #xb220)
-
-(define (get-varint port)
- "Get a unsigned varint from the port (see https://github.com/multiformats/unsigned-varint)."
- (let loop ((byte (get-u8 port))
- (i 0)
- (var 0))
- (let ((new-var (bitwise-ior
- var
- (arithmetic-shift (bit-field byte 0 7)
- (* 7 i)))))
- (if (bit-set? 7 byte)
- (loop (get-u8 port) (1+ i) new-var)
- new-var))))
-
-;; CID decoding (see https://github.com/multiformats/cid/blob/master/README.md#decoding-algorithm)
-
-(define (cid->binary-cid cid)
- ;; "b" stands for base32 (see https://github.com/multiformats/multibase) - that's all we support
- (when (equal? (string-take cid 1) "b")
- (base32-decode
- (string-upcase
- (string-drop cid 1)))))
-
-(define (cid->hash cid)
- "Extract blake2b-256 hash code from a CIDv1"
- (let ((cid-port (open-bytevector-input-port
- (if (bytevector? cid) cid (cid->binary-cid cid)))))
- ;; require CIDv1
- (when (eqv? (get-varint cid-port) 1)
- ;; require multicodec to be raw
- (when (eqv? (get-varint cid-port) multicodec-raw-code)
- ;; require multihash to be blake2b-256
- (when (eqv? (get-varint cid-port) multicodec-blake2b-256-code)
- ;; require the digest-lenght to be 32
- (when (eqv? (get-varint cid-port) 32)
- (get-bytevector-n cid-port 32)))))))
-
-(define (hash->cid hash)
- "Encode blake2b-256 hash code as CIDv1"
- (string-append "b"
- (string-downcase
- (base32-encode
- (let ((bv-accumulator (bytevector-accumulator)))
- ;; set CID version
- (bv-accumulator 1)
- ;; set mutlicodec format to raw
- (bv-accumulator multicodec-raw-code)
- ;; set multihash to blake2b-256
- (bv-accumulator 160) (bv-accumulator 228) (bv-accumulator 2) ;; this is the manual varint encoding of 0xb220
- ;; set hash lenght
- (bv-accumulator 32)
- ;; put the hash code
- (bv-accumulator hash)
- ;; finalize and return bytevector
- (bv-accumulator (eof-object)))))))
-
-(define (ipfs-block-get cid api-host)
- "Get a block from IPFS via the HTTP API"
- (receive (response body)
- (http-get
- (string-append api-host
- (encode-and-join-uri-path '("api" "v0" "block" "get"))
- "?arg=" cid)
- #:decode-body? #f)
- body))
-
-(define %multipart-boundary
- "woobledubledooodidubabidubdipubspoop")
-
-(define (encode-multipart-body bv)
- (let ((body (bytevector-accumulator)))
-
- (body (string->utf8
- (string-append
- "--" %multipart-boundary "\n")))
- (body (string->utf8
- "Content-Type: text/plain\nContent-Disposition: form-data; name=data\n\n"))
-
- (body bv)
-
- (body (string->utf8
- (string-append
- "\n--" %multipart-boundary "--\n")))
-
- ;; finalize and return body as bytevector
- (body (eof-object))))
-
-(define (ipfs-block-put bv api-host)
- "Store input bv as a block in IPFS"
- (receive (response body)
- (http-put
- (string-append api-host
- (encode-and-join-uri-path '("api" "v0" "block" "put"))
- "?format=raw&mhtype=blake2b-256")
- #:headers `((Content-Type .
- ,(string-append
- "multipart/form-data; boundary=\""
- %multipart-boundary "\"")))
-
- #:decode-body? #f
- #:streaming? #t
- #:body (encode-multipart-body bv))
- (when (eqv? (response-code response) 200)
- (assoc-ref (json->scm body) "Key"))))
-
-(define* (make-ipfs-cas #:key (http-api "http://localhost:5001/"))
- "Use IPFS as a content-addressable storage."
- (make-cas
- (lambda (bv)
- (cid->hash (ipfs-block-put bv http-api)))
-
- (lambda (key)
- (ipfs-block-get (hash->cid key) http-api))))
diff --git a/public/index.html b/public/index.html
index 49c4c84..04430d2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<meta name="generator" content="Asciidoctor 2.0.10">
+<meta name="generator" content="Asciidoctor 2.0.12">
<meta name="author" content="pukkamustard">
<title>Encoding for Robust Immutable Storage (ERIS)</title>
<style>
@@ -47,7 +47,7 @@ textarea{overflow:auto;vertical-align:top}
table{border-collapse:collapse;border-spacing:0}
*,*::before,*::after{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}
html,body{font-size:100%}
-body{background:#fff;color:rgba(0,0,0,.8);padding:0;margin:0;font-family:"Noto Serif","DejaVu Serif",serif;font-weight:400;font-style:normal;line-height:1;position:relative;cursor:auto;tab-size:4;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}
+body{background:#fff;color:rgba(0,0,0,.8);padding:0;margin:0;font-family:"Noto Serif","DejaVu Serif",serif;font-weight:400;font-style:normal;line-height:1;position:relative;cursor:auto;tab-size:4;word-wrap:anywhere;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}
a:hover{cursor:pointer}
img,object,embed{max-width:100%;height:auto}
object,embed{height:100%}
@@ -62,10 +62,8 @@ img{-ms-interpolation-mode:bicubic}
img,object,svg{display:inline-block;vertical-align:middle}
textarea{height:auto;min-height:50px}
select{width:100%}
-.center{margin-left:auto;margin-right:auto}
-.stretch{width:100%}
.subheader,.admonitionblock td.content>.title,.audioblock>.title,.exampleblock>.title,.imageblock>.title,.listingblock>.title,.literalblock>.title,.stemblock>.title,.openblock>.title,.paragraph>.title,.quoteblock>.title,table.tableblock>.title,.verseblock>.title,.videoblock>.title,.dlist>.title,.olist>.title,.ulist>.title,.qlist>.title,.hdlist>.title{line-height:1.45;color:#7a2518;font-weight:400;margin-top:0;margin-bottom:.25em}
-div,dl,dt,dd,ul,ol,li,h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6,pre,form,p,blockquote,th,td{margin:0;padding:0;direction:ltr}
+div,dl,dt,dd,ul,ol,li,h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6,pre,form,p,blockquote,th,td{margin:0;padding:0}
a{color:#2156a5;text-decoration:underline;line-height:inherit}
a:hover,a:focus{color:#1d4b8f}
a img{border:0}
@@ -105,19 +103,22 @@ h1{font-size:2.75em}
h2{font-size:2.3125em}
h3,#toctitle,.sidebarblock>.content>.title{font-size:1.6875em}
h4{font-size:1.4375em}}
-table{background:#fff;margin-bottom:1.25em;border:solid 1px #dedede}
+table{background:#fff;margin-bottom:1.25em;border:solid 1px #dedede;word-wrap:normal}
table thead,table tfoot{background:#f7f8f7}
table thead tr th,table thead tr td,table tfoot tr th,table tfoot tr td{padding:.5em .625em .625em;font-size:inherit;color:rgba(0,0,0,.8);text-align:left}
table tr th,table tr td{padding:.5625em .625em;font-size:inherit;color:rgba(0,0,0,.8)}
table tr.even,table tr.alt{background:#f8f8f7}
-table thead tr th,table tfoot tr th,table tbody tr td,table tr td,table tfoot tr td{display:table-cell;line-height:1.6}
+table thead tr th,table tfoot tr th,table tbody tr td,table tr td,table tfoot tr td{line-height:1.6}
h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{line-height:1.2;word-spacing:-.05em}
h1 strong,h2 strong,h3 strong,#toctitle strong,.sidebarblock>.content>.title strong,h4 strong,h5 strong,h6 strong{font-weight:400}
+.center{margin-left:auto;margin-right:auto}
+.stretch{width:100%}
.clearfix::before,.clearfix::after,.float-group::before,.float-group::after{content:" ";display:table}
.clearfix::after,.float-group::after{clear:both}
-:not(pre):not([class^=L])>code{font-size:.9375em;font-style:normal!important;letter-spacing:0;padding:.1em .5ex;word-spacing:-.15em;background:#f7f7f8;-webkit-border-radius:4px;border-radius:4px;line-height:1.45;text-rendering:optimizeSpeed;word-wrap:break-word}
-:not(pre)>code.nobreak{word-wrap:normal}
-:not(pre)>code.nowrap{white-space:nowrap}
+:not(pre).nobreak{word-wrap:normal}
+:not(pre).nowrap{white-space:nowrap}
+:not(pre).pre-wrap{white-space:pre-wrap}
+:not(pre):not([class^=L])>code{font-size:.9375em;font-style:normal!important;letter-spacing:0;padding:.1em .5ex;word-spacing:-.15em;background:#f7f7f8;-webkit-border-radius:4px;border-radius:4px;line-height:1.45;text-rendering:optimizeSpeed}
pre{color:rgba(0,0,0,.9);font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;line-height:1.45;text-rendering:optimizeSpeed}
pre code,pre pre{color:inherit;font-size:inherit;line-height:inherit}
pre>code{display:block}
@@ -182,7 +183,7 @@ body.toc2.toc-right{padding-left:0;padding-right:20em}}
#content #toc{border-style:solid;border-width:1px;border-color:#e0e0dc;margin-bottom:1.25em;padding:1.25em;background:#f8f8f7;-webkit-border-radius:4px;border-radius:4px}
#content #toc>:first-child{margin-top:0}
#content #toc>:last-child{margin-bottom:0}
-#footer{max-width:100%;background:rgba(0,0,0,.8);padding:1.25em}
+#footer{max-width:none;background:rgba(0,0,0,.8);padding:1.25em}
#footer-text{color:rgba(255,255,255,.8);line-height:1.44}
#content{margin-bottom:.625em}
.sect1{padding-bottom:.625em}
@@ -205,7 +206,7 @@ table.tableblock #preamble>.sectionbody>[class="paragraph"]:first-of-type p{font
.admonitionblock>table td.icon{text-align:center;width:80px}
.admonitionblock>table td.icon img{max-width:none}
.admonitionblock>table td.icon .title{font-weight:bold;font-family:"Open Sans","DejaVu Sans",sans-serif;text-transform:uppercase}
-.admonitionblock>table td.content{padding-left:1.125em;padding-right:1.25em;border-left:1px solid #dddddf;color:rgba(0,0,0,.6)}
+.admonitionblock>table td.content{padding-left:1.125em;padding-right:1.25em;border-left:1px solid #dddddf;color:rgba(0,0,0,.6);word-wrap:anywhere}
.admonitionblock>table td.content>:last-child>:last-child{margin-bottom:0}
.exampleblock>.content{border-style:solid;border-width:1px;border-color:#e6e6e6;margin-bottom:1.25em;padding:1.25em;background:#fff;-webkit-border-radius:4px;border-radius:4px}
.exampleblock>.content>:first-child{margin-top:0}
@@ -215,7 +216,7 @@ table.tableblock #preamble>.sectionbody>[class="paragraph"]:first-of-type p{font
.sidebarblock>:last-child{margin-bottom:0}
.sidebarblock>.content>.title{color:#7a2518;margin-top:0;text-align:center}
.exampleblock>.content>:last-child>:last-child,.exampleblock>.content .olist>ol>li:last-child>:last-child,.exampleblock>.content .ulist>ul>li:last-child>:last-child,.exampleblock>.content .qlist>ol>li:last-child>:last-child,.sidebarblock>.content>:last-child>:last-child,.sidebarblock>.content .olist>ol>li:last-child>:last-child,.sidebarblock>.content .ulist>ul>li:last-child>:last-child,.sidebarblock>.content .qlist>ol>li:last-child>:last-child{margin-bottom:0}
-.literalblock pre,.listingblock>.content>pre{-webkit-border-radius:4px;border-radius:4px;word-wrap:break-word;overflow-x:auto;padding:1em;font-size:.8125em}
+.literalblock pre,.listingblock>.content>pre{-webkit-border-radius:4px;border-radius:4px;overflow-x:auto;padding:1em;font-size:.8125em}
@media screen and (min-width:768px){.literalblock pre,.listingblock>.content>pre{font-size:.90625em}}
@media screen and (min-width:1280px){.literalblock pre,.listingblock>.content>pre{font-size:1em}}
.literalblock pre,.listingblock>.content>pre:not(.highlight),.listingblock>.content>pre[class="highlight"],.listingblock>.content>pre[class^="highlight "]{background:#f7f7f8}
@@ -261,21 +262,20 @@ pre.pygments .lineno::before{content:"";margin-right:-.125em}
.quoteblock.excerpt,.quoteblock .quoteblock{margin-left:0}
.quoteblock.excerpt blockquote,.quoteblock.excerpt p,.quoteblock .quoteblock blockquote,.quoteblock .quoteblock p{color:inherit;font-size:1.0625rem}
.quoteblock.excerpt .attribution,.quoteblock .quoteblock .attribution{color:inherit;text-align:left;margin-right:0}
-table.tableblock{max-width:100%;border-collapse:separate}
p.tableblock:last-child{margin-bottom:0}
+td.tableblock>.content{margin-bottom:1.25em;word-wrap:anywhere}
td.tableblock>.content>:last-child{margin-bottom:-1.25em}
-td.tableblock>.content>:last-child.sidebarblock{margin-bottom:0}
table.tableblock,th.tableblock,td.tableblock{border:0 solid #dedede}
-table.grid-all>thead>tr>.tableblock,table.grid-all>tbody>tr>.tableblock{border-width:0 1px 1px 0}
-table.grid-all>tfoot>tr>.tableblock{border-width:1px 1px 0 0}
-table.grid-cols>*>tr>.tableblock{border-width:0 1px 0 0}
-table.grid-rows>thead>tr>.tableblock,table.grid-rows>tbody>tr>.tableblock{border-width:0 0 1px}
-table.grid-rows>tfoot>tr>.tableblock{border-width:1px 0 0}
-table.grid-all>*>tr>.tableblock:last-child,table.grid-cols>*>tr>.tableblock:last-child{border-right-width:0}
-table.grid-all>tbody>tr:last-child>.tableblock,table.grid-all>thead:last-child>tr>.tableblock,table.grid-rows>tbody>tr:last-child>.tableblock,table.grid-rows>thead:last-child>tr>.tableblock{border-bottom-width:0}
+table.grid-all>*>tr>*{border-width:1px}
+table.grid-cols>*>tr>*{border-width:0 1px}
+table.grid-rows>*>tr>*{border-width:1px 0}
table.frame-all{border-width:1px}
+table.frame-ends{border-width:1px 0}
table.frame-sides{border-width:0 1px}
-table.frame-topbot,table.frame-ends{border-width:1px 0}
+table.frame-none>colgroup+*>:first-child>*,table.frame-sides>colgroup+*>:first-child>*{border-top-width:0}
+table.frame-none>:last-child>:last-child>*,table.frame-sides>:last-child>:last-child>*{border-bottom-width:0}
+table.frame-none>*>tr>:first-child,table.frame-ends>*>tr>:first-child{border-left-width:0}
+table.frame-none>*>tr>:last-child,table.frame-ends>*>tr>:last-child{border-right-width:0}
table.stripes-all tr,table.stripes-odd tr:nth-of-type(odd),table.stripes-even tr:nth-of-type(even),table.stripes-hover tr:hover{background:#f8f8f7}
th.halign-left,td.halign-left{text-align:left}
th.halign-right,td.halign-right{text-align:right}
@@ -284,7 +284,7 @@ th.valign-top,td.valign-top{vertical-align:top}
th.valign-bottom,td.valign-bottom{vertical-align:bottom}
th.valign-middle,td.valign-middle{vertical-align:middle}
table thead th,table tfoot th{font-weight:bold}
-tbody tr th{display:table-cell;line-height:1.6;background:#f7f8f7}
+tbody tr th{background:#f7f8f7}
tbody tr th,tbody tr th p,tfoot tr th,tfoot tr th p{color:rgba(0,0,0,.8);font-weight:bold}
p.tableblock>code:only-child{background:none;padding:0}
p.tableblock{font-size:1em}
@@ -313,6 +313,7 @@ ol.lowergreek{list-style-type:lower-greek}
.hdlist>table>tbody>tr,.colist>table>tbody>tr{background:none}
td.hdlist1,td.hdlist2{vertical-align:top;padding:0 .625em}
td.hdlist1{font-weight:bold;padding-bottom:1.25em}
+td.hdlist2{word-wrap:anywhere}
.literalblock+.colist,.listingblock+.colist{margin-top:-.5em}
.colist td:not([class]):first-child{padding:.4em .75em 0;line-height:1;vertical-align:top}
.colist td:not([class]):first-child img{max-width:none}
@@ -385,7 +386,7 @@ a span.icon>.fa{cursor:inherit}
.admonitionblock td.icon .icon-warning::before{content:"\f071";color:#bf6900}
.admonitionblock td.icon .icon-caution::before{content:"\f06d";color:#bf3400}
.admonitionblock td.icon .icon-important::before{content:"\f06a";color:#bf0000}
-.conum[data-value]{display:inline-block;color:#fff!important;background:rgba(0,0,0,.8);-webkit-border-radius:100px;border-radius:100px;text-align:center;font-size:.75em;width:1.67em;height:1.67em;line-height:1.67em;font-family:"Open Sans","DejaVu Sans",sans-serif;font-style:normal;font-weight:bold}
+.conum[data-value]{display:inline-block;color:#fff!important;background:rgba(0,0,0,.8);-webkit-border-radius:50%;border-radius:50%;text-align:center;font-size:.75em;width:1.67em;height:1.67em;line-height:1.67em;font-family:"Open Sans","DejaVu Sans",sans-serif;font-style:normal;font-weight:bold}
.conum[data-value] *{color:#fff!important}
.conum[data-value]+b{display:none}
.conum[data-value]::after{content:attr(data-value)}
@@ -412,6 +413,7 @@ thead{display:table-header-group}
svg{max-width:100%}
p,blockquote,dt,td.content{font-size:1em;orphans:3;widows:3}
h2,h3,#toctitle,.sidebarblock>.content>.title{page-break-after:avoid}
+#header,#content,#footnotes,#footer{max-width:none}
#toc,.sidebarblock,.exampleblock>.content{background:none!important}
#toc{border-bottom:1px solid #dddddf!important;padding-bottom:0!important}
body.book #header{text-align:center}
@@ -531,7 +533,7 @@ This document describes the Encoding for Robust Immutable Storage (ERIS). ERIS i
</ul>
</div>
<div class="paragraph">
-<p>ERIS addresses these issues by splitting content into small uniformly sized and encrypted blocks.</p>
+<p>ERIS addresses these issues by splitting content into small uniformly sized and encrypted blocks. These blocks can be reassembled to the original content only with access to a short <em>read capability</em>, which can be encoded as an URN.</p>
</div>
<div class="paragraph">
<p>Encodings similar to ERIS are already widely-used in applications and protocols such as GNUNet (see <a href="#_previous_work">Section 1.3</a>), BitTorrent <a href="#BEP52">[BEP52]</a>, Freenet <a href="#Freenet">[Freenet]</a> and others. However, they all use slightly different encodings that are tied to the respective protocols and applications. ERIS defines an encoding independant of any specific protocol or application and decouples content from transport and storage layers. ERIS may be seen as a modest step towards Information-Centric Networking <a href="#RFC7927">[RFC7927]</a>.</p>
@@ -551,9 +553,13 @@ This document describes the Encoding for Robust Immutable Storage (ERIS). ERIS i
<dd>
<p>Integrity of content can be verified efficiently.</p>
</dd>
+<dt class="hdlist1">Confidentiality </dt>
+<dd>
+<p>Encoded content can only be decoded with access to the read capability. Peers without access to the read capability can cache and transport individiual blocks without being able to read the content.</p>
+</dd>
<dt class="hdlist1">URN reference </dt>
<dd>
-<p>ERIS encoded content can be referrenced with a single URN.</p>
+<p>ERIS encoded content can be referrenced with a single URN (the encoded read capability).</p>
</dd>
<dt class="hdlist1">Storage efficiency </dt>
<dd>
@@ -1522,7 +1528,7 @@ ERIS-Decode(BLOCK-SIZE, LEVEL, ROOT-REFERENCE, ROOT-KEY):
</ul>
</div>
<div class="paragraph">
-<p>More interesting transport and storage layers use the fact that blocks are content-addressed. For example the peer-to-peer network <a href="https://ipfs.io/">IPFS</a> can be used to store and transport blocks (see the <a href="https://gitlab.com/openengiadina/eris/-/blob/main/eris/block-storage/ipfs.scm">example</a> using the reference Guile implementation). The major advantages over using IPFS directly include:</p>
+<p>More interesting transport and storage layers use the fact that blocks are content-addressed. For example the peer-to-peer network <a href="https://ipfs.io/">IPFS</a> can be used to store and transport blocks (see the <a href="https://gitlab.com/openengiadina/eris/-/blob/main/examples/ipfs.org">example</a> using the reference Guile implementation). The major advantages over using IPFS directly include:</p>
</div>
<div class="ulist">
<ul>
@@ -1852,7 +1858,7 @@ ERIS-Decode(BLOCK-SIZE, LEVEL, ROOT-REFERENCE, ROOT-KEY):
</div>
<div id="footer">
<div id="footer-text">
-Last updated 2020-11-26 11:57:17 +0100
+Last updated 2020-11-28 11:55:34 +0100
</div>
</div>
</body>