active-boxes 0.0.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ import abc
2
+ import binascii
3
+ import json
4
+ import os
5
+ import typing
6
+ from typing import Any
7
+ from typing import Dict
8
+ from typing import List
9
+ from typing import Optional
10
+
11
+ import requests
12
+
13
+ from .__version__ import __version__
14
+ from .collection import parse_collection
15
+ from .errors import ActivityGoneError
16
+ from .errors import ActivityNotFoundError
17
+ from .errors import ActivityUnavailableError
18
+ from .errors import NotAnActivityError
19
+ from .urlutils import URLLookupFailedError
20
+ from .urlutils import check_url as check_url
21
+
22
+ if typing.TYPE_CHECKING:
23
+ from active_boxes import activitypub as ap # noqa: type checking
24
+
25
+
26
+ class Backend(abc.ABC):
27
+ def debug_mode(self) -> bool:
28
+ """Should be overidded to return `True` in order to enable the debug mode."""
29
+ return False
30
+
31
+ def check_url(self, url: str) -> None:
32
+ check_url(url, debug=self.debug_mode())
33
+
34
+ def user_agent(self) -> str:
35
+ return (
36
+ f"{requests.utils.default_user_agent()} (Active Boxes/{__version__};"
37
+ " +http://github.com/tsileo/little-boxes)"
38
+ )
39
+
40
+ def random_object_id(self) -> str:
41
+ """Generates a random object ID."""
42
+ return binascii.hexlify(os.urandom(8)).decode("utf-8")
43
+
44
+ def fetch_json(self, url: str, **kwargs):
45
+ self.check_url(url)
46
+ resp = requests.get(
47
+ url,
48
+ headers={"User-Agent": self.user_agent(), "Accept": "application/json"},
49
+ **kwargs,
50
+ timeout=15,
51
+ allow_redirects=True,
52
+ )
53
+
54
+ resp.raise_for_status()
55
+
56
+ return resp
57
+
58
+ def parse_collection(
59
+ self, payload: Optional[Dict[str, Any]] = None, url: Optional[str] = None
60
+ ) -> List[str]:
61
+ return parse_collection(payload=payload, url=url, fetcher=self.fetch_iri)
62
+
63
+ def extra_inboxes(self) -> List[str]:
64
+ """Allows to define inboxes that will be part of of the recipient for every activity."""
65
+ return []
66
+
67
+ def is_from_outbox(
68
+ self, as_actor: "ap.Person", activity: "ap.BaseActivity"
69
+ ) -> bool:
70
+ return activity.get_actor().id == as_actor.id
71
+
72
+ @abc.abstractmethod
73
+ def base_url(self) -> str:
74
+ pass # pragma: no cover
75
+
76
+ def fetch_iri(self, iri: str, **kwargs) -> "ap.ObjectType": # pragma: no cover
77
+ if not iri.startswith("http"):
78
+ raise NotAnActivityError(f"{iri} is not a valid IRI")
79
+
80
+ try:
81
+ self.check_url(iri)
82
+ except URLLookupFailedError:
83
+ # The IRI is inaccessible
84
+ raise ActivityUnavailableError(f"unable to fetch {iri}, url lookup failed")
85
+
86
+ try:
87
+ resp = requests.get(
88
+ iri,
89
+ headers={
90
+ "User-Agent": self.user_agent(),
91
+ "Accept": "application/activity+json",
92
+ },
93
+ timeout=15,
94
+ allow_redirects=False,
95
+ **kwargs,
96
+ )
97
+ except (
98
+ requests.exceptions.ConnectTimeout,
99
+ requests.exceptions.ReadTimeout,
100
+ requests.exceptions.ConnectionError,
101
+ ):
102
+ raise ActivityUnavailableError(f"unable to fetch {iri}, connection error")
103
+ if resp.status_code == 404:
104
+ raise ActivityNotFoundError(f"{iri} is not found")
105
+ elif resp.status_code == 410:
106
+ raise ActivityGoneError(f"{iri} is gone")
107
+ elif resp.status_code in [500, 502, 503]:
108
+ raise ActivityUnavailableError(
109
+ f"unable to fetch {iri}, server error ({resp.status_code})"
110
+ )
111
+
112
+ resp.raise_for_status()
113
+
114
+ try:
115
+ out = resp.json()
116
+ except (json.JSONDecodeError, ValueError):
117
+ # TODO(tsileo): a special error type?
118
+ raise NotAnActivityError(f"{iri} is not JSON")
119
+
120
+ return out
121
+
122
+ @abc.abstractmethod
123
+ def activity_url(self, obj_id: str) -> str:
124
+ pass # pragma: no cover
125
+
126
+ @abc.abstractmethod
127
+ def note_url(self, obj_id: str) -> str:
128
+ pass # pragma: no cover
@@ -0,0 +1,70 @@
1
+ """Collection releated utils."""
2
+ from typing import Any
3
+ from typing import Callable
4
+ from typing import Dict
5
+ from typing import List
6
+ from typing import Optional
7
+
8
+ from .errors import RecursionLimitExceededError
9
+ from .errors import UnexpectedActivityTypeError
10
+
11
+
12
+ def parse_collection( # noqa: C901
13
+ payload: Optional[Dict[str, Any]] = None,
14
+ url: Optional[str] = None,
15
+ level: int = 0,
16
+ fetcher: Optional[Callable[[str], Dict[str, Any]]] = None,
17
+ ) -> List[Any]:
18
+ """Resolve/fetch a `Collection`/`OrderedCollection`."""
19
+ if not fetcher:
20
+ raise Exception("must provide a fetcher")
21
+ if level > 3:
22
+ raise RecursionLimitExceededError("recursion limit exceeded")
23
+
24
+ # Go through all the pages
25
+ out: List[Any] = []
26
+ if url:
27
+ payload = fetcher(url)
28
+ if not payload:
29
+ raise ValueError("must at least prove a payload or an URL")
30
+
31
+ if payload["type"] in ["Collection", "OrderedCollection"]:
32
+ if "orderedItems" in payload:
33
+ return payload["orderedItems"]
34
+ if "items" in payload:
35
+ return payload["items"]
36
+ if "first" in payload:
37
+ if isinstance(payload["first"], str):
38
+ out.extend(
39
+ parse_collection(
40
+ url=payload["first"], level=level + 1, fetcher=fetcher
41
+ )
42
+ )
43
+ else:
44
+ if "orderedItems" in payload["first"]:
45
+ out.extend(payload["first"]["orderedItems"])
46
+ if "items" in payload["first"]:
47
+ out.extend(payload["first"]["items"])
48
+ n = payload["first"].get("next")
49
+ if n:
50
+ out.extend(
51
+ parse_collection(url=n, level=level + 1, fetcher=fetcher)
52
+ )
53
+ return out
54
+
55
+ while payload:
56
+ if payload["type"] in ["CollectionPage", "OrderedCollectionPage"]:
57
+ if "orderedItems" in payload:
58
+ out.extend(payload["orderedItems"])
59
+ if "items" in payload:
60
+ out.extend(payload["items"])
61
+ n = payload.get("next")
62
+ if n is None:
63
+ break
64
+ payload = fetcher(n)
65
+ else:
66
+ raise UnexpectedActivityTypeError(
67
+ "unexpected activity type {}".format(payload["type"])
68
+ )
69
+
70
+ return out
@@ -0,0 +1,69 @@
1
+ from typing import Dict
2
+ from typing import List
3
+ from typing import Tuple
4
+
5
+ from markdown import markdown
6
+
7
+ import regex as re
8
+
9
+ from .activitypub import get_backend
10
+ from .webfinger import get_actor_url
11
+
12
+
13
+ def _set_attrs(attrs, new=False):
14
+ attrs[(None, "target")] = "_blank"
15
+ attrs[(None, "class")] = "external"
16
+ attrs[(None, "rel")] = "noopener"
17
+ attrs[(None, "title")] = attrs[(None, "href")]
18
+ return attrs
19
+
20
+
21
+ HASHTAG_REGEX = re.compile(r"(#[\d\w]+)")
22
+ MENTION_REGEX = re.compile(r"@[\d\w_.+-]+@[\d\w-]+\.[\d\w\-.]+")
23
+
24
+
25
+ def hashtagify(content: str) -> Tuple[str, List[Dict[str, str]]]:
26
+ base_url = get_backend().base_url()
27
+ tags = []
28
+ hashtags = re.findall(HASHTAG_REGEX, content)
29
+ hashtags = list(set(hashtags)) # unique tags
30
+ hashtags.sort()
31
+ hashtags.reverse() # replace longest tag first
32
+ for hashtag in hashtags:
33
+ tag = hashtag[1:]
34
+ link = f'<a href="{base_url}/tags/{tag}" class="mention hashtag" rel="tag">#<span>{tag}</span></a>'
35
+ tags.append(dict(href=f"{base_url}/tags/{tag}", name=hashtag, type="Hashtag"))
36
+ content = content.replace(hashtag, link)
37
+ return content, tags
38
+
39
+
40
+ def mentionify(
41
+ content: str, hide_domain: bool = False
42
+ ) -> Tuple[str, List[Dict[str, str]]]:
43
+ tags = []
44
+ for mention in re.findall(MENTION_REGEX, content):
45
+ _, username, domain = mention.split("@")
46
+ actor_url = get_actor_url(mention)
47
+ if not actor_url:
48
+ # FIXME(tsileo): raise an error?
49
+ continue
50
+ p = get_backend().fetch_iri(actor_url)
51
+ tags.append(dict(type="Mention", href=p["id"], name=mention))
52
+
53
+ d = f"@{domain}"
54
+ if hide_domain:
55
+ d = ""
56
+
57
+ link = f'<span class="h-card"><a href="{p["url"]}" class="u-url mention">@<span>{username}</span>{d}</a></span>'
58
+ content = content.replace(mention, link)
59
+ return content, tags
60
+
61
+
62
+ def parse_markdown(content: str) -> Tuple[str, List[Dict[str, str]]]:
63
+ tags = []
64
+ content, hashtag_tags = hashtagify(content)
65
+ tags.extend(hashtag_tags)
66
+ content, mention_tags = mentionify(content)
67
+ tags.extend(mention_tags)
68
+ content = markdown(content, extensions=["mdx_linkify"])
69
+ return content, tags
active_boxes/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ """Errors raised by this package."""
2
+ from typing import Any
3
+ from typing import Dict
4
+ from typing import Optional
5
+
6
+
7
+ class Error(Exception):
8
+ """Base error for exceptions raised by this package."""
9
+
10
+
11
+ class DropActivityPreProcessError(Error):
12
+ """Raised in `_pre_process_from_inbox` to notify that we don't want to save the message.
13
+
14
+ (like when receiving `Announce` with an OStatus link).
15
+ """
16
+
17
+
18
+ class ServerError(Error):
19
+ """HTTP-friendly base error, with a status code, a message and an optional payload."""
20
+
21
+ status_code = 400
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ status_code: Optional[int] = None,
27
+ payload: Optional[Dict[str, Any]] = None,
28
+ ) -> None:
29
+ Exception.__init__(self)
30
+ self.message = message
31
+ if status_code is not None:
32
+ self.status_code = status_code
33
+ self.payload = payload
34
+
35
+ def to_dict(self) -> Dict[str, Any]:
36
+ rv = dict(self.payload or {})
37
+ rv["message"] = self.message
38
+ return rv
39
+
40
+ def __repr__(self) -> str: # pragma: no cover
41
+ return (
42
+ f"{self.__class__.__qualname__}({self.message!r}, "
43
+ f"payload={self.payload!r}, status_code={self.status_code})"
44
+ )
45
+
46
+ def __str__(self) -> str: # pragma: no cover
47
+ return self.__repr__()
48
+
49
+
50
+ class ActorBlockedError(ServerError):
51
+ """Raised when an activity from a blocked actor is received."""
52
+
53
+
54
+ class NotFromOutboxError(ServerError):
55
+ """Raised when an activity targets an object from the inbox when an object from the oubox was expected."""
56
+
57
+
58
+ class ActivityNotFoundError(ServerError):
59
+ """Raised when an activity is not found."""
60
+
61
+ status_code = 404
62
+
63
+
64
+ class ActivityGoneError(ServerError):
65
+ """Raised when trying to fetch a remote activity that was deleted."""
66
+
67
+ status_code = 410
68
+
69
+
70
+ class BadActivityError(ServerError):
71
+ """Raised when an activity could not be parsed/initialized."""
72
+
73
+
74
+ class RecursionLimitExceededError(BadActivityError):
75
+ """Raised when the recursion limit for fetching remote object was exceeded (likely a collection)."""
76
+
77
+
78
+ class UnexpectedActivityTypeError(BadActivityError):
79
+ """Raised when an another activty was expected."""
80
+
81
+
82
+ class ActivityUnavailableError(ServerError):
83
+ """Raises when fetching a remote activity times out."""
84
+
85
+ status_code = 503
86
+
87
+
88
+ class NotAnActivityError(ServerError):
89
+ """Raised when no JSON can be decoded.
90
+
91
+ Most likely raised when stumbling upon a OStatus notice or failed lookup.
92
+ """
@@ -0,0 +1,145 @@
1
+ """Implements HTTP signature for Flask requests.
2
+
3
+ Mastodon instances won't accept requests that are not signed using this scheme.
4
+
5
+ """
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ from datetime import datetime
10
+ from typing import Any
11
+ from typing import Dict
12
+ from typing import Optional
13
+ from urllib.parse import urlparse
14
+
15
+ from Crypto.Hash import SHA256
16
+ from Crypto.Signature import PKCS1_v1_5
17
+ from requests.auth import AuthBase
18
+
19
+ from .activitypub import get_backend
20
+ from .activitypub import _has_type
21
+ from .errors import ActivityNotFoundError
22
+ from .errors import ActivityGoneError
23
+ from .key import Key
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _build_signed_string(
29
+ signed_headers: str, method: str, path: str, headers: Any, body_digest: str
30
+ ) -> str:
31
+ out = []
32
+ for signed_header in signed_headers.split(" "):
33
+ if signed_header == "(request-target)":
34
+ out.append("(request-target): " + method.lower() + " " + path)
35
+ elif signed_header == "digest":
36
+ out.append("digest: " + body_digest)
37
+ else:
38
+ out.append(signed_header + ": " + headers[signed_header])
39
+ return "\n".join(out)
40
+
41
+
42
+ def _parse_sig_header(val: Optional[str]) -> Optional[Dict[str, str]]:
43
+ if not val:
44
+ return None
45
+ out = {}
46
+ for data in val.split(","):
47
+ k, v = data.split("=", 1)
48
+ out[k] = v[1 : len(v) - 1] # noqa: black conflict
49
+ return out
50
+
51
+
52
+ def _verify_h(signed_string, signature, pubkey):
53
+ signer = PKCS1_v1_5.new(pubkey)
54
+ digest = SHA256.new()
55
+ digest.update(signed_string.encode("utf-8"))
56
+ return signer.verify(digest, signature)
57
+
58
+
59
+ def _body_digest(body: str) -> str:
60
+ h = hashlib.new("sha256")
61
+ h.update(body) # type: ignore
62
+ return "SHA-256=" + base64.b64encode(h.digest()).decode("utf-8")
63
+
64
+
65
+ def _get_public_key(key_id: str) -> Key:
66
+ actor = get_backend().fetch_iri(key_id)
67
+ if _has_type(actor["type"], "Key"):
68
+ # The Key is not embedded in the Person
69
+ k = Key(actor["owner"], actor["id"])
70
+ k.load_pub(actor["publicKeyPem"])
71
+ else:
72
+ k = Key(actor["id"], actor["publicKey"]["id"])
73
+ k.load_pub(actor["publicKey"]["publicKeyPem"])
74
+
75
+ # Ensure the right key was fetch
76
+ if key_id != k.key_id():
77
+ raise ValueError(
78
+ f"failed to fetch requested key {key_id}: got {actor['publicKey']['id']}"
79
+ )
80
+
81
+ return k
82
+
83
+
84
+ def verify_request(method: str, path: str, headers: Any, body: str) -> bool:
85
+ hsig = _parse_sig_header(headers.get("Signature"))
86
+ if not hsig:
87
+ logger.debug("no signature in header")
88
+ return False
89
+ logger.debug(f"hsig={hsig}")
90
+ signed_string = _build_signed_string(
91
+ hsig["headers"], method, path, headers, _body_digest(body)
92
+ )
93
+
94
+ try:
95
+ k = _get_public_key(hsig["keyId"])
96
+ except (ActivityGoneError, ActivityNotFoundError):
97
+ logger.debug("cannot get public key")
98
+ return False
99
+
100
+ return _verify_h(signed_string, base64.b64decode(hsig["signature"]), k.pubkey)
101
+
102
+
103
+ class HTTPSigAuth(AuthBase):
104
+ """Requests auth plugin for signing requests on the fly."""
105
+
106
+ def __init__(self, key: Key) -> None:
107
+ self.key = key
108
+
109
+ def __call__(self, r):
110
+ logger.info(f"keyid={self.key.key_id()}")
111
+ host = urlparse(r.url).netloc
112
+
113
+ bh = hashlib.new("sha256")
114
+ body = r.body
115
+ try:
116
+ body = r.body.encode("utf-8")
117
+ except AttributeError:
118
+ pass
119
+ bh.update(body)
120
+ bodydigest = "SHA-256=" + base64.b64encode(bh.digest()).decode("utf-8")
121
+
122
+ date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")
123
+
124
+ r.headers.update({"Digest": bodydigest, "Date": date, "Host": host})
125
+
126
+ sigheaders = "(request-target) user-agent host date digest content-type"
127
+
128
+ to_be_signed = _build_signed_string(
129
+ sigheaders, r.method, r.path_url, r.headers, bodydigest
130
+ )
131
+ signer = PKCS1_v1_5.new(self.key.privkey)
132
+ digest = SHA256.new()
133
+ digest.update(to_be_signed.encode("utf-8"))
134
+ sig = base64.b64encode(signer.sign(digest))
135
+ sig = sig.decode("utf-8")
136
+
137
+ key_id = self.key.key_id()
138
+ headers = {
139
+ "Signature": f'keyId="{key_id}",algorithm="rsa-sha256",headers="{sigheaders}",signature="{sig}"'
140
+ }
141
+ logger.debug(f"signed request headers={headers}")
142
+
143
+ r.headers.update(headers)
144
+
145
+ return r
active_boxes/key.py ADDED
@@ -0,0 +1,63 @@
1
+ import base64
2
+ from typing import Any
3
+ from typing import Dict
4
+ from typing import Optional
5
+
6
+ from Crypto.PublicKey import RSA
7
+ from Crypto.Util import number
8
+
9
+
10
+ class Key(object):
11
+ DEFAULT_KEY_SIZE = 2048
12
+
13
+ def __init__(self, owner: str, id_: Optional[str] = None) -> None:
14
+ self.owner = owner
15
+ self.privkey_pem: Optional[str] = None
16
+ self.pubkey_pem: Optional[str] = None
17
+ self.privkey: Optional[RSA.RsaKey] = None
18
+ self.pubkey: Optional[RSA.RsaKey] = None
19
+ self.id_ = id_
20
+
21
+ def load_pub(self, pubkey_pem: str) -> None:
22
+ self.pubkey_pem = pubkey_pem
23
+ self.pubkey = RSA.importKey(pubkey_pem)
24
+
25
+ def load(self, privkey_pem: str) -> None:
26
+ self.privkey_pem = privkey_pem
27
+ self.privkey = RSA.importKey(self.privkey_pem)
28
+ self.pubkey_pem = self.privkey.publickey().exportKey("PEM").decode("utf-8")
29
+
30
+ def new(self) -> None:
31
+ k = RSA.generate(self.DEFAULT_KEY_SIZE)
32
+ self.privkey_pem = k.exportKey("PEM").decode("utf-8")
33
+ self.pubkey_pem = k.publickey().exportKey("PEM").decode("utf-8")
34
+ self.privkey = k
35
+
36
+ def key_id(self) -> str:
37
+ return self.id_ or f"{self.owner}#main-key"
38
+
39
+ def to_dict(self) -> Dict[str, Any]:
40
+ return {
41
+ "id": self.key_id(),
42
+ "owner": self.owner,
43
+ "publicKeyPem": self.pubkey_pem,
44
+ "type": "Key",
45
+ }
46
+
47
+ @classmethod
48
+ def from_dict(cls, data):
49
+ try:
50
+ k = cls(data["owner"], data["id"])
51
+ k.load_pub(data["publicKeyPem"])
52
+ except KeyError:
53
+ raise ValueError(f"bad key data {data!r}")
54
+ return k
55
+
56
+ def to_magic_key(self) -> str:
57
+ mod = base64.urlsafe_b64encode(
58
+ number.long_to_bytes(self.privkey.n) # type: ignore
59
+ ).decode("utf-8")
60
+ pubexp = base64.urlsafe_b64encode(
61
+ number.long_to_bytes(self.privkey.e) # type: ignore
62
+ ).decode("utf-8")
63
+ return f"data:application/magic-public-key,RSA.{mod}.{pubexp}"
@@ -0,0 +1,83 @@
1
+ import base64
2
+ import hashlib
3
+ import typing
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from typing import Dict
7
+
8
+ from Crypto.Hash import SHA256
9
+ from Crypto.Signature import PKCS1_v1_5
10
+ from pyld import jsonld
11
+
12
+ if typing.TYPE_CHECKING:
13
+ from .key import Key # noqa: type checking
14
+
15
+
16
+ # cache the downloaded "schemas", otherwise the library is super slow
17
+ # (https://github.com/digitalbazaar/pyld/issues/70)
18
+ _CACHE: Dict[str, Any] = {}
19
+ LOADER = jsonld.requests_document_loader()
20
+
21
+
22
+ def _caching_document_loader(url: str) -> Any:
23
+ if url in _CACHE:
24
+ return _CACHE[url]
25
+ resp = LOADER(url)
26
+ _CACHE[url] = resp
27
+ return resp
28
+
29
+
30
+ jsonld.set_document_loader(_caching_document_loader)
31
+
32
+
33
+ def _options_hash(doc):
34
+ doc = dict(doc["signature"])
35
+ for k in ["type", "id", "signatureValue"]:
36
+ if k in doc:
37
+ del doc[k]
38
+ doc["@context"] = "https://w3id.org/identity/v1"
39
+ normalized = jsonld.normalize(
40
+ doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
41
+ )
42
+ h = hashlib.new("sha256")
43
+ h.update(normalized.encode("utf-8"))
44
+ return h.hexdigest()
45
+
46
+
47
+ def _doc_hash(doc):
48
+ doc = dict(doc)
49
+ if "signature" in doc:
50
+ del doc["signature"]
51
+ normalized = jsonld.normalize(
52
+ doc, {"algorithm": "URDNA2015", "format": "application/nquads"}
53
+ )
54
+ h = hashlib.new("sha256")
55
+ h.update(normalized.encode("utf-8"))
56
+ return h.hexdigest()
57
+
58
+
59
+ def verify_signature(doc, key: "Key"):
60
+ to_be_signed = _options_hash(doc) + _doc_hash(doc)
61
+ signature = doc["signature"]["signatureValue"]
62
+ signer = PKCS1_v1_5.new(key.pubkey or key.privkey) # type: ignore
63
+ digest = SHA256.new()
64
+ digest.update(to_be_signed.encode("utf-8"))
65
+ return signer.verify(digest, base64.b64decode(signature)) # type: ignore
66
+
67
+
68
+ def generate_signature(doc, key: "Key"):
69
+ options = {
70
+ "type": "RsaSignature2017",
71
+ "creator": doc["actor"] + "#main-key",
72
+ "created": datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
73
+ }
74
+ doc["signature"] = options
75
+ to_be_signed = _options_hash(doc) + _doc_hash(doc)
76
+ if not key.privkey:
77
+ raise ValueError(f"missing privkey on key {key!r}")
78
+
79
+ signer = PKCS1_v1_5.new(key.privkey)
80
+ digest = SHA256.new()
81
+ digest.update(to_be_signed.encode("utf-8"))
82
+ sig = base64.b64encode(signer.sign(digest)) # type: ignore
83
+ options["signatureValue"] = sig.decode("utf-8")