kopia lustrzana https://github.com/snarfed/bridgy-fed
AP users: rename [is_]homepage => [is_]web_url, move into Protocol subclasses
for #512circle-datastore-transactions
rodzic
28eabd07a3
commit
958f81ddd1
|
@ -54,6 +54,24 @@ class ActivityPub(User, Protocol):
|
|||
"""
|
||||
LABEL = 'activitypub'
|
||||
|
||||
def web_url(self):
|
||||
"""Returns this user's web URL aka web_url, eg 'https://foo.com/'."""
|
||||
return util.get_url(self.actor_as2) or self.ap_actor()
|
||||
|
||||
def ap_address(self):
|
||||
"""Returns this user's ActivityPub address, eg '@foo.com@foo.com'."""
|
||||
if self.direct:
|
||||
return f'@{self.username()}@{self.key.id()}'
|
||||
else:
|
||||
return f'@{self.key.id()}@{request.host}'
|
||||
|
||||
def ap_actor(self, rest=None):
|
||||
"""Returns this user's ActivityPub/AS2 actor id URL.
|
||||
|
||||
Eg 'https://fed.brid.gy/foo.com'
|
||||
"""
|
||||
return self.key.id()
|
||||
|
||||
@classmethod
|
||||
def send(cls, obj, url, log_data=True):
|
||||
"""Delivers an activity to an inbox URL."""
|
||||
|
@ -373,13 +391,13 @@ def postprocess_as2(activity, target=None, wrap=True):
|
|||
activity['object'] = target_id
|
||||
elif not id:
|
||||
obj['id'] = util.get_first(obj, 'url') or target_id
|
||||
elif g.user and g.user.is_homepage(id):
|
||||
elif g.user and g.user.is_web_url(id):
|
||||
obj['id'] = g.user.ap_actor()
|
||||
elif g.external_user:
|
||||
obj['id'] = redirect_wrap(g.external_user)
|
||||
|
||||
# for Accepts
|
||||
if g.user and g.user.is_homepage(obj.get('object')):
|
||||
if g.user and g.user.is_web_url(obj.get('object')):
|
||||
obj['object'] = g.user.ap_actor()
|
||||
elif g.external_user and g.external_user == obj.get('object'):
|
||||
obj['object'] = redirect_wrap(g.external_user)
|
||||
|
@ -469,11 +487,11 @@ def postprocess_as2_actor(actor, wrap=True):
|
|||
if not actor:
|
||||
return actor
|
||||
elif isinstance(actor, str):
|
||||
if g.user and g.user.is_homepage(actor):
|
||||
if g.user and g.user.is_web_url(actor):
|
||||
return g.user.ap_actor()
|
||||
return redirect_wrap(actor)
|
||||
|
||||
url = g.user.homepage if g.user else None
|
||||
url = g.user.web_url() if g.user else None
|
||||
urls = util.get_list(actor, 'url')
|
||||
if not urls and url:
|
||||
urls = [url]
|
||||
|
@ -483,7 +501,7 @@ def postprocess_as2_actor(actor, wrap=True):
|
|||
urls[0] = redirect_wrap(urls[0])
|
||||
|
||||
id = actor.get('id')
|
||||
if g.user and (not id or g.user.is_homepage(id)):
|
||||
if g.user and (not id or g.user.is_web_url(id)):
|
||||
actor['id'] = g.user.ap_actor()
|
||||
elif g.external_user and (not id or id == g.external_user):
|
||||
actor['id'] = redirect_wrap(g.external_user)
|
||||
|
|
|
@ -95,7 +95,7 @@ def pretty_link(url, text=None, **kwargs):
|
|||
text: str
|
||||
kwargs: passed through to :func:`webutil.util.pretty_link`
|
||||
"""
|
||||
if g.user and g.user.is_homepage(url):
|
||||
if g.user and g.user.is_web_url(url):
|
||||
return g.user.user_page_link()
|
||||
|
||||
if text is None:
|
||||
|
|
76
models.py
76
models.py
|
@ -103,11 +103,6 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
"""Try to prevent instantiation. Use subclasses instead."""
|
||||
raise NotImplementedError()
|
||||
|
||||
# TODO(#512): move this and is_homepage to web.py?
|
||||
@property
|
||||
def homepage(self):
|
||||
return f'https://{self.key.id()}/'
|
||||
|
||||
def _post_put_hook(self, future):
|
||||
logger.info(f'Wrote {self.key}')
|
||||
|
||||
|
@ -200,16 +195,48 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
logger.info(f'Defaulting username to key id {id}')
|
||||
return id
|
||||
|
||||
def web_url(self):
|
||||
"""Returns this user's web URL aka web_url, eg 'https://foo.com/'.
|
||||
|
||||
To be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
str
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_web_url(self, url):
|
||||
"""Returns True if the given URL is this user's web URL (web_url).
|
||||
|
||||
Args:
|
||||
url: str
|
||||
|
||||
Returns:
|
||||
boolean
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
url = url.strip().rstrip('/')
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
if parsed_url.scheme not in ('http', 'https', ''):
|
||||
return False
|
||||
|
||||
this = self.web_url().rstrip('/')
|
||||
parsed_this = urllib.parse.urlparse(this)
|
||||
|
||||
return (url == this or url == parsed_this.netloc or
|
||||
parsed_url[1:] == parsed_this[1:]) # ignore http vs https
|
||||
|
||||
def ap_address(self):
|
||||
"""Returns this user's ActivityPub address, eg '@me@foo.com'.
|
||||
|
||||
To be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
str
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
if self.direct:
|
||||
return f'@{self.username()}@{self.key.id()}'
|
||||
else:
|
||||
return f'@{self.key.id()}@{request.host}'
|
||||
|
||||
def ap_actor(self, rest=None):
|
||||
"""Returns this user's ActivityPub/AS2 actor id.
|
||||
|
@ -217,33 +244,14 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
Eg 'https://fed.brid.gy/ap/bluesky/foo.com'
|
||||
|
||||
To be implemented by subclasses.
|
||||
|
||||
Args:
|
||||
rest: str, optional, appended to URL path
|
||||
|
||||
Returns:
|
||||
str
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
if self.direct or rest:
|
||||
# special case Web users to skip /ap/web/ prefix, for backward compatibility
|
||||
url = common.host_url(self.key.id() if self.LABEL == 'web'
|
||||
else f'/ap{self.user_page_path()}')
|
||||
if rest:
|
||||
url += f'/{rest}'
|
||||
return url
|
||||
# TODO(#512): drop once we fetch site if web user doesn't already exist
|
||||
else:
|
||||
return redirect_wrap(self.homepage)
|
||||
|
||||
def is_homepage(self, url):
|
||||
"""Returns True if the given URL points to this user's home page."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
url = url.strip().rstrip('/')
|
||||
if url == self.key.id():
|
||||
return True
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
return (parsed.netloc == self.key.id()
|
||||
and parsed.scheme in ('', 'http', 'https')
|
||||
and not parsed.path and not parsed.query
|
||||
and not parsed.params and not parsed.fragment)
|
||||
|
||||
def user_page_path(self, rest=None):
|
||||
"""Returns the user's Bridgy Fed user page path."""
|
||||
|
|
4
pages.py
4
pages.py
|
@ -131,7 +131,7 @@ def feed(protocol, domain):
|
|||
|
||||
actor = {
|
||||
'displayName': domain,
|
||||
'url': g.user.homepage,
|
||||
'url': g.user.web_url(),
|
||||
}
|
||||
title = f'Bridgy Fed feed for {domain}'
|
||||
|
||||
|
@ -277,7 +277,7 @@ def nodeinfo():
|
|||
'name': 'bridgy-fed',
|
||||
'version': os.getenv('GAE_VERSION'),
|
||||
'repository': 'https://github.com/snarfed/bridgy-fed',
|
||||
'homepage': 'https://fed.brid.gy/',
|
||||
'web_url': 'https://fed.brid.gy/',
|
||||
},
|
||||
'protocols': [
|
||||
'activitypub',
|
||||
|
|
|
@ -278,11 +278,11 @@ class ActivityPubTest(TestCase):
|
|||
self.assertTrue(type.startswith(as2.CONTENT_TYPE), type)
|
||||
self.assertEqual({
|
||||
'preferredUsername': 'fake.com',
|
||||
'id': 'http://bf/fake/ap',
|
||||
'inbox': 'http://bf/fake/ap/inbox',
|
||||
'outbox': 'http://bf/fake/ap/outbox',
|
||||
'following': 'http://bf/fake/ap/following',
|
||||
'followers': 'http://bf/fake/ap/followers',
|
||||
'id': 'http://bf/fake.com/ap',
|
||||
'inbox': 'http://bf/fake.com/ap/inbox',
|
||||
'outbox': 'http://bf/fake.com/ap/outbox',
|
||||
'following': 'http://bf/fake.com/ap/following',
|
||||
'followers': 'http://bf/fake.com/ap/followers',
|
||||
'endpoints': {
|
||||
'sharedInbox': 'http://localhost/ap/sharedInbox',
|
||||
},
|
||||
|
@ -1259,8 +1259,8 @@ class ActivityPubUtilsTest(TestCase):
|
|||
def setUp(self):
|
||||
super().setUp()
|
||||
self.request_context.push()
|
||||
g.user = self.make_user('user.com', has_hcard=True,
|
||||
actor_as2=ACTOR)
|
||||
g.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
|
||||
|
||||
def tearDown(self):
|
||||
self.request_context.pop()
|
||||
super().tearDown()
|
||||
|
@ -1302,16 +1302,16 @@ class ActivityPubUtilsTest(TestCase):
|
|||
'actor': {
|
||||
'id': 'baj',
|
||||
'preferredUsername': 'site',
|
||||
'url': 'http://localhost/r/https://site/',
|
||||
'url': 'http://localhost/r/https://site',
|
||||
},
|
||||
'attributedTo': [{
|
||||
'id': 'bar',
|
||||
'preferredUsername': 'site',
|
||||
'url': 'http://localhost/r/https://site/',
|
||||
'url': 'http://localhost/r/https://site',
|
||||
}, {
|
||||
'id': 'baz',
|
||||
'preferredUsername': 'site',
|
||||
'url': 'http://localhost/r/https://site/',
|
||||
'url': 'http://localhost/r/https://site',
|
||||
}],
|
||||
'to': [as2.PUBLIC_AUDIENCE],
|
||||
}, activitypub.postprocess_as2({
|
||||
|
@ -1542,3 +1542,21 @@ class ActivityPubUtilsTest(TestCase):
|
|||
activitypub.postprocess_as2(obj),
|
||||
activitypub.postprocess_as2(activitypub.postprocess_as2(obj)),
|
||||
ignore=['to'])
|
||||
|
||||
def test_ap_actor(self):
|
||||
user = self.make_user('http://foo/actor', cls=ActivityPub)
|
||||
self.assertEqual('http://foo/actor', user.ap_actor())
|
||||
|
||||
def test_ap_address(self):
|
||||
user = self.make_user('http://foo/actor', cls=ActivityPub)
|
||||
self.assertEqual('http://foo/actor', user.ap_actor())
|
||||
|
||||
def test_web_url(self):
|
||||
user = self.make_user('http://foo/actor', cls=ActivityPub)
|
||||
self.assertEqual('http://foo/actor', user.web_url())
|
||||
|
||||
user.actor_as2 = copy.deepcopy(ACTOR) # no url
|
||||
self.assertEqual('http://foo/actor', user.web_url())
|
||||
|
||||
user.actor_as2['url'] = ['http://my/url']
|
||||
self.assertEqual('http://my/url', user.web_url())
|
||||
|
|
|
@ -86,6 +86,14 @@ class UserTest(TestCase):
|
|||
g.user.actor_as2 = ACTOR
|
||||
self.assertEqual('<a class="h-card u-author" href="/web/y.z"><img src="https://user.com/me.jpg" class="profile"> Mrs. ☕ Foo</a>', g.user.user_page_link())
|
||||
|
||||
def test_is_web_url(self):
|
||||
for url in 'y.z', '//y.z', 'http://y.z', 'https://y.z':
|
||||
self.assertTrue(g.user.is_web_url(url), url)
|
||||
|
||||
for url in (None, '', 'user', 'com', 'com.user', 'ftp://y.z',
|
||||
'https://user', '://y.z'):
|
||||
self.assertFalse(g.user.is_web_url(url), url)
|
||||
|
||||
|
||||
class ObjectTest(TestCase):
|
||||
def setUp(self):
|
||||
|
|
|
@ -1501,16 +1501,31 @@ http://this/404s
|
|||
'preferredUsername': 'user.com',
|
||||
})
|
||||
|
||||
def test_homepage(self, _, __):
|
||||
self.assertEqual('https://user.com/', self.user.homepage)
|
||||
def test_web_url(self, _, __):
|
||||
self.assertEqual('https://user.com/', self.user.web_url())
|
||||
|
||||
def test_is_homepage(self, _, __):
|
||||
for url in 'user.com', '//user.com', 'http://user.com', 'https://user.com':
|
||||
self.assertTrue(self.user.is_homepage(url), url)
|
||||
def test_ap_address(self, *_):
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
for url in (None, '', 'user', 'com', 'com.user', 'ftp://user.com',
|
||||
'https://user', '://user.com'):
|
||||
self.assertFalse(self.user.is_homepage(url), url)
|
||||
g.user.actor_as2 = {'type': 'Person'}
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
g.user.actor_as2 = {'url': 'http://foo'}
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']}
|
||||
self.assertEqual('@baz@user.com', g.user.ap_address())
|
||||
|
||||
g.user.direct = False
|
||||
self.assertEqual('@user.com@localhost', g.user.ap_address())
|
||||
|
||||
def test_ap_actor(self, *_):
|
||||
self.assertEqual('http://localhost/user.com', g.user.ap_actor())
|
||||
|
||||
g.user.direct = False
|
||||
self.assertEqual('http://localhost/r/https://user.com/', g.user.ap_actor())
|
||||
|
||||
self.assertEqual('http://localhost/user.com/inbox', g.user.ap_actor('inbox'))
|
||||
|
||||
def test_check_web_site(self, mock_get, _):
|
||||
redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com'
|
||||
|
@ -1547,29 +1562,6 @@ http://this/404s
|
|||
self.assertTrue(get_flashed_messages()[0].startswith(
|
||||
"Couldn't connect to https://orig/: "))
|
||||
|
||||
def test_ap_address(self, *_):
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
g.user.actor_as2 = {'type': 'Person'}
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
g.user.actor_as2 = {'url': 'http://foo'}
|
||||
self.assertEqual('@user.com@user.com', g.user.ap_address())
|
||||
|
||||
g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']}
|
||||
self.assertEqual('@baz@user.com', g.user.ap_address())
|
||||
|
||||
g.user.direct = False
|
||||
self.assertEqual('@user.com@localhost', g.user.ap_address())
|
||||
|
||||
def test_ap_actor(self, *_):
|
||||
self.assertEqual('http://localhost/user.com', g.user.ap_actor())
|
||||
|
||||
g.user.direct = False
|
||||
self.assertEqual('http://localhost/r/https://user.com/', g.user.ap_actor())
|
||||
|
||||
self.assertEqual('http://localhost/user.com/inbox', g.user.ap_actor('inbox'))
|
||||
|
||||
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
|
|
|
@ -44,11 +44,14 @@ class Fake(User, protocol.Protocol):
|
|||
# in-order list of ids
|
||||
fetched = []
|
||||
|
||||
def web_url(self):
|
||||
return f'https://{self.key.id()}'
|
||||
|
||||
def ap_address(self):
|
||||
return '@fake@fake'
|
||||
return f'@{self.key.id()}@fake'
|
||||
|
||||
def ap_actor(self, rest=None):
|
||||
return 'http://bf/fake/ap' + (f'/{rest}' if rest else '')
|
||||
return f'http://bf/{self.key.id()}/ap' + (f'/{rest}' if rest else '')
|
||||
|
||||
@classmethod
|
||||
def send(cls, obj, url, log_data=True):
|
||||
|
|
30
web.py
30
web.py
|
@ -49,6 +49,10 @@ class Web(User, Protocol):
|
|||
def _get_kind(cls):
|
||||
return 'MagicKey'
|
||||
|
||||
def web_url(self):
|
||||
"""Returns this user's web URL aka web_url, eg 'https://foo.com/'."""
|
||||
return f'https://{self.key.id()}/'
|
||||
|
||||
def ap_address(self):
|
||||
"""Returns this user's ActivityPub address, eg '@foo.com@foo.com'.
|
||||
|
||||
|
@ -74,7 +78,7 @@ class Web(User, Protocol):
|
|||
return url
|
||||
|
||||
# TODO(#512): drop once we fetch site if web user doesn't already exist
|
||||
return common.redirect_wrap(self.homepage)
|
||||
return common.redirect_wrap(self.web_url())
|
||||
|
||||
def verify(self):
|
||||
"""Fetches site a couple ways to check for redirects and h-card.
|
||||
|
@ -94,7 +98,7 @@ class Web(User, Protocol):
|
|||
root_site = f'https://{root}/'
|
||||
try:
|
||||
resp = util.requests_get(root_site, gateway=False)
|
||||
if resp.ok and self.is_homepage(resp.url):
|
||||
if resp.ok and self.is_web_url(resp.url):
|
||||
logger.info(f'{root_site} redirects to {resp.url} ; using {root} instead')
|
||||
root_user = Web.get_or_create(root)
|
||||
self.use_instead = root_user.key
|
||||
|
@ -108,7 +112,7 @@ class Web(User, Protocol):
|
|||
self.has_redirects = False
|
||||
self.redirects_error = None
|
||||
try:
|
||||
url = urljoin(self.homepage, path)
|
||||
url = urljoin(self.web_url(), path)
|
||||
resp = util.requests_get(url, gateway=False)
|
||||
domain_urls = ([f'https://{domain}/' for domain in common.DOMAINS] +
|
||||
[common.host_url()])
|
||||
|
@ -129,7 +133,7 @@ class Web(User, Protocol):
|
|||
|
||||
# check home page
|
||||
try:
|
||||
obj = Web.load(self.homepage, gateway=True)
|
||||
obj = Web.load(self.web_url(), gateway=True)
|
||||
self.actor_as2 = activitypub.postprocess_as2(as2.from_as1(obj.as1))
|
||||
self.has_hcard = True
|
||||
except (BadRequest, NotFound):
|
||||
|
@ -168,12 +172,12 @@ class Web(User, Protocol):
|
|||
check_backlink: bool, optional, whether to require a link to Bridgy Fed
|
||||
"""
|
||||
url = obj.key.id()
|
||||
is_homepage = ((g.user and g.user.is_homepage(url)) or
|
||||
is_web_url = ((g.user and g.user.is_web_url(url)) or
|
||||
(g.external_user and g.external_user == url))
|
||||
|
||||
|
||||
require_backlink = None
|
||||
if check_backlink or (check_backlink is None and not is_homepage):
|
||||
if check_backlink or (check_backlink is None and not is_web_url):
|
||||
require_backlink = common.host_url().rstrip('/')
|
||||
|
||||
try:
|
||||
|
@ -186,8 +190,8 @@ class Web(User, Protocol):
|
|||
error(f'id {urlparse(url).fragment} not found in {url}')
|
||||
|
||||
# find mf2 item
|
||||
if is_homepage:
|
||||
logger.info(f"{url} is user's homepage")
|
||||
if is_web_url:
|
||||
logger.info(f"{url} is user's web url")
|
||||
entry = mf2util.representative_hcard(parsed, parsed['url'])
|
||||
logger.info(f'Representative h-card: {json_dumps(entry, indent=2)}')
|
||||
if not entry:
|
||||
|
@ -200,7 +204,7 @@ class Web(User, Protocol):
|
|||
# store final URL in mf2 object, and also default url property to it,
|
||||
# since that's the fallback for AS1/AS2 id
|
||||
entry['url'] = parsed['url']
|
||||
if is_homepage:
|
||||
if is_web_url:
|
||||
entry.setdefault('rel-urls', {}).update(parsed.get('rel-urls', {}))
|
||||
props = entry.setdefault('properties', {})
|
||||
props.setdefault('url', [parsed['url']])
|
||||
|
@ -209,7 +213,7 @@ class Web(User, Protocol):
|
|||
# run full authorship algorithm if necessary: https://indieweb.org/authorship
|
||||
# duplicated in microformats2.json_to_object
|
||||
author = util.get_first(props, 'author')
|
||||
if not isinstance(author, dict) and not is_homepage:
|
||||
if not isinstance(author, dict) and not is_web_url:
|
||||
logger.info(f'Fetching full authorship for author {author}')
|
||||
author = mf2util.find_author({'items': [entry]}, hentry=entry,
|
||||
fetch_mf2_func=util.fetch_mf2)
|
||||
|
@ -329,7 +333,7 @@ def webmention_interactive():
|
|||
"""
|
||||
try:
|
||||
webmention_external()
|
||||
flash(f'Updating fediverse profile from <a href="{g.user.homepage}">{g.user.key.id()}</a>...')
|
||||
flash(f'Updating fediverse profile from <a href="{g.user.web_url()}">{g.user.key.id()}</a>...')
|
||||
except HTTPException as e:
|
||||
flash(util.linkify(str(e.description), pretty=True))
|
||||
|
||||
|
@ -379,14 +383,14 @@ def webmention_task():
|
|||
# set actor to user
|
||||
props = obj.mf2['properties']
|
||||
author_urls = microformats2.get_string_urls(props.get('author', []))
|
||||
if author_urls and not g.user.is_homepage(author_urls[0]):
|
||||
if author_urls and not g.user.is_web_url(author_urls[0]):
|
||||
logger.info(f'Overriding author {author_urls[0]} with {g.user.ap_actor()}')
|
||||
props['author'] = [g.user.ap_actor()]
|
||||
|
||||
logger.info(f'Converted to AS1: {obj.type}: {json_dumps(obj.as1, indent=2)}')
|
||||
|
||||
# if source is home page, send an actor Update to followers' instances
|
||||
if g.user.is_homepage(obj.key.id()):
|
||||
if g.user.is_web_url(obj.key.id()):
|
||||
obj.put()
|
||||
actor_as1 = {
|
||||
**obj.as1,
|
||||
|
|
|
@ -54,20 +54,17 @@ class Actor(flask_util.XrdOrJrd):
|
|||
error(f'No user or web site found for {domain}', status=404)
|
||||
|
||||
actor = g.user.to_as1() or {}
|
||||
homepage = g.user.homepage
|
||||
handle = g.user.ap_address()
|
||||
|
||||
logger.info(f'Generating WebFinger data for {domain}')
|
||||
logger.info(f'AS1 actor: {actor}')
|
||||
urls = util.dedupe_urls(util.get_list(actor, 'urls') +
|
||||
util.get_list(actor, 'url') +
|
||||
[homepage])
|
||||
[g.user.web_url()])
|
||||
logger.info(f'URLs: {urls}')
|
||||
canonical_url = urls[0]
|
||||
|
||||
# generate webfinger content
|
||||
data = util.trim_nulls({
|
||||
'subject': 'acct:' + handle.lstrip('@'),
|
||||
'subject': 'acct:' + g.user.ap_address().lstrip('@'),
|
||||
'aliases': urls,
|
||||
'links':
|
||||
[{
|
||||
|
|
Ładowanie…
Reference in New Issue